Repository: elastic/eui (TypeScript)

Issue:
[EuiResizableButton] Prop to switch between drag handle icon or simple border
Currently, the `EuiResizableButton` component is designed to appear like our `grab` and `grabHorizontal` icons by default. When hovering, these faux icons transition to 2px wide gray border, extending the full width/height of the parent `EuiResizableContainer`. When active/focus, the border color changes to blue, with a lower opacity blue emanating from the border.

**Current**
![CleanShot 2024-01-04 at 14 24 28](https://github.com/elastic/eui/assets/3884767/b424eedf-3aad-479e-b538-bc94b0c5d5f0)

While these styles work fine in some circumstances, I often find myself wishing we could provide a more minimalist option in some interfaces (ex. ES|QL editor vertical resizing). Would it be possible to add a new prop to the `EuiResizableButton` that functions as follows in the different states?

- **Default:** 1px wide `euiTheme.colors.lightShade` border, extending the full width/height of the parent `EuiResizableContainer`.
- **Hover:** Border width transitions to 2px and color transitions to `euiTheme.colors.mediumShade`.
- **Active/Focus:** Border color transitions to `euiTheme.colors.primary`.

Here's a quick mockup of what I was thinking. Thoughts?

**Default**
<img width="1440" alt="Default" src="https://github.com/elastic/eui/assets/3884767/a304f283-05b2-4894-8ea3-5374b6761c08">

**Hover**
<img width="1440" alt="Hover" src="https://github.com/elastic/eui/assets/3884767/715bf7b7-8652-4cea-afe1-e60d944f83d1">

**Active/Focus**
<img width="1440" alt="Resize" src="https://github.com/elastic/eui/assets/3884767/65d3223d-a23a-40d7-8d42-20b933629a07">

CCing @cee-chen, as we chatted about this in the last Figma EUI library working group meeting.


Discussion: @MichaelMarcialis how do you feel about keeping the slight blue tint/halo around the border in active/focus state? i.e.,

<img width="34" alt="" src="https://github.com/elastic/eui/assets/549407/e039039e-d1c5-4833-8c09-a06b211b3a28">

I personally kind of like it for greater visibility. WDYT?
This is not a strict objection, just adding some food for thought.

If we add this as an optional property, do we lose some important consistency? Users may have established a recognition of the `grab` handle meaning that things can be resized. Do we lose that by allowing this option?

Likewise, if we do add this option, what is the guidance that we provide that goes along with this? When should we be using the minimal option vs the regular option.
> If we add this as an optional property, do we lose some important consistency?

Discover has already their own resize UI overriding/hiding the grab handle, so this is basically just bringing EUI to parity with production 🤷 

> Likewise, if we do add this option, what is the guidance that we provide that goes along with this? When should we be using the minimal option vs the regular option.

++, agreed some firmer guidance on this would be nice. My best guess is to opt out of the drag handle when there's a lot of other busy things going on on the page, or the resize behavior is "nice to have" but not a primary focus.



Fix (patch):
diff --git a/changelogs/upcoming/7455.md b/changelogs/upcoming/7455.md
new file mode 100644
index 00000000000..556da2a60c7
--- /dev/null
+++ b/changelogs/upcoming/7455.md
@@ -0,0 +1,1 @@
+- Updated `EuiResizableButton` to allow customizing the `indicator` style with either `handle` (default) or `border`
diff --git a/src-docs/src/views/resizable_container/resizable_button_indicator.tsx b/src-docs/src/views/resizable_container/resizable_button_indicator.tsx
new file mode 100644
index 00000000000..32a39e91530
--- /dev/null
+++ b/src-docs/src/views/resizable_container/resizable_button_indicator.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { EuiText, EuiResizableContainer } from '../../../../src/components';
+import { faker } from '@faker-js/faker';
+
+const text = (
+  <>
+    <p>{faker.lorem.paragraphs()}</p>
+    <p>{faker.lorem.paragraphs()}</p>
+    <p>{faker.lorem.paragraphs()}</p>
+  </>
+);
+
+export default () => (
+  <EuiResizableContainer style={{ height: '300px' }}>
+    {(EuiResizablePanel, EuiResizableButton) => (
+      <>
+        <EuiResizablePanel initialSize={50} minSize="50px" tabIndex={0}>
+          <EuiText>{text}</EuiText>
+        </EuiResizablePanel>
+
+        <EuiResizableButton indicator="border" />
+
+        <EuiResizablePanel
+          initialSize={50}
+          minSize="50px"
+          tabIndex={0}
+          paddingSize="none"
+        >
+          <EuiResizableContainer
+            direction="vertical"
+            style={{ height: '100%', overflow: 'hidden' }}
+          >
+            {(EuiResizablePanel, EuiResizableButton) => (
+              <>
+                <EuiResizablePanel initialSize={50} minSize="50px" tabIndex={0}>
+                  <EuiText>{text}</EuiText>
+                </EuiResizablePanel>
+
+                <EuiResizableButton indicator="border" />
+
+                <EuiResizablePanel initialSize={50} minSize="50px" tabIndex={0}>
+                  <EuiText>{text}</EuiText>
+                </EuiResizablePanel>
+              </>
+            )}
+          </EuiResizableContainer>
+        </EuiResizablePanel>
+      </>
+    )}
+  </EuiResizableContainer>
+);
diff --git a/src-docs/src/views/resizable_container/resizable_container_example.js b/src-docs/src/views/resizable_container/resizable_container_example.js
index 703f9113f23..f8469e0096e 100644
--- a/src-docs/src/views/resizable_container/resizable_container_example.js
+++ b/src-docs/src/views/resizable_container/resizable_container_example.js
@@ -29,6 +29,7 @@ import ResizablePanelCollapsible from './resizable_panel_collapsible';
 import ResizablePanelCollapsibleResponsive from './resizable_panel_collapsible_responsive';
 import ResizablePanelCollapsibleOpts from './resizable_panel_collapsible_options';
 import ResizablePanelCollapsibleExt from './resizable_panel_collapsible_external';
+import ResizableButtonIndicator from './resizable_button_indicator';
 import ResizableButton from './resizable_button';
 
 const ResizableContainerSource = require('!!raw-loader!./resizable_container_basic');
@@ -40,6 +41,7 @@ const ResizablePanelCollapsibleSource = require('!!raw-loader!./resizable_panel_
 const ResizablePanelCollapsibleResponsiveSource = require('!!raw-loader!./resizable_panel_collapsible_responsive');
 const ResizablePanelCollapsibleOptsSource = require('!!raw-loader!./resizable_panel_collapsible_options');
 const ResizablePanelCollapsibleExtSource = require('!!raw-loader!./resizable_panel_collapsible_external');
+const ResizableButtonIndicatorSource = require('!!raw-loader!./resizable_button_indicator');
 const ResizableButtonSource = require('!!raw-loader!./resizable_button');
 
 const basicSnippet = `<EuiResizableContainer>
@@ -504,6 +506,35 @@ export const ResizableContainerExample = {
       demo: <ResizablePanelCollapsibleExt />,
       snippet: collapsibleExtSnippet,
     },
+    {
+      source: [
+        {
+          type: GuideSectionTypes.TSX,
+          code: ResizableButtonIndicatorSource,
+        },
+      ],
+      title: 'Resizable button indicator',
+      text: (
+        <>
+          <p>
+            By default, <strong>EuiResizableButton</strong> shows a grab handle
+            indicator as a UI hint. For use cases where the resize behavior is
+            "nice to have" but not a primary UX focus, or where there are many
+            other busy UI elements on the page, you can set{' '}
+            <EuiCode>indicator="border"</EuiCode> to display a subdued border
+            element instead, which only provides resize affordance on hover or
+            focus.
+          </p>
+        </>
+      ),
+      demo: <ResizableButtonIndicator />,
+      demoPanelProps: { paddingSize: 'none' },
+      props: { EuiResizableButton },
+      snippet: `<EuiResizableButton
+  isHorizontal
+  indicator="border"
+/>`,
+    },
     {
       source: [
         {
@@ -511,7 +542,7 @@ export const ResizableContainerExample = {
           code: ResizableButtonSource,
         },
       ],
-      title: 'Custom resizable button',
+      title: 'Custom resize logic',
       text: (
         <>
           <p>
diff --git a/src/components/flyout/flyout_resizable.styles.ts b/src/components/flyout/flyout_resizable.styles.ts
index abcda128fef..1257a6d6235 100644
--- a/src/components/flyout/flyout_resizable.styles.ts
+++ b/src/components/flyout/flyout_resizable.styles.ts
@@ -14,17 +14,21 @@ import { logicalCSS } from '../../global_styling';
 export const euiFlyoutResizableButtonStyles = ({ euiTheme }: UseEuiTheme) => ({
   euiFlyoutResizableButton: css`
     position: absolute;
-
-    /* Hide the default grab icon (although the hover/focus states should remain) */
-    &::before,
-    &::after {
-      background-color: transparent;
-    }
-  `,
-  left: css`
-    ${logicalCSS('right', `-${euiTheme.border.width.thin}`)}
-  `,
-  right: css`
-    ${logicalCSS('left', `-${euiTheme.border.width.thin}`)}
   `,
+  overlay: {
+    left: css`
+      ${logicalCSS('right', 0)}
+    `,
+    right: css`
+      ${logicalCSS('left', 0)}
+    `,
+  },
+  push: {
+    left: css`
+      ${logicalCSS('right', `-${euiTheme.border.width.thin}`)}
+    `,
+    right: css`
+      ${logicalCSS('left', `-${euiTheme.border.width.thin}`)}
+    `,
+  },
 });
diff --git a/src/components/flyout/flyout_resizable.tsx b/src/components/flyout/flyout_resizable.tsx
index 632d3af67c3..7005aafd69c 100644
--- a/src/components/flyout/flyout_resizable.tsx
+++ b/src/components/flyout/flyout_resizable.tsx
@@ -34,6 +34,7 @@ export const EuiFlyoutResizable = forwardRef(
       maxWidth,
       minWidth = 200,
       side = 'right',
+      type = 'overlay',
       children,
       ...rest
     }: EuiFlyoutResizableProps,
@@ -41,7 +42,7 @@ export const EuiFlyoutResizable = forwardRef(
   ) => {
     const euiTheme = useEuiTheme();
     const styles = euiFlyoutResizableButtonStyles(euiTheme);
-    const cssStyles = [styles.euiFlyoutResizableButton, styles[side]];
+    const cssStyles = [styles.euiFlyoutResizableButton, styles[type][side]];
 
     const getFlyoutMinMaxWidth = useCallback(
       (width: number) => {
@@ -141,10 +142,12 @@ export const EuiFlyoutResizable = forwardRef(
         size={flyoutWidth || size}
         maxWidth={maxWidth}
         side={side}
+        type={type}
         ref={setRefs}
       >
         <EuiResizableButton
           isHorizontal
+          indicator="border"
           css={cssStyles}
           onMouseDown={onMouseDown}
           onTouchStart={onMouseDown}
diff --git a/src/components/resizable_container/resizable_button.stories.tsx b/src/components/resizable_container/resizable_button.stories.tsx
index 4cd4b2261b0..511648a0cec 100644
--- a/src/components/resizable_container/resizable_button.stories.tsx
+++ b/src/components/resizable_container/resizable_button.stories.tsx
@@ -21,6 +21,7 @@ const meta: Meta<EuiResizableButtonProps> = {
   component: EuiResizableButton,
   args: {
     // Component defaults
+    indicator: 'handle',
     alignIndicator: 'center',
     disabled: false,
     isHorizontal: false,
@@ -32,8 +33,14 @@ type Story = StoryObj<EuiResizableButtonProps>;
 
 export const Playground: Story = {
   render: (args) => (
-    <EuiPanel style={{ blockSize: 200, inlineSize: 200 }}>
-      <EuiResizableButton {...args} />
+    <EuiPanel
+      style={{ blockSize: 200, inlineSize: 200, position: 'relative' }}
+      borderRadius="none"
+    >
+      <EuiResizableButton
+        style={{ position: 'absolute', top: 0, left: 0 }}
+        {...args}
+      />
     </EuiPanel>
   ),
 };
diff --git a/src/components/resizable_container/resizable_button.styles.ts b/src/components/resizable_container/resizable_button.styles.ts
index 340d249038b..a7436cc7554 100644
--- a/src/components/resizable_container/resizable_button.styles.ts
+++ b/src/components/resizable_container/resizable_button.styles.ts
@@ -22,38 +22,24 @@ export const euiResizableButtonStyles = (euiThemeContext: UseEuiTheme) => {
   const transition = `${transitionSpeed} ease`;
 
   return {
-    // Mimics the "grab" icon with CSS psuedo-elements.
-    // 1. The "grab" icon transforms into a thicker straight line on :hover and :focus
-    // 2. Start/end aligned handles should have a slight margin offset that disappears on hover/focus
+    // Creates a resizable indicator (either a grab handle or a plain border) with CSS psuedo-elements.
+    // 1. The "grab" handle transforms into a thicker straight line on :hover and :focus
+    // 2. Start/end aligned grab handles should have a slight margin offset that disappears on hover/focus
     // 3. CSS hack to smooth out/anti-alias the 1px wide handles at various zoom levels
     euiResizableButton: css`
       z-index: 1;
       flex-shrink: 0;
       display: flex;
       justify-content: center;
-      gap: ${mathWithUnits(grabHandleHeight, (x) => x * 2)};
 
       &:disabled {
         display: none;
       }
 
-      /* 1 */
-      &:hover,
-      &:focus {
-        gap: 0;
-        justify-content: center;
-      }
-
-      ${euiCanAnimate} {
-        transition: gap ${transition}, justify-content ${transition};
-      }
-
       &::before,
       &::after {
         content: '';
         display: block;
-        background-color: ${euiTheme.colors.darkestShade};
-        transform: translateZ(0); /* 3 */
 
         ${euiCanAnimate} {
           transition: width ${transition}, height ${transition},
@@ -61,20 +47,15 @@ export const euiResizableButtonStyles = (euiThemeContext: UseEuiTheme) => {
         }
       }
 
-      /* Lighten the "grab" icon on :hover */
+      /* Lighten color on :hover */
       &:hover {
         &::before,
         &::after {
           background-color: ${euiTheme.colors.mediumShade};
-
-          /* Delay transition on hover so animation is not accidentally triggered on mouse over */
-          ${euiCanAnimate} {
-            transition-delay: ${transitionSpeed};
-          }
         }
       }
 
-      /* Add a transparent background to the container and emphasize the "grab" icon
+      /* Add a transparent background to the container and color the border primary
          with primary color on :focus (NOTE - :active is needed for Safari) */
       &:focus,
       &:active {
@@ -84,7 +65,7 @@ export const euiResizableButtonStyles = (euiThemeContext: UseEuiTheme) => {
         &::after {
           background-color: ${euiTheme.colors.primary};
 
-          /* Overrides default transition so that "grab" icon background-color doesn't animate */
+          /* Overrides default transition so that "grab" background-color doesn't animate */
           ${euiCanAnimate} {
             transition: width ${transition}, height ${transition};
             transition-delay: ${mathWithUnits(transitionSpeed, (x) => x / 2)};
@@ -97,25 +78,6 @@ export const euiResizableButtonStyles = (euiThemeContext: UseEuiTheme) => {
       ${logicalCSS('height', '100%')}
       ${logicalCSS('width', buttonSize)}
       margin-inline: ${mathWithUnits(buttonSize, (x) => x / -2)};
-
-      &::before,
-      &::after {
-        ${logicalCSS('width', grabHandleHeight)}
-        ${logicalCSS('height', grabHandleWidth)}
-        margin-block: ${euiTheme.size.base}; /* 2 */
-      }
-
-      /* 1 */
-      &:hover,
-      &:focus,
-      &:active {
-        &::before,
-        &::after {
-          ${logicalCSS('height', '100%')}
-          margin-block: 0; /* 2 */
-          transform: none; /* 3 */
-        }
-      }
     `,
     vertical: css`
       flex-direction: column;
@@ -123,26 +85,120 @@ export const euiResizableButtonStyles = (euiThemeContext: UseEuiTheme) => {
       ${logicalCSS('width', '100%')}
       ${logicalCSS('height', buttonSize)}
       margin-block: ${mathWithUnits(buttonSize, (x) => x / -2)};
+    `,
 
+    border: css`
       &::before,
       &::after {
-        ${logicalCSS('height', grabHandleHeight)}
-        ${logicalCSS('width', grabHandleWidth)}
-        margin-inline: ${euiTheme.size.base}; /* 2 */
+        background-color: ${euiTheme.border.color};
       }
+    `,
+    borderDirection: {
+      horizontal: css`
+        &::before {
+          ${logicalCSS('width', euiTheme.border.width.thin)}
+          ${logicalCSS('height', '100%')}
+        }
+
+        &:hover,
+        &:focus,
+        &:active {
+          &::after {
+            ${logicalCSS('width', euiTheme.border.width.thin)}
+            ${logicalCSS('height', '100%')}
+          }
+        }
+      `,
+      vertical: css`
+        &::before {
+          ${logicalCSS('height', euiTheme.border.width.thin)}
+          ${logicalCSS('width', '100%')}
+        }
+
+        &:hover,
+        &:focus,
+        &:active {
+          &::after {
+            ${logicalCSS('height', euiTheme.border.width.thin)}
+            ${logicalCSS('width', '100%')}
+          }
+        }
+      `,
+    },
+
+    handle: css`
+      gap: ${mathWithUnits(grabHandleHeight, (x) => x * 2)};
 
       /* 1 */
       &:hover,
       &:focus,
       &:active {
+        gap: 0;
+      }
+
+      ${euiCanAnimate} {
+        transition: gap ${transition};
+      }
+
+      &::before,
+      &::after {
+        background-color: ${euiTheme.colors.darkestShade};
+        transform: translateZ(0); /* 3 */
+      }
+
+      /* Lighten color on :hover */
+      &:hover {
         &::before,
         &::after {
-          ${logicalCSS('width', '100%')}
-          margin-inline: 0; /* 2 */
-          transform: none; /* 3 */
+          /* Delay transition on hover so animation is not accidentally triggered on mouse over */
+          ${euiCanAnimate} {
+            transition-delay: ${transitionSpeed};
+          }
         }
       }
     `,
+    handleDirection: {
+      horizontal: css`
+        &::before,
+        &::after {
+          ${logicalCSS('width', grabHandleHeight)}
+          ${logicalCSS('height', grabHandleWidth)}
+          margin-block: ${euiTheme.size.base}; /* 2 */
+        }
+
+        /* 1 */
+        &:hover,
+        &:focus,
+        &:active {
+          &::before,
+          &::after {
+            ${logicalCSS('height', '100%')}
+            margin-block: 0; /* 2 */
+            transform: none; /* 3 */
+          }
+        }
+      `,
+      vertical: css`
+        &::before,
+        &::after {
+          ${logicalCSS('height', grabHandleHeight)}
+          ${logicalCSS('width', grabHandleWidth)}
+          margin-inline: ${euiTheme.size.base}; /* 2 */
+        }
+
+        /* 1 */
+        &:hover,
+        &:focus,
+        &:active {
+          &::before,
+          &::after {
+            ${logicalCSS('width', '100%')}
+            margin-inline: 0; /* 2 */
+            transform: none; /* 3 */
+          }
+        }
+      `,
+    },
     alignIndicator: {
       center: css`
         align-items: center;
diff --git a/src/components/resizable_container/resizable_button.tsx b/src/components/resizable_container/resizable_button.tsx
index 416ea0d1141..a25ca3c145b 100644
--- a/src/components/resizable_container/resizable_button.tsx
+++ b/src/components/resizable_container/resizable_button.tsx
@@ -37,8 +37,14 @@ export type EuiResizableButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
      */
     isHorizontal?: boolean;
     /**
-     * Specify the alignment of the initial resize indicator. Defaults to `center`,
-     * but consider using `start` for extremely tall content that scrolls off-screen
+     * By default, EuiResizableButton will show a grab handle to indicate resizability.
+     * This indicator can be optionally hidden to show a plain border instead.
+     */
+    indicator?: 'handle' | 'border';
+    /**
+     * Allows customizing the alignment of grab `handle` indicators (does nothing for
+     * border indicators). Defaults to `center`, but consider using `start` for extremely
+     * tall content that scrolls off-screen
      */
     alignIndicator?: 'center' | 'start' | 'end';
     /**
@@ -54,44 +60,61 @@ export type EuiResizableButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
 export const EuiResizableButton = forwardRef<
   HTMLButtonElement,
   EuiResizableButtonProps
->(({ isHorizontal, alignIndicator = 'center', className, ...rest }, ref) => {
-  const classes = classNames('euiResizableButton', className);
+>(
+  (
+    {
+      isHorizontal,
+      indicator = 'handle',
+      alignIndicator = 'center',
+      className,
+      ...rest
+    },
+    ref
+  ) => {
+    const classes = classNames('euiResizableButton', className);
 
-  const euiTheme = useEuiTheme();
-  const styles = euiResizableButtonStyles(euiTheme);
-  const cssStyles = [
-    styles.euiResizableButton,
-    isHorizontal ? styles.horizontal : styles.vertical,
-    styles.alignIndicator[alignIndicator],
-  ];
+    const resizeDirection = isHorizontal ? 'horizontal' : 'vertical';
 
-  return (
-    <EuiI18n
-      tokens={[
-        'euiResizableButton.horizontalResizerAriaLabel',
-        'euiResizableButton.verticalResizerAriaLabel',
-      ]}
-      defaults={[
-        'Press the left or right arrow keys to adjust panels size',
-        'Press the up or down arrow keys to adjust panels size',
-      ]}
-    >
-      {([horizontalResizerAriaLabel, verticalResizerAriaLabel]: string[]) => (
-        <button
-          ref={ref}
-          aria-label={
-            isHorizontal ? horizontalResizerAriaLabel : verticalResizerAriaLabel
-          }
-          className={classes}
-          css={cssStyles}
-          data-test-subj="euiResizableButton"
-          type="button"
-          {...rest}
-        />
-      )}
-    </EuiI18n>
-  );
-});
+    const euiTheme = useEuiTheme();
+    const styles = euiResizableButtonStyles(euiTheme);
+    const cssStyles = [
+      styles.euiResizableButton,
+      styles[indicator],
+      styles[`${indicator}Direction`][resizeDirection],
+      styles[resizeDirection],
+      indicator === 'handle' && styles.alignIndicator[alignIndicator],
+    ];
+
+    return (
+      <EuiI18n
+        tokens={[
+          'euiResizableButton.horizontalResizerAriaLabel',
+          'euiResizableButton.verticalResizerAriaLabel',
+        ]}
+        defaults={[
+          'Press the left or right arrow keys to adjust panels size',
+          'Press the up or down arrow keys to adjust panels size',
+        ]}
+      >
+        {([horizontalResizerAriaLabel, verticalResizerAriaLabel]: string[]) => (
+          <button
+            ref={ref}
+            aria-label={
+              isHorizontal
+                ? horizontalResizerAriaLabel
+                : verticalResizerAriaLabel
+            }
+            className={classes}
+            css={cssStyles}
+            data-test-subj="euiResizableButton"
+            type="button"
+            {...rest}
+          />
+        )}
+      </EuiI18n>
+    );
+  }
+);
 EuiResizableButton.displayName = 'EuiResizableButton';
 
 /**