diff --git a/packages/jui/integration-tests/modal-window_menu.cy.tsx b/packages/jui/integration-tests/modal-window_menu.cy.tsx
index 1f864712..29c98264 100644
--- a/packages/jui/integration-tests/modal-window_menu.cy.tsx
+++ b/packages/jui/integration-tests/modal-window_menu.cy.tsx
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import {
- ActionButton,
+ IconButton,
FocusScope,
Item,
Menu,
@@ -31,9 +31,9 @@ const ModalOnMenuItem = () => {
)}
>
{(props, ref) => (
-
+
-
+
)}
{isOpen && (
diff --git a/packages/jui/package.json b/packages/jui/package.json
index 7e730f7d..c95c957f 100644
--- a/packages/jui/package.json
+++ b/packages/jui/package.json
@@ -81,7 +81,7 @@
"@babel/core": "^7.13.15",
"@babel/plugin-proposal-decorators": "^7.17.12",
"@babel/preset-typescript": "7.13.0",
- "@percy/cli": "^1.16.0",
+ "@percy/cli": "^1.27.1",
"@percy/cypress": "^3.1.2",
"@react-stately/data": "^3.4.2",
"@react-types/button": "^3.4.1",
@@ -103,9 +103,9 @@
"buffer": "^6.0.3",
"circular-dependency-plugin": "^5.2.2",
"crypto-browserify": "^3.12.0",
- "cypress": "^12.1.0",
+ "cypress": "^13.2.0",
"cypress-plugin-snapshots": "1.4.4",
- "cypress-real-events": "^1.7.4",
+ "cypress-real-events": "1.7.4",
"hygen": "^6.2.11",
"jest": "^29.0.3",
"path-browserify": "^1.0.1",
diff --git a/packages/jui/src/ActionButton/index.ts b/packages/jui/src/ActionButton/index.ts
deleted file mode 100644
index 4b6341ed..00000000
--- a/packages/jui/src/ActionButton/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./ActionButton";
diff --git a/packages/jui/src/ActionSystem/Action.ts b/packages/jui/src/ActionSystem/Action.ts
new file mode 100644
index 00000000..e22fc737
--- /dev/null
+++ b/packages/jui/src/ActionSystem/Action.ts
@@ -0,0 +1,72 @@
+import React from "react";
+import { Shortcut } from "@intellij-platform/core/ActionSystem/Shortcut";
+
+export interface ActionContext {
+ element: Element | null;
+ /**
+ * UI event that triggered the action, if a shortcut triggered the action.
+ */
+ event:
+ | React.MouseEvent
+ | React.KeyboardEvent
+ | null;
+}
+
+/**
+ * Represents the definition of an action.
+ * @interface
+ */
+export interface ActionDefinition {
+ /**
+ * The unique identifier for the action. Used to assign shortcuts to the action, via a {@link Keymap}.
+ */
+ id: string;
+ /**
+ * The title of an action.
+ * This value will be used as the text in UI display for the action.
+ */
+ title: string;
+ /**
+ * The function that will be executed when the action is performed.
+ * @param context It provides further information about the action event.
+ */
+ actionPerformed: (context: ActionContext) => void;
+ /**
+ * An optional icon for an action.
+ * If provided, it will be displayed along with the title in the UI.
+ */
+ icon?: React.ReactNode;
+ /**
+ * An optional description for an action.
+ * If provided, it can be displayed as additional information about the action in the UI.
+ */
+ description?: string;
+ /**
+ * An optional disable state for an action.
+ * If set to `true`, this action would be in disabled state and cannot be performed.
+ */
+ isDisabled?: boolean;
+}
+
+export interface MutableAction
+ extends Pick<
+ ActionDefinition,
+ "title" | "icon" | "description" | "isDisabled"
+ > {
+ id: string;
+ /**
+ * shortcuts assigned to this action based on the keymap context
+ */
+ shortcuts: readonly Shortcut[] | undefined;
+ /**
+ * string representation of the shortcuts
+ */
+ shortcut: string | undefined;
+
+ /**
+ * Performs the action, if it's enabled.
+ */
+ perform: (context?: ActionContext) => void;
+}
+
+export type Action = Readonly;
diff --git a/packages/jui/src/ActionSystem/ActionGroup.tsx b/packages/jui/src/ActionSystem/ActionGroup.tsx
index 72c6e34c..f6cc757c 100644
--- a/packages/jui/src/ActionSystem/ActionGroup.tsx
+++ b/packages/jui/src/ActionSystem/ActionGroup.tsx
@@ -1,7 +1,7 @@
import {
Action,
ActionDefinition,
-} from "@intellij-platform/core/ActionSystem/ActionsProvider";
+} from "@intellij-platform/core/ActionSystem/Action";
export type ActionInResolvedGroup = Action & { parent: ResolvedActionGroup };
diff --git a/packages/jui/src/ActionSystem/ActionsProvider.tsx b/packages/jui/src/ActionSystem/ActionsProvider.tsx
index 01ca9bdf..4af23e7e 100644
--- a/packages/jui/src/ActionSystem/ActionsProvider.tsx
+++ b/packages/jui/src/ActionSystem/ActionsProvider.tsx
@@ -1,12 +1,11 @@
-import {
- Keymap,
- useKeymap,
-} from "@intellij-platform/core/ActionSystem/KeymapProvider";
import { pick, sortBy } from "ramda";
import React, { HTMLAttributes, useContext, useEffect, useState } from "react";
-import { shortcutToString } from "@intellij-platform/core/ActionSystem/shortcutToString";
-import { useShortcuts } from "@intellij-platform/core/ActionSystem/useShortcut";
-import { Shortcut } from "@intellij-platform/core/ActionSystem/Shortcut";
+import { useEventCallback } from "@intellij-platform/core/utils/useEventCallback";
+import { dfsVisit } from "@intellij-platform/core/utils/tree-utils";
+
+import { Keymap, useKeymap } from "./KeymapProvider";
+import { shortcutToString } from "./shortcutToString";
+import { useShortcuts } from "./useShortcut";
import {
ActionGroup,
ActionInResolvedGroup,
@@ -14,54 +13,20 @@ import {
isActionGroupDefinition,
MutableActionGroup,
} from "./ActionGroup";
-import { useEventCallback } from "@intellij-platform/core/utils/useEventCallback";
-import { dfsVisit } from "@intellij-platform/core/utils/tree-utils";
-
-export interface ActionContext {
- element: Element | null;
- event:
- | React.MouseEvent
- | React.KeyboardEvent
- | null;
-}
-
-export interface ActionDefinition {
- id: string;
- title: string;
- actionPerformed: (
- /**
- * UI event that triggered the action, if a shortcut triggered the action.
- */
- context: ActionContext
- ) => void;
- icon?: React.ReactNode;
- description?: string;
- isDisabled?: boolean;
-}
-
-export interface MutableAction
- extends Pick<
- ActionDefinition,
- "title" | "icon" | "description" | "isDisabled"
- > {
- id: string;
- /**
- * shortcuts assigned to this action based on the keymap context
- */
- shortcuts: readonly Shortcut[] | undefined;
- /**
- * string representation of the shortcuts
- */
- shortcut: string | undefined;
+import {
+ Action,
+ ActionContext,
+ ActionDefinition,
+ MutableAction,
+} from "@intellij-platform/core/ActionSystem/Action";
+/**
+ * Represents the properties required for the ActionsProvider component.
+ */
+interface ActionsProviderProps {
/**
- * Performs the action, if it's enabled.
+ * A collection of action definitions.
*/
- perform: (context?: ActionContext) => void;
-}
-export type Action = Readonly;
-
-interface ActionsProviderProps {
actions: ActionDefinition[];
children: (args: {
shortcutHandlerProps: HTMLAttributes;
@@ -83,6 +48,15 @@ function generateId() {
const ACTION_PROVIDER_ID_ATTRIBUTE = "data-action-provider";
const ACTION_PROVIDER_ID_DATA_PREFIX = "action_provider_id_";
const actionProvidersMap = new Map();
+
+/**
+ * Provides a set of actions for the wrapped UI. Uses the currently provided keymap to find the shortcuts
+ * for each action, and passes the necessary event handlers for the shortcuts, to the `children` render function.
+ *
+ * @param {Array} props.actions - The actions to be provided.
+ * @param {boolean} [props.useCapture] - Specifies whether to use capture phase for event handling.
+ * @param {Function} props.children - Render function that accepts shortcutHandlerProps as argument.
+ */
export function ActionsProvider(props: ActionsProviderProps): JSX.Element {
const parentContext = useContext(ActionsContext);
const keymap = useKeymap();
diff --git a/packages/jui/src/ActionSystem/KeymapProvider.tsx b/packages/jui/src/ActionSystem/KeymapProvider.tsx
index 586c383b..42030dc8 100644
--- a/packages/jui/src/ActionSystem/KeymapProvider.tsx
+++ b/packages/jui/src/ActionSystem/KeymapProvider.tsx
@@ -1,6 +1,6 @@
import React, { useContext } from "react";
-import { Shortcut } from "@intellij-platform/core/ActionSystem/Shortcut";
-import { defaultKeymap } from "@intellij-platform/core/ActionSystem/defaultKeymap";
+import { Shortcut } from "./Shortcut";
+import { defaultKeymap } from "./defaultKeymap";
export interface Keymap {
[actionId: string]: ReadonlyArray;
diff --git a/packages/jui/src/ActionSystem/components/ActionButton.tsx b/packages/jui/src/ActionSystem/components/ActionButton.tsx
index 8a6cb676..2030548a 100644
--- a/packages/jui/src/ActionSystem/components/ActionButton.tsx
+++ b/packages/jui/src/ActionSystem/components/ActionButton.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import { useAction } from "@intellij-platform/core/ActionSystem";
-import { ActionButton as ActionButtonUI } from "@intellij-platform/core/ActionButton";
+import { useAction } from "@intellij-platform/core/ActionSystem/ActionsProvider";
+import { IconButton } from "@intellij-platform/core/IconButton";
import { ActionTooltip, TooltipTrigger } from "@intellij-platform/core/Tooltip";
export const ActionButton = ({
@@ -18,12 +18,12 @@ export const ActionButton = ({
return <>>;
}
const actionButton = (
- action?.perform()}
isDisabled={action.isDisabled}
>
{action.icon || children}
-
+
);
if (action.title) {
return (
diff --git a/packages/jui/src/ActionSystem/components/ActionGroupMenu.tsx b/packages/jui/src/ActionSystem/components/ActionGroupMenu.tsx
index 51aa93a6..1eb71310 100644
--- a/packages/jui/src/ActionSystem/components/ActionGroupMenu.tsx
+++ b/packages/jui/src/ActionSystem/components/ActionGroupMenu.tsx
@@ -1,14 +1,12 @@
-import { ActionGroup } from "@intellij-platform/core/ActionSystem";
import React from "react";
-import { ActionMenuProps, ActionsMenu } from "./ActionsMenu";
+import { type ActionGroup } from "@intellij-platform/core/ActionSystem/ActionGroup";
+import { type ActionMenuProps, ActionsMenu } from "./ActionsMenu";
export type ActionGroupMenuProps = Omit & {
actionGroup: ActionGroup;
};
/**
- * Renders children of an action group as a menu
- *
- * TODO: handle isPopup in children groups to render the child group as either a section or submenu
+ * Renders children of an action group as a menu.
*/
export const ActionGroupMenu = ({
actionGroup,
diff --git a/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx b/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx
index 5e2c5def..eafc1569 100644
--- a/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx
+++ b/packages/jui/src/ActionSystem/components/ActionsMenu.cy.tsx
@@ -1,8 +1,5 @@
import React from "react";
-import {
- ActionItem,
- ActionsMenu,
-} from "@intellij-platform/core/ActionSystem/components";
+import { ActionItem, ActionsMenu } from "@intellij-platform/core";
import {
ActionGroupDefinition,
ActionsProvider,
@@ -75,6 +72,7 @@ describe("ActionsMenu", () => {
);
cy.findByRole("menuitem", { name: "Action 1" }).should("not.exist");
+ cy.findByRole("group", { name: "Action Group 1" }).should("not.exist");
cy.findByRole("menuitem", { name: "Action Group 1" }).click();
cy.findByRole("menuitem", { name: "Action 1" });
});
@@ -109,7 +107,7 @@ describe("ActionsMenu", () => {
);
cy.findByRole("menuitem", { name: "Action 1" });
- cy.findByRole("group", { name: "Action Group 1" });
+ cy.findByRole("group");
});
it("performs selected action", () => {
diff --git a/packages/jui/src/ActionSystem/components/ActionsMenu.tsx b/packages/jui/src/ActionSystem/components/ActionsMenu.tsx
index dbd963d2..f28bf973 100644
--- a/packages/jui/src/ActionSystem/components/ActionsMenu.tsx
+++ b/packages/jui/src/ActionSystem/components/ActionsMenu.tsx
@@ -1,13 +1,10 @@
import React from "react";
import { flatten } from "ramda";
import { Menu, MenuItemLayout } from "@intellij-platform/core/Menu";
-import {
- Divider,
- DividerItem,
- Item,
-} from "@intellij-platform/core/Collections";
-import { Action, ActionGroup } from "@intellij-platform/core/ActionSystem";
-import { Section } from "@react-stately/collections";
+import { Divider, Item, Section } from "@intellij-platform/core/Collections";
+import { DividerItem } from "@intellij-platform/core/Collections/Divider"; // Importing from /Collections breaks the build for some reason
+import { type ActionGroup } from "@intellij-platform/core/ActionSystem/ActionGroup";
+import { type Action } from "@intellij-platform/core/ActionSystem/Action";
type ActionGroupAsMenuItem = Pick<
ActionGroup,
@@ -16,7 +13,7 @@ type ActionGroupAsMenuItem = Pick<
export type ActionItem = ActionGroupAsMenuItem | Action | DividerItem;
function isAction(item: ActionItem): item is Action {
- return "actionPerformed" in item;
+ return "perform" in item;
}
export type ActionMenuProps = {
@@ -70,7 +67,10 @@ export function renderActionAsMenuItem(
const isGroup = "children" in action;
if (isGroup && !action.isPopup) {
return (
-
+ // `title` is intentionally not passed, as menu sections created from action groups usually don't have title.
+ // Maybe it should be an option?
+ // @ts-expect-error: hasDivider is not yet made a public API.
+
{renderActionAsMenuItem}
);
diff --git a/packages/jui/src/ActionSystem/components/index.ts b/packages/jui/src/ActionSystem/components/index.ts
index 12338391..6291bfde 100644
--- a/packages/jui/src/ActionSystem/components/index.ts
+++ b/packages/jui/src/ActionSystem/components/index.ts
@@ -1,13 +1,14 @@
-import { ActionButton } from "./ActionButton";
+/**
+ * Action system components. Intentionally re-exported only from the root index file, and not the index.ts in
+ * ActionSystem.
+ */
+export { ActionButton } from "./ActionButton";
export {
ActionsMenu,
+ renderActionAsMenuItem,
type ActionMenuProps,
type ActionItem,
} from "./ActionsMenu";
export { ActionGroupMenu, type ActionGroupMenuProps } from "./ActionGroupMenu";
-export const Action = {
- Button: ActionButton,
-};
-
export { useCreateDefaultActionGroup } from "./useCreateDefaultActionGroup";
diff --git a/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx b/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx
index 3cfd3c3d..4d327491 100644
--- a/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx
+++ b/packages/jui/src/ActionSystem/components/useCreateDefaultActionGroup.tsx
@@ -1,17 +1,15 @@
+import { flatten } from "ramda";
import React from "react";
import {
- ActionGroupDefinition,
+ type ActionGroupDefinition,
isActionGroupDefinition,
} from "@intellij-platform/core/ActionSystem/ActionGroup";
+import { useGetActionShortcut } from "@intellij-platform/core/ActionSystem/ActionShortcut";
import { Popup, usePopupManager } from "@intellij-platform/core/Popup";
-import {
- ActionContext,
- useGetActionShortcut,
-} from "@intellij-platform/core/ActionSystem";
import { SpeedSearchMenu } from "@intellij-platform/core/Menu";
import { useEventCallback } from "@intellij-platform/core/utils/useEventCallback";
-import { renderActionAsMenuItem } from "@intellij-platform/core/ActionSystem/components/ActionsMenu";
-import { flatten } from "ramda";
+import { renderActionAsMenuItem } from "./ActionsMenu";
+import { ActionContext } from "@intellij-platform/core/ActionSystem/Action";
export const useCreateDefaultActionGroup = () => {
const { show } = usePopupManager();
@@ -22,42 +20,44 @@ export const useCreateDefaultActionGroup = () => {
context: ActionContext
) => {
show(({ close }) => (
- {
- // The need for calculating `allActions` is a consequence of the issue explained in the note above.
- const allActions = flatten(
- children.map((item) =>
- isActionGroupDefinition(item) ? item.children : item
- )
- );
- const action = allActions.find((action) => action.id === key);
- if (action && !action.isDisabled) {
- action.actionPerformed(context);
+
+ {
+ // The need for calculating `allActions` is a consequence of the issue explained in the note above.
+ const allActions = flatten(
+ children.map((item) =>
+ isActionGroupDefinition(item) ? item.children : item
+ )
+ );
+ const action = allActions.find((action) => action.id === key);
+ if (action && !action.isDisabled) {
+ action.actionPerformed(context);
+ }
+ }}
+ onClose={close}
+ autoFocus="first"
+ >
+ {(item) =>
+ renderActionAsMenuItem({
+ ...item,
+ // a consequence of the issue explained in the note above.
+ shortcut: getActionShortcut(item.id),
+ })
}
- }}
- onClose={close}
- autoFocus="first"
- >
- {(item) =>
- renderActionAsMenuItem({
- ...item,
- // a consequence of the issue explained in the note above.
- shortcut: getActionShortcut(item.id),
- })
- }
-
- }
- header={title}
- />
+
+ }
+ header={title}
+ />
+
));
}
);
diff --git a/packages/jui/src/ActionSystem/defaultKeymap.tsx b/packages/jui/src/ActionSystem/defaultKeymap.tsx
index de24ed82..41023d11 100644
--- a/packages/jui/src/ActionSystem/defaultKeymap.tsx
+++ b/packages/jui/src/ActionSystem/defaultKeymap.tsx
@@ -1,4 +1,3 @@
-import { Keymap } from "@intellij-platform/core/ActionSystem/KeymapProvider";
import {
FOCUS_EDITOR_ACTION_ID,
HIDE_ACTIVE_WINDOW_ACTION_ID,
@@ -12,11 +11,17 @@ import {
// For some reason importing from shorter paths doesn't work as expected in cypress ÂŻ\_(ă)_/ÂŻ
// Weirdly, `import *` works in that case.
} from "@intellij-platform/core/ToolWindowsImpl/ToolWindowActionIds";
-import { CommonActionId } from "@intellij-platform/core/ActionSystem/CommonActionIds";
+
+import { Keymap } from "./KeymapProvider";
+import { CommonActionId } from "./CommonActionIds";
// TODO: OS specific defaults
// TODO: extract and export action ids
// NOTE: defaultKeymap doesn't belong to ActionSystem semantically. Would be something to be moved to a separate module
+/**
+ * Default Intellij Idea keymapping for common action ids, including tool window actions.
+ * @see CommonActionId
+ */
export const defaultKeymap: Keymap = {
[RESIZE_TOOL_WINDOW_RIGHT_ACTION_ID]: [
{
diff --git a/packages/jui/src/ActionSystem/index.ts b/packages/jui/src/ActionSystem/index.ts
index 88a6b26c..07efa74c 100644
--- a/packages/jui/src/ActionSystem/index.ts
+++ b/packages/jui/src/ActionSystem/index.ts
@@ -6,3 +6,7 @@ export * from "./ActionShortcut";
export * from "./CommonActionIds";
export * from "./shortcutToString";
export * from "./ActionGroup";
+export { Action } from "@intellij-platform/core/ActionSystem/Action";
+export { MutableAction } from "@intellij-platform/core/ActionSystem/Action";
+export { ActionDefinition } from "@intellij-platform/core/ActionSystem/Action";
+export { ActionContext } from "@intellij-platform/core/ActionSystem/Action";
diff --git a/packages/jui/src/ActionSystem/useActionGroup.tsx b/packages/jui/src/ActionSystem/useActionGroup.tsx
index 0a0e359e..0a4ff64d 100644
--- a/packages/jui/src/ActionSystem/useActionGroup.tsx
+++ b/packages/jui/src/ActionSystem/useActionGroup.tsx
@@ -1,8 +1,5 @@
import { useAction } from "./ActionsProvider";
-import {
- isResolvedActionGroup,
- ResolvedActionGroup,
-} from "@intellij-platform/core/ActionSystem/ActionGroup";
+import { isResolvedActionGroup, ResolvedActionGroup } from "./ActionGroup";
export const useActionGroup = (
actionGroupId: string
diff --git a/packages/jui/src/Balloon/index.ts b/packages/jui/src/Balloon/index.ts
index 89768b64..7ce96ddd 100644
--- a/packages/jui/src/Balloon/index.ts
+++ b/packages/jui/src/Balloon/index.ts
@@ -1,3 +1,3 @@
export * from "./Balloon";
export * from "./BalloonManager";
-export { StyledBalloonsStack } from "@intellij-platform/core/Balloon/StyledBalloonsStack";
+export { StyledBalloonsStack } from "./StyledBalloonsStack";
diff --git a/packages/jui/src/Checkbox/Checkbox.cy.tsx b/packages/jui/src/Checkbox/Checkbox.cy.tsx
index f37285f6..6aef6491 100644
--- a/packages/jui/src/Checkbox/Checkbox.cy.tsx
+++ b/packages/jui/src/Checkbox/Checkbox.cy.tsx
@@ -40,7 +40,7 @@ describe("Checkbox", () => {
);
cy.get("#dummyInput").focus(); // tabbing to the text input
cy.realPress("Tab"); // next tab should move focus to the checkbox
- cy.focused().should("have.attr", "type", "checkbox");
+ cy.findByRole("checkbox").should("be.focused");
cy.mount(
@@ -50,7 +50,7 @@ describe("Checkbox", () => {
);
cy.get("#dummyInput").focus(); // tabbing to the text input
cy.realPress("Tab"); // next tab should not focus the checkbox since excludeFromTabOrder is passed
- cy.focused().should("not.exist");
+ cy.findByRole("checkbox").should("not.be.focused");
});
it("supports preventFocus", () => {
diff --git a/packages/jui/src/Checkbox/Checkbox.tsx b/packages/jui/src/Checkbox/Checkbox.tsx
index f4bb2b14..de2c3064 100644
--- a/packages/jui/src/Checkbox/Checkbox.tsx
+++ b/packages/jui/src/Checkbox/Checkbox.tsx
@@ -28,7 +28,7 @@ export interface CheckboxProps
* a questionably better UX.
* Note: Passing {@link excludeFromTabOrder} will still let the checkbox be focusable, while `preventFocus`, doesn't
* let the component get focused at all.
- * TODO(potential): it might be nicer to have a `preventFocusOnPress` prop consistent with ActionButton, instead.
+ * TODO(potential): it might be nicer to have a `preventFocusOnPress` prop consistent with IconButton, instead.
* In that case preventing focus completely would be achieved with `preventFocusOnPres` and `excludeFromTabOrder`.
*/
preventFocus?: boolean;
diff --git a/packages/jui/src/Collections/Divider.ts b/packages/jui/src/Collections/Divider.ts
index 7a8a604f..b656f223 100644
--- a/packages/jui/src/Collections/Divider.ts
+++ b/packages/jui/src/Collections/Divider.ts
@@ -15,6 +15,19 @@ import { ItemProps } from "@react-types/shared";
import { PartialNode } from "@react-stately/collections";
interface DividerProps {}
+
+/**
+ * To be used in dynamic collections, just to provide a key and make it easy to check in the render
+ * function to figure out what to render (an Item or a Divider)
+ */
+export class DividerItem {
+ private static seq = 0;
+ key = "divider_" + DividerItem.seq++;
+ get id() {
+ return this.key;
+ }
+}
+
function Divider({}: DividerProps): ReactElement {
// eslint-disable-line @typescript-eslint/no-unused-vars
return null as any;
@@ -36,15 +49,3 @@ Divider.getCollectionNode = function* getCollectionNode(
hasChildNodes: false,
};
};
-
-/**
- * To be used in dynamic collections, just to provide a key and make it easy to check in the render
- * function to figure out what to render (an Item or a Divider)
- */
-export class DividerItem {
- private static seq = 0;
- key = "divider_" + DividerItem.seq++;
- get id() {
- return this.key;
- }
-}
diff --git a/packages/jui/src/ActionButton/ActionButton.tsx b/packages/jui/src/IconButton/IconButton.tsx
similarity index 85%
rename from packages/jui/src/ActionButton/ActionButton.tsx
rename to packages/jui/src/IconButton/IconButton.tsx
index 6946e2cd..4f6e282c 100644
--- a/packages/jui/src/ActionButton/ActionButton.tsx
+++ b/packages/jui/src/IconButton/IconButton.tsx
@@ -4,7 +4,7 @@ import { styled } from "../styled";
import { mergeProps, useObjectRef } from "@react-aria/utils";
import { useFocusable } from "@react-aria/focus";
-export interface ActionButtonProps
+export interface IconButtonProps
extends PressProps,
// Maybe we should allow any arbitrary HTMLProps props, instead of whitelisting?
Pick<
@@ -12,10 +12,13 @@ export interface ActionButtonProps
"onFocus" | "onBlur" | "style" | "className"
> {
children?: React.ReactNode;
+ /**
+ * The minimum width/height of the button.
+ */
minSize?: number;
/**
- * Whether the button should be focusable by pressing tab. The default is true for action buttons, which means they
- * are not included in the tab order.
+ * Whether the button should be focusable by pressing tab. The default is true for icon buttons (aka. action buttons),
+ * which means they are not included in the tab order.
*/
excludeFromTabOrder?: boolean;
}
@@ -23,7 +26,7 @@ export interface ActionButtonProps
export const DEFAULT_MINIMUM_BUTTON_SIZE = 22;
export const NAVBAR_MINIMUM_BUTTON_SIZE = 20;
-export const StyledActionButton = styled.button<{ minSize: number }>`
+export const StyledIconButton = styled.button<{ minSize: number }>`
position: relative; // to allow absolutely positioned overlays like an dropdown icon at the bottom right corner
background: none;
color: inherit;
@@ -58,7 +61,11 @@ export const StyledActionButton = styled.button<{ minSize: number }>`
}
`;
-export const ActionButton = React.forwardRef(function ActionButton(
+/**
+ * Icon button, aka Action Button, in the reference implementation.
+ * @see https://jetbrains.github.io/ui/controls/icon_button/
+ */
+export const IconButton = React.forwardRef(function IconButton(
{
minSize = DEFAULT_MINIMUM_BUTTON_SIZE,
preventFocusOnPress = true,
@@ -72,7 +79,7 @@ export const ActionButton = React.forwardRef(function ActionButton(
onPressUp,
shouldCancelOnPointerExit,
...otherProps
- }: ActionButtonProps,
+ }: IconButtonProps,
forwardedRef: ForwardedRef
) {
// FIXME: use useButton
@@ -93,7 +100,7 @@ export const ActionButton = React.forwardRef(function ActionButton(
});
return (
- & {
/**
* whether the default arrow should be removed or not. false by default.
@@ -22,22 +22,22 @@ type ActionButtonWithMenuProps = ActionButtonProps &
noArrow?: boolean;
};
/**
- * Renders an ActionButton which opens a menu. by default a down arrow icon is shown as an overlay on the rendered
+ * Renders an IconButton which opens a menu. by default a down arrow icon is shown as an overlay on the rendered
* icon, but it can be disabled by passing `noArrow`. It also restores the focus to the previously focused element,
* when the menu is closed.
* @param renderMenu: render prop for rendering the menu
- * @param children: the content of the action button
+ * @param children: the content of the icon button
* @param noArrow: whether the default arrow should be removed or not. false by default.
- * @param buttonProps: the rest of the props that will be passed down to ActionButton
+ * @param buttonProps: the rest of the props that will be passed down to IconButton
*
* TODO: Add story and write test for focus restoration, noArrow, and basic functionality.
*/
-export const ActionButtonWithMenu = ({
+export const IconButtonWithMenu = ({
renderMenu,
children,
noArrow = false,
...buttonProps
-}: ActionButtonWithMenuProps) => {
+}: IconButtonWithMenuProps) => {
const previouslyFocusedElementRef = useRef();
return (
{(props, ref) => (
- {
if (e.relatedTarget && e.relatedTarget instanceof HTMLElement) {
@@ -61,7 +61,7 @@ export const ActionButtonWithMenu = ({
>
{children}
{!noArrow && }
-
+
)}
);
diff --git a/packages/jui/src/Menu/Menu.cy.tsx b/packages/jui/src/Menu/Menu.cy.tsx
index 8aa21db2..16428b12 100644
--- a/packages/jui/src/Menu/Menu.cy.tsx
+++ b/packages/jui/src/Menu/Menu.cy.tsx
@@ -734,9 +734,11 @@ describe("ContextMenu", () => {
// to viewport.
cy.mount();
cy.scrollTo("bottom", { duration: 0 });
- cy.get("#context-menu-container").rightclick("bottomRight", {
- scrollBehavior: false,
- });
+ cy.get("#context-menu-container")
+ .realMouseMove(0, 0) // this fixes a flakiness in screenshots, which depends on whether other test cases are run before this
+ .rightclick("bottomRight", {
+ scrollBehavior: false,
+ });
matchImageSnapshot("context-menu-opened");
});
diff --git a/packages/jui/src/Menu/Menu.stories.tsx b/packages/jui/src/Menu/Menu.stories.tsx
index f8495f7d..89900912 100644
--- a/packages/jui/src/Menu/Menu.stories.tsx
+++ b/packages/jui/src/Menu/Menu.stories.tsx
@@ -3,8 +3,8 @@ import { StoryObj, StoryFn, Meta } from "@storybook/react";
import { Item } from "@react-stately/collections";
import { ContextMenuContainer, styled } from "@intellij-platform/core";
-import { ActionButton } from "../ActionButton";
-import { ActionToolbar } from "../ActionToolbar/ActionToolbar";
+import { IconButton } from "../IconButton";
+import { Toolbar } from "../Toolbar/Toolbar";
import { Divider, DividerItem } from "../Collections/Divider";
import { PlatformIcon } from "../Icon";
import { styledComponentsControlsExclude } from "../story-helpers";
@@ -198,7 +198,7 @@ export const MenuWithTrigger: StoryObj<
: undefined,
}}
>
-
+ (
@@ -216,12 +216,12 @@ export const MenuWithTrigger: StoryObj<
)}
>
{(props, ref) => (
-
+
-
+
)}
-
+
diff --git a/packages/jui/src/Popup/Popup.stories.tsx b/packages/jui/src/Popup/Popup.stories.tsx
index 06cfea86..a0e27b17 100644
--- a/packages/jui/src/Popup/Popup.stories.tsx
+++ b/packages/jui/src/Popup/Popup.stories.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import {
- ActionButton,
+ IconButton,
Checkbox,
FocusScope,
PlatformIcon,
@@ -197,9 +197,9 @@ export const CustomHeader: StoryObj = {
6+ usages
-
+
-
+
}
diff --git a/packages/jui/src/Popup/PopupManager.tsx b/packages/jui/src/Popup/PopupManager.tsx
index e53b0c5e..6dd997ff 100644
--- a/packages/jui/src/Popup/PopupManager.tsx
+++ b/packages/jui/src/Popup/PopupManager.tsx
@@ -7,7 +7,6 @@ import React, {
useState,
} from "react";
import { Popup, PopupProps } from "./Popup";
-import { props } from "ramda";
import { PopupControllerContext } from "@intellij-platform/core/Popup/PopupContext";
interface PopupManagerAPI {
diff --git a/packages/jui/src/Popup/PopupTrigger.stories.tsx b/packages/jui/src/Popup/PopupTrigger.stories.tsx
index f3a27525..906e4675 100644
--- a/packages/jui/src/Popup/PopupTrigger.stories.tsx
+++ b/packages/jui/src/Popup/PopupTrigger.stories.tsx
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Meta, StoryFn, StoryObj } from "@storybook/react";
import {
- ActionButton,
+ IconButton,
Checkbox,
PlatformIcon,
Popup,
@@ -20,9 +20,9 @@ export default {
component: PopupTrigger,
args: {
children: (
-
+
-
+
),
popup: (
diff --git a/packages/jui/src/Popup/PopupTrigger.tsx b/packages/jui/src/Popup/PopupTrigger.tsx
index d02f1656..335e9f72 100644
--- a/packages/jui/src/Popup/PopupTrigger.tsx
+++ b/packages/jui/src/Popup/PopupTrigger.tsx
@@ -22,7 +22,7 @@ export interface PopupTriggerProps
/**
* Popup opened by a trigger. `trigger` can be an element of any pressable component (such as {@link Button} or
- * {@link ActionButton}), and is rendered in place. Similar to {@link Popup component}, `children` defines the content
+ * {@link IconButton}), and is rendered in place. Similar to {@link Popup component}, `children` defines the content
* of Popup.
*/
export const PopupTrigger = React.forwardRef(function PopupTrigger(
diff --git a/packages/jui/src/Tabs/TabsOverflowMenu.tsx b/packages/jui/src/Tabs/TabsOverflowMenu.tsx
index fa2a5fa4..2fd57a38 100644
--- a/packages/jui/src/Tabs/TabsOverflowMenu.tsx
+++ b/packages/jui/src/Tabs/TabsOverflowMenu.tsx
@@ -1,7 +1,7 @@
import { Collection, Node } from "@react-types/shared";
import { Item } from "@react-stately/collections";
import { Menu, MenuTrigger } from "@intellij-platform/core/Menu";
-import { ActionButton } from "@intellij-platform/core/ActionButton";
+import { IconButton } from "@intellij-platform/core/IconButton";
import { PlatformIcon } from "@intellij-platform/core/Icon";
import React, { Key } from "react";
@@ -40,9 +40,9 @@ export const TabsOverflowMenu = ({
}}
>
{(props, ref) => (
-
+
-
+
)}
)}
diff --git a/packages/jui/src/ToolWindows/ToolWindowStripe.tsx b/packages/jui/src/ToolWindows/ToolWindowStripe.tsx
index e35b7246..dad7cfe8 100644
--- a/packages/jui/src/ToolWindows/ToolWindowStripe.tsx
+++ b/packages/jui/src/ToolWindows/ToolWindowStripe.tsx
@@ -172,6 +172,9 @@ function ToolWindowStripeButton({
const { pressProps } = {
pressProps: {
onPointerUp: onPress,
+ onMouseDown: (e) => {
+ e.preventDefault();
+ },
} as HTMLAttributes,
}; //usePress({ onPress });
const props = useElementMove({
diff --git a/packages/jui/src/ToolWindows/ToolWindows.cy.tsx b/packages/jui/src/ToolWindows/ToolWindows.cy.tsx
index 7dfa9744..deba7f3e 100644
--- a/packages/jui/src/ToolWindows/ToolWindows.cy.tsx
+++ b/packages/jui/src/ToolWindows/ToolWindows.cy.tsx
@@ -42,6 +42,7 @@ const SimpleToolWindows = ({
>
+
main content
);
@@ -62,7 +63,7 @@ describe("ToolWindows", () => {
cy.findByTestId("First window focusable 1").should("have.focus");
});
- it("Focuses the first focusable element, when a non-focusable area within the tool window is clicked", () => {
+ it("focuses the first focusable element, when a non-focusable area within the tool window is clicked", () => {
cy.mount(
@@ -77,17 +78,55 @@ describe("ToolWindows", () => {
cy.findByTestId("First window focusable 1").should("have.focus");
});
- it("keeps focused element focused, when a non-focusable area within in the main area is clicked", () => {
+ it("moves focus to the main area, when a non-focusable area within in the main area is clicked", () => {
cy.mount(
);
- cy.contains("First window").click();
- cy.findByTestId("First window focusable 2").click();
- cy.get("body").click(500, 100);
- cy.focused().should("exist");
- cy.findByTestId("First window focusable 2").should("have.focus");
+ cy.contains("First window").realClick();
+ cy.findByTestId("First window focusable 2")
+ .realClick()
+ .should("be.focused");
+ cy.findByTestId("main content non-focusable").realClick();
+ cy.findByTestId("main content focusable").should("have.focus");
+ });
+
+ it("keeps main content focused, when a non-focusable element inside is clicked", () => {
+ cy.mount(
+
+
outside area
+
+
+ );
+ cy.findByTestId("main content focusable").focus().should("have.focus");
+ cy.findByTestId("main content non-focusable").realClick(); // clicking non-focusable element inside tool window
+ cy.findByTestId("main content focusable").should("have.focus");
+ });
+
+ it("looses focus when a non-focusable element outside is clicked", () => {
+ cy.mount(
+
+
+
+
+ );
+ cy.findByTestId("main content focusable").focus().should("have.focus");
+ cy.findByTestId("outside-input").realClick().should("have.focus");
});
// NOTE: even when focus is within the tool window, focusing the tool window container (by pressing header,
@@ -104,7 +143,7 @@ describe("ToolWindows", () => {
cy.findByTestId("First window focusable 1").should("have.focus");
});
- it("moves focus to the main content when a window is toggled closed ", () => {
+ it("moves focus to the main content when a window is toggled closed", () => {
cy.mount(
@@ -114,10 +153,25 @@ describe("ToolWindows", () => {
cy.findByTestId("Second window stripe button").click();
cy.findByTestId("Second window stripe button").click();
- // Focus should to go main content, but it doesn't currently because of a known issue. FIXME in ToolWindows
- // cy.findByTestId("main content focusable").should('have.focus');
- cy.findByTestId("First window focusable 1").should("have.focus");
+ cy.findByTestId("main content focusable").should("have.focus");
});
+
+ it("keeps focus where it is, when stripe buttons are being pressed", () => {
+ cy.mount(
+
+
+
+ );
+ cy.findByTestId("First window stripe button").click();
+ cy.findByTestId("First window focusable 1").should("be.focused");
+ cy.findByTestId("First window stripe button").realMouseDown();
+ cy.findByTestId("First window focusable 1").should("be.focused");
+ cy.findByTestId("First window stripe button").realMouseMove(10, 50);
+ cy.findByTestId("First window focusable 1").should("be.focused");
+ cy.get("body").realMouseUp();
+ cy.findByTestId("First window focusable 1").should("be.focused");
+ });
+
/**
* Known issue. FIXME. See DefaultToolWindow for more info
*/
diff --git a/packages/jui/src/ToolWindows/ToolWindows.tsx b/packages/jui/src/ToolWindows/ToolWindows.tsx
index 9bc62de1..6df27424 100644
--- a/packages/jui/src/ToolWindows/ToolWindows.tsx
+++ b/packages/jui/src/ToolWindows/ToolWindows.tsx
@@ -1,3 +1,4 @@
+import { indexBy } from "ramda";
import React, {
CSSProperties,
ForwardedRef,
@@ -8,7 +9,8 @@ import React, {
useRef,
useState,
} from "react";
-import { FocusScope as AriaFocusScope } from "@react-aria/focus";
+import { useLatest } from "@intellij-platform/core/utils/useLatest";
+
import { ThreeViewSplitter } from "../ThreeViewSplitter/ThreeViewSplitter";
import { FocusScope } from "../utils/FocusScope";
import { FloatToolWindows } from "./FloatToolWindows";
@@ -25,8 +27,7 @@ import { ToolWindowStateProvider } from "./ToolWindowsState/ToolWindowStateProvi
import { ToolWindowStripe } from "./ToolWindowStripe";
import { UndockSide } from "./UndockSide";
import { Anchor, isHorizontalToolWindow } from "./utils";
-import { useLatest } from "@intellij-platform/core/utils/useLatest";
-import { indexBy } from "ramda";
+import { useOnFocusLost } from "./useOnFocusLost";
import { useInteractOutside } from "@react-aria/interactions";
interface ToolWindow {
@@ -67,13 +68,6 @@ export interface ToolWindowsProps {
* props to be passed to the container element.
*/
containerProps?: Omit, "as">;
-
- /**
- * By default, `ToolWindows` prevents focus from going to `body`, when something blurs. This is especially
- * useful for global actions handled at the level of ToolWindows, to be able to consistently capture keyboard events.
- * setting `disableFocusTrap` to true prevents that default behavior.
- */
- allowBlurOnInteractionOutside?: boolean;
}
export interface ToolWindowRefValue {
@@ -111,7 +105,6 @@ export const ToolWindows = React.forwardRef(function ToolWindows(
{
hideToolWindowBars = false,
useWidescreenLayout = false,
- allowBlurOnInteractionOutside = false,
height = "100%",
minHeight = "0",
toolWindowsState,
@@ -119,7 +112,7 @@ export const ToolWindows = React.forwardRef(function ToolWindows(
windows,
children,
mainContentMinWidth = 50,
- containerProps,
+ containerProps = {},
}: ToolWindowsProps,
ref: ForwardedRef
): React.ReactElement {
@@ -197,18 +190,23 @@ export const ToolWindows = React.forwardRef(function ToolWindows(
[]
);
- const [interactionOutside, setInteractionOutside] = useState(false);
+ const interactionOutsideRef = useRef(false);
useInteractOutside({
ref: containerRef,
- isDisabled: !allowBlurOnInteractionOutside,
onInteractOutsideStart: () => {
- setInteractionOutside(true);
+ interactionOutsideRef.current = true;
},
onInteractOutside: () => {
- setInteractionOutside(false);
+ interactionOutsideRef.current = false;
},
});
+ useOnFocusLost(({ focusReceivingElement }) => {
+ if (!focusReceivingElement && !interactionOutsideRef.current) {
+ mainContentFocusScopeRef.current?.focus();
+ }
+ }, containerRef);
+
// TODO: extract component candidate
const renderStripe = ({
anchor,
@@ -431,38 +429,20 @@ export const ToolWindows = React.forwardRef(function ToolWindows(
>
);
};
+
return (
- /**
- * About FocusScope:
- * When focus is within the ToolWindows, clicking on non-focusable parts of the UI should not make the focus get
- * lost. That's especially important with the top level actions being handled on a wrapper around ToolWindows.
- * Because if the focus goes to body, keyboard events are no longer handled, with the way action system is currently
- * implemented.
- * AriaFocusScope provides a somewhat accurate behaviour, but it might also be too much, and we can consider
- * a more light-weight approach. Issues with the current usage of AriaFocus:
- * - FocusScope traverses the dom tree to find focusable elements, and it might come with considerable performance
- * penalty at this place. Something to be investigated more.
- * - When the focus is lost, e.g. the active tool window closes, FocusScope moves focus to the **first** focusable
- * element, whereas in the reference implementation of Tool Windows, the focus goes to the main content (usually
- * the editor).
- * TODO: investigate alternative approaches for focus handling here.
- */
-
-
- {layoutState && renderInnerLayout(layoutState)}
-
-
+ {layoutState && renderInnerLayout(layoutState)}
+
);
});
diff --git a/packages/jui/src/ToolWindows/stories/ToolWindows.stories.tsx b/packages/jui/src/ToolWindows/stories/ToolWindows.stories.tsx
index 22b8c9d9..f6701dba 100644
--- a/packages/jui/src/ToolWindows/stories/ToolWindows.stories.tsx
+++ b/packages/jui/src/ToolWindows/stories/ToolWindows.stories.tsx
@@ -13,7 +13,7 @@ import {
import { indexBy, map } from "ramda";
import React, { useState } from "react";
import packageJson from "../../../package.json";
-import { ActionButton } from "../../ActionButton";
+import { IconButton } from "../../IconButton";
import { SpeedSearchTreeSample } from "../../story-components";
import { styledComponentsControlsExclude } from "../../story-helpers";
import { DefaultToolWindow } from "../../ToolWindowsImpl/DefaultToolWindow";
@@ -52,9 +52,9 @@ const windows = [
headerContent="Project"
additionalActions={
<>
-
+
-
+
>
}
>
diff --git a/packages/jui/src/ToolWindows/stories/components/FakeExecution.tsx b/packages/jui/src/ToolWindows/stories/components/FakeExecution.tsx
index 15502403..8e2d3197 100644
--- a/packages/jui/src/ToolWindows/stories/components/FakeExecution.tsx
+++ b/packages/jui/src/ToolWindows/stories/components/FakeExecution.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
-import { ActionToolbar } from "../../../ActionToolbar/ActionToolbar";
-import { ActionButton } from "../../../ActionButton";
+import { Toolbar } from "../../../Toolbar/Toolbar";
+import { IconButton } from "../../../IconButton";
import { PlatformIcon } from "../../../Icon";
import { StyledHorizontalSeparator } from "../../../StyledSeparator";
import { styled } from "../../../styled";
@@ -74,21 +74,21 @@ export const FakeExecutionToolbar = ({
execution: Execution;
toggle: (executionId: string) => void;
}) => (
-
- toggle(id)}>
+
+ toggle(id)}>
-
- toggle(id)}>
+
+ toggle(id)}>
-
+
-
+
-
-
+
+
-
-
+
+
);
export const VerticalFlexContainer = styled.div`
diff --git a/packages/jui/src/ToolWindows/useOnFocusLost.tsx b/packages/jui/src/ToolWindows/useOnFocusLost.tsx
new file mode 100644
index 00000000..68479a42
--- /dev/null
+++ b/packages/jui/src/ToolWindows/useOnFocusLost.tsx
@@ -0,0 +1,38 @@
+import { RefObject, useEffect } from "react";
+
+/**
+ * Executes a callback function when focus is lost from the container element. i.e. when the currently focused element
+ * was within the container and:
+ * - The focus is going to an element outside the container, or
+ * - The focus is about to get lost (i.e. go to `document.body`).
+ *
+ * Note: react-aria's `useFocusWithin` (and it's `onBlurWithin` option) can't be used, since (at least currently) it
+ * doesn't cover the scenario where the focus is about to get lost due to the focused element getting unmounted.
+ */
+export function useOnFocusLost(
+ onFocusLost: (args: {
+ focusLosingElement: HTMLElement | null;
+ focusReceivingElement: Element | null;
+ }) => void,
+ containerRef: RefObject
+): void {
+ useEffect(() => {
+ const handleBodyFocus = (e: FocusEvent) => {
+ if (
+ e.target instanceof HTMLElement &&
+ containerRef.current?.contains(e.target) &&
+ (!e.relatedTarget || e.relatedTarget instanceof HTMLElement) &&
+ !containerRef.current?.contains(e.relatedTarget)
+ ) {
+ onFocusLost({
+ focusLosingElement: e.target,
+ focusReceivingElement: e.relatedTarget,
+ });
+ }
+ };
+ containerRef.current?.addEventListener("focusout", handleBodyFocus);
+ return () => {
+ containerRef.current?.removeEventListener("focusout", handleBodyFocus);
+ };
+ }, []);
+}
diff --git a/packages/jui/src/ToolWindowsImpl/DefaultToolWindowHeader.tsx b/packages/jui/src/ToolWindowsImpl/DefaultToolWindowHeader.tsx
index c62d2e01..9e2de406 100644
--- a/packages/jui/src/ToolWindowsImpl/DefaultToolWindowHeader.tsx
+++ b/packages/jui/src/ToolWindowsImpl/DefaultToolWindowHeader.tsx
@@ -1,18 +1,18 @@
import React, { HTMLProps } from "react";
-import { ActionButton } from "@intellij-platform/core/ActionButton";
-import { ActionToolbar } from "@intellij-platform/core/ActionToolbar/ActionToolbar";
+import { IconButton } from "@intellij-platform/core/IconButton";
+import { Toolbar } from "@intellij-platform/core/Toolbar/Toolbar";
import { PlatformIcon } from "@intellij-platform/core/Icon";
import { MenuTrigger } from "@intellij-platform/core/Menu/MenuTrigger";
import { ActionTooltip, TooltipTrigger } from "@intellij-platform/core/Tooltip";
import { styled } from "@intellij-platform/core/styled";
import { StyledHorizontalSeparator } from "@intellij-platform/core/StyledSeparator";
import { UnknownThemeProp } from "@intellij-platform/core/Theme/Theme";
-import { Action } from "@intellij-platform/core/ActionSystem/components";
import { ToolWindowSettingsIconMenu } from "./ToolWindowSettingsIconMenu";
import {
DOCK_TOOL_WINDOW_ACTION_ID,
HIDE_ACTIVE_WINDOW_ACTION_ID,
} from "./ToolWindowActionIds";
+import { ActionButton } from "@intellij-platform/core/ActionSystem/components";
export interface ToolWindowHeaderProps
extends Omit, "ref" | "as"> {
@@ -65,14 +65,14 @@ export const DefaultToolWindowHeader: React.FC = ({
{children}
-
+
{additionalActions && (
<>
{additionalActions}
>
)}
-
+ {
return ;
@@ -80,14 +80,14 @@ export const DefaultToolWindowHeader: React.FC = ({
>
{(props, ref) => (
}>
-
+
-
+
)}
-
-
+
+
);
diff --git a/packages/jui/src/ToolWindowsImpl/DefaultToolWindows.cy.tsx b/packages/jui/src/ToolWindowsImpl/DefaultToolWindows.cy.tsx
index 96286afb..5879b9d9 100644
--- a/packages/jui/src/ToolWindowsImpl/DefaultToolWindows.cy.tsx
+++ b/packages/jui/src/ToolWindowsImpl/DefaultToolWindows.cy.tsx
@@ -217,10 +217,7 @@ describe("DefaultToolWindowActions", () => {
);
cy.realPress(["Meta", "2"]);
-
- // Focus should to go main content, but it doesn't currently because of a known issue. FIXME in ToolWindows
- // cy.findByTestId("main content focusable").should("have.focus");
- cy.findByTestId("First window").find("input").eq(0).should("have.focus");
+ cy.findByTestId("main content focusable").should("have.focus");
});
});
});
diff --git a/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx b/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx
index ac137d30..15786ce7 100644
--- a/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx
+++ b/packages/jui/src/ToolWindowsImpl/ToolWindowSettingsIconMenu.tsx
@@ -8,7 +8,7 @@ import {
import {
ActionItem,
ActionsMenu,
-} from "@intellij-platform/core/ActionSystem/components/ActionsMenu";
+} from "@intellij-platform/core/ActionSystem/components";
import {
MOVE_TO_ACTION_GROUP_ID,
TOOL_WINDOW_RESIZE_ACTION_GROUP_ID,
diff --git a/packages/jui/src/ToolWindowsImpl/index.ts b/packages/jui/src/ToolWindowsImpl/index.ts
index 881dc827..bb721836 100644
--- a/packages/jui/src/ToolWindowsImpl/index.ts
+++ b/packages/jui/src/ToolWindowsImpl/index.ts
@@ -1,6 +1,6 @@
/**
* Default implementation of Tool Window UI. Unlike the core part of ToolWindow API, this module depends
- * on other modules and components, e.g. Menu, ActionButton, Tabs, Action System, etc.
+ * on other modules and components, e.g. Menu, IconButton, Tabs, Action System, etc.
*/
export * from "./DefaultToolWindows";
export * from "./useToolWindowsActions";
diff --git a/packages/jui/src/ToolWindowsImpl/useToolWindowActions.tsx b/packages/jui/src/ToolWindowsImpl/useToolWindowActions.tsx
index ee52c95f..62cd0fd9 100644
--- a/packages/jui/src/ToolWindowsImpl/useToolWindowActions.tsx
+++ b/packages/jui/src/ToolWindowsImpl/useToolWindowActions.tsx
@@ -23,7 +23,7 @@ import {
UNDOCK_MODE_ACTION_ID,
WINDOW_MODE_ACTION_ID,
} from "./ToolWindowActionIds";
-import { ActionGroupDefinition } from "@intellij-platform/core/ActionSystem/ActionGroup";
+import { ActionGroupDefinition } from "@intellij-platform/core/ActionSystem";
import { useCreateDefaultActionGroup } from "@intellij-platform/core/ActionSystem/components";
// Resize steps in Intellij Platform is calculated based on the size of a "W" character and some
diff --git a/packages/jui/src/ActionToolbar/ActionToolbar.tsx b/packages/jui/src/Toolbar/Toolbar.tsx
similarity index 76%
rename from packages/jui/src/ActionToolbar/ActionToolbar.tsx
rename to packages/jui/src/Toolbar/Toolbar.tsx
index 80ed4eb5..1058967c 100644
--- a/packages/jui/src/ActionToolbar/ActionToolbar.tsx
+++ b/packages/jui/src/Toolbar/Toolbar.tsx
@@ -1,20 +1,21 @@
-import { Theme } from "@intellij-platform/core/Theme";
import React, { useContext } from "react";
-import { StyledActionButton } from "../ActionButton";
+import { Theme } from "@intellij-platform/core/Theme";
+
+import { StyledIconButton } from "../IconButton";
import { styled } from "../styled";
import {
StyledHorizontalSeparator,
StyledVerticalSeparator,
} from "../StyledSeparator";
-interface ActionToolbarProps {
+interface ToolbarProps {
orientation?: "vertical" | "horizontal";
/**
* Whether to include a border to the bottom/right the toolbar, or not.
*/
hasBorder?: boolean;
}
-const StyledActionToolbar = styled.div`
+const StyledToolbar = styled.div`
display: flex;
`;
@@ -31,7 +32,7 @@ const getBorder = ({
theme.dark ? "rgb(50,50,50)" : "rgb(192, 192, 192)"
)}`
: "none";
-const StyledHorizontalActionToolbar = styled(StyledActionToolbar)<{
+const StyledHorizontalToolbar = styled(StyledToolbar)<{
hasBorder?: boolean;
}>`
padding: 2px;
@@ -42,12 +43,12 @@ const StyledHorizontalActionToolbar = styled(StyledActionToolbar)<{
// NOTE: in the original implementation, there is no empty space between buttons, but buttons have kind of an
// invisible left padding, which is mouse-intractable, but doesn't visually seem a part of the button.
// Although implementable, it didn't seem necessary to follow the exact same thing. Margin should be fine.
- ${StyledActionButton} {
+ ${StyledIconButton} {
margin: 0 2px 0 2px;
}
`;
-const StyledVerticalActionToolbar = styled(StyledActionToolbar)<{
+const StyledVerticalToolbar = styled(StyledToolbar)<{
hasBorder?: boolean;
}>`
flex-direction: column;
@@ -57,7 +58,7 @@ const StyledVerticalActionToolbar = styled(StyledActionToolbar)<{
${StyledVerticalSeparator} {
margin: 4px 1px;
}
- ${StyledActionButton} {
+ ${StyledIconButton} {
margin: 2px 0 1px 0;
}
`;
@@ -74,7 +75,7 @@ const OrientationContext = React.createContext<"horizontal" | "vertical">(
* - hidden, shown by arrow. Similar to actions in Git->Log. Note that the behaviour for horizontal and vertical
* modes are different apparently.
*/
-export const ActionToolbar: React.FC = ({
+export const Toolbar: React.FC = ({
orientation = "horizontal",
hasBorder = false,
children,
@@ -82,22 +83,22 @@ export const ActionToolbar: React.FC = ({
return (
{orientation === "horizontal" ? (
-
+
{children}
-
+
) : (
-
+
{children}
-
+
)}
);
};
/**
- * Separator to be used between action buttons in an action toolbar.
+ * Separator to be used between items in a toolbar.
*/
-export const ActionToolbarSeparator = (): React.ReactElement => {
+export const ToolbarSeparator = (): React.ReactElement => {
const orientation = useContext(OrientationContext);
return orientation === "horizontal" ? (
diff --git a/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx b/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx
index 9f60688b..1633b67f 100644
--- a/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx
+++ b/packages/jui/src/Tooltip/TooltipTrigger.stories.tsx
@@ -2,10 +2,10 @@ import { Meta, StoryFn, StoryObj } from "@storybook/react";
import React from "react";
import { Tooltip } from "./Tooltip";
import {
- ActionButton,
+ IconButton,
ActionHelpTooltip,
- ActionToolbar,
- ActionToolbarSeparator,
+ Toolbar,
+ ToolbarSeparator,
Button,
Link,
PlatformIcon,
@@ -69,24 +69,24 @@ export const All: StoryFn = () => {
}>
-
+ }>
-
+
-
+
}>
-
+
-
+
-
+ }>
-
+
-
+
-
+ = () => {
/>
}
>
-
+
-
+
-
+
);
};
diff --git a/packages/jui/src/Tree/Tree.cy.tsx b/packages/jui/src/Tree/Tree.cy.tsx
index 50f4b83d..b89feb63 100644
--- a/packages/jui/src/Tree/Tree.cy.tsx
+++ b/packages/jui/src/Tree/Tree.cy.tsx
@@ -69,9 +69,9 @@ describe("Tree", () => {
const onAction = cy.stub().as("onAction");
cy.mount();
- cy.contains("index.ts").dblclick();
+ cy.findByRole("treeitem", { name: "index.ts" }).dblclick();
cy.get("@onAction").should("be.calledOnceWith", "index.ts");
- cy.contains("index.ts").type("{enter}");
+ cy.findByRole("treeitem", { name: "index.ts" }).type("{enter}");
cy.get("@onAction").should("be.calledTwice");
});
diff --git a/packages/jui/src/index.ts b/packages/jui/src/index.ts
index 17b234ce..45cb2f07 100644
--- a/packages/jui/src/index.ts
+++ b/packages/jui/src/index.ts
@@ -7,9 +7,10 @@ export * from "./Menu";
export * from "./ToolWindows";
export * from "./ToolWindowsImpl";
export * from "./ActionSystem";
-export * from "./ActionButton";
-export * from "./ActionToolbar/ActionToolbar";
-export * from "./ActionButtonWithMenu/ActionButtonWithMenu";
+export * from "./ActionSystem/components";
+export * from "./IconButton";
+export * from "./Toolbar/Toolbar";
+export * from "./IconButtonWithMenu/IconButtonWithMenu";
export * from "./Icon";
export * from "./TextWithHighlights/TextWithHighlights";
export * from "./SpeedSearch";
diff --git a/packages/jui/src/theme.stories.tsx b/packages/jui/src/theme.stories.tsx
index c3477442..0cb44653 100644
--- a/packages/jui/src/theme.stories.tsx
+++ b/packages/jui/src/theme.stories.tsx
@@ -3,7 +3,7 @@ import { useState } from "react";
import { indexBy, map } from "ramda";
import { Meta, StoryFn } from "@storybook/react";
import {
- ActionButton,
+ IconButton,
ActionHelpTooltip,
ActionTooltip,
AutoHoverPlatformIcon,
@@ -71,9 +71,9 @@ export const Theme: StoryFn = () => {
headerContent="SpeedSearchTree"
additionalActions={
<>
-
+
-
+
>
}
>
diff --git a/packages/website/docs/components/ToolWindows.mdx b/packages/website/docs/components/ToolWindows.mdx
index 45e6a09f..8aae6c13 100644
--- a/packages/website/docs/components/ToolWindows.mdx
+++ b/packages/website/docs/components/ToolWindows.mdx
@@ -317,6 +317,10 @@ toolWindowState({
});
```
+## Tool window actions
+
+TODO
+
## Advanced API
### Hiding tool window bars
diff --git a/packages/website/docs/components/ActionToolbar.mdx b/packages/website/docs/components/Toolbar.mdx
similarity index 90%
rename from packages/website/docs/components/ActionToolbar.mdx
rename to packages/website/docs/components/Toolbar.mdx
index 687f6dbb..de3c4668 100644
--- a/packages/website/docs/components/ActionToolbar.mdx
+++ b/packages/website/docs/components/Toolbar.mdx
@@ -4,4 +4,4 @@
import { RefToIntellijPlatform } from "@site/src/components/RefToIntellijPlatform.tsx";
import { Example } from "@site/src/components/ExampleContext";
-# Action Toolbar
+# Toolbar
diff --git a/packages/website/docs/guides/ActionSystem.mdx b/packages/website/docs/guides/ActionSystem.mdx
new file mode 100644
index 00000000..45d27b6e
--- /dev/null
+++ b/packages/website/docs/guides/ActionSystem.mdx
@@ -0,0 +1,372 @@
+---
+---
+
+# Actions
+
+:::warning Experimental
+The current implementation of the action system is experimental and is likely to undergo significant revisions to meet
+the performance demands of real-world applications with thousands of actions.
+:::
+
+An _action_ represents an available functionality, without specifying how it's invoked. Shortcuts can separately be
+assigned to actions via a `keymap`, allowing for a customizable and personalized user experience. The action system,
+at its core, is only about defining actions for each part of the UI and associating these actions with
+shortcuts, without any direct dependencies on other aspects of the design system. However, there are
+[UI components](#ui-components) available to facilitate creation of toolbars and menus based on actions.
+
+:::note
+The core functionality of the action system may be extracted into a standalone package in the future,
+making it a reusable component for applications built using any design system.
+:::
+
+The action system consists of the following primary components:
+
+- `ActionsProvider` component, which allows for defining actions for a UI area.
+- `KeymapProvider` component, which allows for assigning shortcut(s) to actions.
+- UI components that helps with creating menus, toolbars, etc. based on action system abstractions.
+
+Below is a basic example of how these components are used.
+Subsequent sections will provide detailed explanations of the components and interfaces involved.
+
+```tsx live noInline noPadding themed
+const StyledDiv = styled.div`
+ &:focus {
+ outline: 4px solid ${({ theme }) => theme.color("Button.focusedBorderColor")}!important;
+ outline-offset: 2px;
+ }
+`;
+render(
+
+ ,
+ actionPerformed: () => alert("My Action performed"),
+ },
+ ]}
+ >
+ {({ shortcutHandlerProps }) => (
+
+
+ Click here to focus, then press Shift+D
+
+
+
+ Or press this button:
+
+
+ )}
+
+
+);
+```
+
+## Defining actions
+
+`ActionsProvider` is used as a wrapper component that defines available actions for the wrapped UI.
+Multiple, potentially nested `ActionsProvider` may be rendered in an application. `ActionProvider` itself doesn't render
+any wrapper element. Shortcut event handler props are passed to `children` which is expected to be a render function:
+
+```tsx
+
+ {({ shortcutHandlerProps }) =>
...
}
+
+```
+
+`actions` is an array of objects implementing `ActionDefinition` interface. At minimum, an action must have:
+
+- `id`: A unique identifier based on which shortcuts are assigned in `keymap`.
+- `actionPerformed`: A function to be invoked when the action is triggered, e.g. via a shortcut.
+- `title`: The only mandatory presentation information for the action.
+
+Moreover, an action has the following optional properties:
+
+- `isDisabled`: If `true`, prevents action from being performed. [Action UI components](#ui-components) will also utilize
+ this field to either disable or hide the corresponding UI component.
+- `icon`: A React node to be used as the associated icon for the action. It's required if the action is to be rendered
+ as an [ActionButton](#actionbutton), and optional for [menu items](#menu).
+- `description`: Plain text, additional description about the action. Not used in [ActionButton](#actionbutton), or
+ [menu items](#menu).
+
+### Action Groups
+
+An ActionGroup is a special type of action with a list of children actions. An action group itself can be performed.
+
+### Default action group
+
+A default implementation of action group is available, which when performed, opens a [Popup](../components/popup),
+presenting the children actions in a [SpeedSearchMenu](../components/Menu#speedsearchmenu). The default action
+group requires [PopupManager](../components/Popup#popupmanager), for opening the popup.
+
+`useCreateDefaultActionGroup` returns a function that creates action groups:
+
+```tsx
+import { useCreateDefaultActionGroup } from "@intellij-platform/core";
+
+const createDefaultActionGroup = useCreateDefaultActionGroup();
+
+createDefaultActionGroup({
+ id: "MY_ACTION_GROUP",
+ title: "My Action Group",
+ children: [
+ /* action definitions */
+ ],
+});
+```
+
+```tsx themed live noInline
+function ExampleApp({ children }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+function DefaultActionGroupExample() {
+ const createDefaultActionGroup = useCreateDefaultActionGroup();
+
+ return (
+ ,
+ children: [
+ {
+ id: "New Changelist...",
+ title: "New Changelist...",
+ icon: ,
+ actionPerformed: () => alert("New Changelist..."),
+ },
+ {
+ id: "Edit Changelist...",
+ title: "Edit Changelist...",
+ icon: ,
+ actionPerformed: () => alert("Edit Changelist..."),
+ },
+ {
+ id: "Move to another changelist...",
+ title: "Move to another changelist...",
+ actionPerformed: () => alert("Edit Changelist..."),
+ },
+ ],
+ }),
+ ]}
+ >
+ {({ shortcutHandlerProps }) => (
+
+
+
+ )}
+
+ );
+}
+
+render(
+
+
+
+);
+```
+
+## Using actions
+
+`useActions` can be used to query the list of all provided actions. Use `useAction` instead to query an specific action,
+by id. The queried `Action` object has a `perform` method which can be invoked e.g. when a menu item or a button is
+pressed. Note that `Action` interface is quite similar but not identical to `ActionDefinition`.
+
+```tsx
+function MyActionButton() {
+ const action = useAction("MY_ACTION");
+ return action && ;
+}
+```
+
+`useActionGroup` is also similar to `useAction`, but it asserts the queried action is a group.
+
+### Query actions based on DOM elements
+
+`useAction` or `useActions` is the canonical API for accessing currently provided actions from a React component.
+`getAvailableActionsFor` is an alternative API that allows for querying available actions from a DOM element. This can
+be useful in specific scenarios such as the implementation of the [find action][find-action-in-the-reference-impl].
+You can also check out the implementation of "find action" action [in the example app][find-action-in-example-app].
+
+### UI components
+
+`useAction`, and `useActionGroup` can be used to query actions and create a menu or toolbar out of them. For typical
+use cases, however, there are convenient components that interface on `Action` or action id.
+
+### Menu
+
+Use `ActionsMenu` to render a list of action objects as a menu. `ActionGroup` items are rendered as a section or a
+submenu, depending on `isPopup` property of the action group object.
+Note that `ActionsMenu` just provides an interface based on action items, but it doesn't query any action from the
+actions context, and just uses action properties to create menu items from it.
+
+```tsx themed live
+,
+ shortcut: "âX",
+ perform: () => alert("Cut"),
+ },
+ {
+ id: "Copy",
+ title: "Copy",
+ icon: ,
+ shortcut: "âC",
+ perform: () => alert("Copy"),
+ },
+ {
+ id: "Copy Path/Reference...",
+ title: "Copy Path/Reference...",
+ perform: () => alert("Copy Path/Reference..."),
+ },
+ {
+ id: "Paste",
+ title: "Paste",
+ icon: ,
+ shortcut: "âV",
+ perform: () => alert("Paste"),
+ },
+ new DividerItem(),
+ {
+ id: "Compare with...",
+ title: "Diff",
+ icon: ,
+ shortcut: "âD",
+ perform: () => alert("Diff"),
+ },
+ ]}
+/>
+```
+
+Use `ActionGroupMenu` to render actions of an action group, as a menu:
+
+```tsx themed live
+,
+ shortcut: "âX",
+ perform: () => alert("Cut"),
+ },
+ {
+ id: "Copy",
+ title: "Copy",
+ icon: ,
+ shortcut: "âC",
+ perform: () => alert("Copy"),
+ },
+ {
+ id: "Copy Path/Reference...",
+ title: "Copy Path/Reference...",
+ perform: () => alert("Copy Path/Reference..."),
+ },
+ {
+ id: "Paste",
+ title: "Paste",
+ icon: ,
+ shortcut: "âV",
+ perform: () => alert("Paste"),
+ },
+ {
+ id: "DIFF_ACTION_GROUP",
+ title: "Diff actions",
+ children: [
+ {
+ id: "Compare with...",
+ title: "Diff",
+ icon: ,
+ shortcut: "âD",
+ perform: () => alert("Diff"),
+ },
+ ],
+ },
+ ],
+ }}
+/>
+```
+
+### ActionButton
+
+Use `ActionButton` to create an `IconButton` based on a provided action. Unlike `ActionsMenu` and `ActionGroupMenu`,
+`ActionButton` interfaces on action ID, and queries the target action via `useAction`. If the action doesn't exist,
+it renders nothing.
+
+```jsx live themed noPadding
+,
+ actionPerformed: () => alert("Refresh"),
+ },
+ ]}
+>
+ {({ shortcutHandlerProps }) => (
+
+
+
+ )}
+
+```
+
+## Shortcuts and key mapping
+
+A `Keymap` is a mapping from action ids to a list of associated shortcuts. In an application that uses actions, the
+`KeymapProvider` is typically used as a top level wrapper, to provide a keymap.
+
+```tsx
+import {Keymap, KeymapProvider} from '@intellij-platform/core';
+
+const keymap: Keymap = isMac() ? macKeymap : defaultKeymap;
+...
+```
+
+## Differences with the Intellij Platform
+
+While the action system almost replicates [Intellij Platform's Action System][action-system], there are some design
+differences worth noting:
+
+TODO
+
+[action-system]: https://plugins.jetbrains.com/docs/intellij/basic-action-system.html
+[find-action-in-the-reference-impl]: https://www.jetbrains.com/idea/guide/tutorials/presenting/find-action
+[find-action-in-example-app]: https://github.com/alirezamirian/jui/blob/master/packages/example-app/src/SearchEverywhere/contributors/action/actionsSearchContributor.tsx
diff --git a/packages/website/docs/guides/Collections.mdx b/packages/website/docs/guides/Collections.mdx
index b5a15495..ff474b74 100644
--- a/packages/website/docs/guides/Collections.mdx
+++ b/packages/website/docs/guides/Collections.mdx
@@ -3,7 +3,7 @@
# Collections
-A good number of components like List, Tree, Menu, Tabs, etc. display a collection of items.
+A good number of components such as List, Tree, Menu, Tabs, etc. display a collection of items.
[@react-stately/collections](https://react-spectrum.adobe.com/react-stately/collections.html) is used for all of them
to provide a uniform and flexible API that would allow for both static jsx-based or dynamic source of items.
@@ -15,17 +15,14 @@ on the item object. It's important to understand this assumption that the render
not any other piece of state from the closure, for example.
:::info
-Item renderer function is assumed to be a pure function that depends only on its single argument, the item
-object.
+The item renderer function should be a pure function that relies exclusively on its single argument: the item object.
:::
-In some cases however, there might be a need to render some UI based on a something other than the item object itself.
-Below is a few ways to do that.
+However, in certain scenarios, you might need to render UI elements based on factors other than the item object. Here are a few ways to achieve this:
-### Using context
+xwxw### Using context
-TODO (important point: especially useful for components that implement virtual rendering, as only a limited number of
-items are mounted at each moment, and changes to the additional state would only affect those, without collection being rebuilt)
+TODO (This is particularly useful for components implementing virtual rendering, where only a limited number of items are mounted at a time, and changes to additional state should affect only those without rebuilding the entire collection).
### Wrapping items with extra state
@@ -33,14 +30,12 @@ TODO
### Disabling or limiting cache
-Passing `true` to `cacheInvalidation` prop in components that support it will disable caching altogether, but can
-drastically reduce the performance for large number of components. In a test on a [Tree](../components/Tree) with 400 elements,
-it was ~10x slower with cache being disabled.
+For components that support it, you can control caching by setting the `cacheInvalidation` prop. Passing `true` to this prop will disable caching entirely, but it may significantly reduce performance for large collections. In a performance test on a [Tree](../components/Tree) with 400 elements, disabling the cache resulted in a ~10x slower rendering.
-A middle ground is to pass an array of cache invalidators instead of turning the cache off completely:
+A middle-ground approach is to pass an array of cache invalidators instead of completely turning off the cache:
```tsx
...
```
-This would invalidate the cache only when `nestedSelection` is changed.
+This configuration will only invalidate the cache when `nestedSelection` changes.
diff --git a/packages/website/src/theme/CodeBlock/Expandable.tsx b/packages/website/src/theme/CodeBlock/Expandable.tsx
new file mode 100644
index 00000000..1b8b2765
--- /dev/null
+++ b/packages/website/src/theme/CodeBlock/Expandable.tsx
@@ -0,0 +1,72 @@
+import React, { useEffect, useRef, useState } from "react";
+import styled from "styled-components";
+
+const StyledSvg = styled.svg`
+ position: absolute;
+ width: 24px;
+ left: 50%;
+ bottom: 1rem;
+ transform: translate(-50%, 0) rotate(180deg);
+`;
+
+const StyledExpandButton = styled.div`
+ position: absolute;
+ width: 100%;
+ height: 50%;
+ bottom: 0;
+ background: linear-gradient(0deg, rgba(255, 255, 255, 1), transparent);
+ cursor: pointer;
+ ${StyledSvg} {
+ opacity: 0;
+ transition: opacity 0.3s;
+ }
+ &:hover ${StyledSvg} {
+ opacity: 1;
+ }
+`;
+const StyledContainer = styled.div<{ expanded: boolean }>`
+ position: relative;
+ max-height: ${({ expanded }) => (expanded ? undefined : "200px")};
+`;
+
+export function Expandable({
+ children,
+ expanded,
+ onExpand,
+ isExpandable,
+ setIsExpandable,
+}: {
+ children: React.ReactNode;
+ expanded: boolean;
+ onExpand: () => void;
+ isExpandable: boolean;
+ setIsExpandable: (isExpandable: boolean) => void;
+}) {
+ const ref = useRef();
+ useEffect(() => {
+ const expandable = ref.current?.scrollHeight > ref.current?.offsetHeight;
+ if (expandable !== isExpandable) {
+ setIsExpandable(expandable);
+ }
+ });
+ return (
+
+ {children}
+ {!expanded && isExpandable && (
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/website/src/theme/CodeBlock/Playground.tsx b/packages/website/src/theme/CodeBlock/Playground.tsx
new file mode 100644
index 00000000..8a6a7104
--- /dev/null
+++ b/packages/website/src/theme/CodeBlock/Playground.tsx
@@ -0,0 +1,200 @@
+/**
+ * NOTE: this file is copied from Docusaurus repo, to customize the live code blocks UI, since the exported
+ * functionality doesn't allow for the needed customizations.
+ * Original file: https://github.com/facebook/docusaurus/blob/main/packages/docusaurus-theme-live-codeblock/src/theme/Playground/index.tsx
+ */
+
+import React, { useState } from "react";
+import clsx from "clsx";
+import useIsBrowser from "@docusaurus/useIsBrowser";
+import { LiveEditor, LiveError, LivePreview, LiveProvider } from "react-live";
+import Translate from "@docusaurus/Translate";
+import BrowserOnly from "@docusaurus/BrowserOnly";
+import {
+ ErrorBoundaryTryAgainButton,
+ usePrismTheme,
+} from "@docusaurus/theme-common";
+import ErrorBoundary from "@docusaurus/ErrorBoundary";
+
+import type { Props } from "@theme/Playground";
+import type { Props as ErrorProps } from "@theme/Error";
+
+import styles from "@docusaurus/theme-live-codeblock/lib/theme/Playground/styles.module.css";
+import { Expandable } from "./Expandable";
+import {
+ PlatformIcon,
+ IconButton,
+ ThemeProvider,
+ Theme,
+ ActionTooltip,
+ TooltipTrigger,
+} from "@intellij-platform/core";
+import lightThemeJson from "@intellij-platform/core/themes/intellijlaf.theme.json";
+
+function Header({
+ children,
+ icons,
+}: {
+ children: React.ReactNode;
+ icons?: React.ReactNode;
+}) {
+ return (
+
+ {children}
+ {icons}
+
+ );
+}
+
+function LivePreviewLoader() {
+ // Is it worth improving/translating?
+ return