diff --git a/.changeset/large-lands-worry.md b/.changeset/large-lands-worry.md
new file mode 100644
index 0000000000..ab189ab41e
--- /dev/null
+++ b/.changeset/large-lands-worry.md
@@ -0,0 +1,8 @@
+---
+'@lg-chat/input-bar': minor
+---
+
+[LG-5600](https://jira.mongodb.org/browse/LG-5600)
+Fix send button disabled logic: the send button now remains enabled during loading state (even with empty message body) to allow users to stop the request. The `disabled` and `disableSend` props still take precedence.
+
+Add `onClickStopButton` prop to handle stop actions during loading state. When triggered, the previous message body is restored to the input field (similar to error state behavior).
diff --git a/chat/input-bar/README.md b/chat/input-bar/README.md
index 47acde93a1..17bdaced89 100644
--- a/chat/input-bar/README.md
+++ b/chat/input-bar/README.md
@@ -22,54 +22,59 @@ npm install @lg-chat/input-bar
## Example
-### Compact
-
-```tsx
-import { InputBar } from '@lg-chat/input-bar';
-import {
- LeafyGreenChatProvider,
- Variant,
-} from '@lg-chat/leafygreen-chat-provider';
-
-return (
-
-
-
-);
-```
-
-### Spacious
-
```tsx
-import { InputBar } from '@lg-chat/input-bar';
+import { useState } from 'react';
+import { InputBar, State } from '@lg-chat/input-bar';
import {
LeafyGreenChatProvider,
Variant,
} from '@lg-chat/leafygreen-chat-provider';
-return (
-
-
-
-);
+const Example = () => {
+ const [state, setState] = useState(State.Unset);
+
+ const handleMessageSend = (messageBody: string) => {
+ console.log('Sending:', messageBody);
+ setState(State.Loading);
+ // Simulate API call
+ };
+
+ const handleClickStopButton = () => {
+ console.log('Stopping request');
+ setState(State.Unset);
+ // Cancel your API request here
+ };
+
+ return (
+
+
+
+ );
+};
```
## Properties
-| Prop | Type | Description | Default |
-| ----------------------------- | ---------------------------------------------- | ----------------------------------------------------------- | ------- |
-| `badgeText` | `string` | Determines the text inside the rendered Badge | |
-| `darkMode` | `boolean` | Determines if the component will render in dark mode | `false` |
-| `disabled` | `boolean` | Determines whether the user can interact with the InputBar | `false` |
-| `disableSend` | `boolean` | When defined as `true`, disables the send action and button | |
-| `errorMessage` | `ReactNode` | Custom error message to display when `state='error'` | |
-| `onMessageSend` | `(messageBody: string, e?: FormEvent) => void` | Submit event handler. | |
-| `shouldRenderGradient` | `boolean` | Toggles the gradient animation around the input | `true` |
-| `shouldRenderHotkeyIndicator` | `boolean` | Toggles the hotkey indicator on the right side of the input | `false` |
-| `textareaProps` | `TextareaAutosizeProps` | Props passed to the TextareaAutosize component. | |
-| `textareaRef` | `RefObject` | Ref object to access the textarea element directly | |
-| `state` | `'unset' \| 'error' \| 'loading'` | The current state of the InputBar. | |
-| `...` | `HTMLElementProps<'form'>` | Props spread on the root element | |
+| Prop | Type | Description | Default |
+| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `badgeText` | `string` | Determines the text inside the rendered Badge | |
+| `darkMode` | `boolean` | Determines if the component will render in dark mode | `false` |
+| `disabled` | `boolean` | Determines whether the user can interact with the InputBar | `false` |
+| `disableSend` | `boolean` | When defined as `true`, disables the send action and button | |
+| `errorMessage` | `ReactNode` | Custom error message to display when `state='error'` | |
+| `onClickStopButton` | `() => void` | Callback fired when the stop button is clicked during a loading state. Restores the previous message body. Only applies in compact variant. | |
+| `onMessageSend` | `(messageBody: string, e?: FormEvent) => void` | Callback fired when the user sends a message. | |
+| `shouldRenderGradient` | `boolean` | Toggles the gradient animation around the input | `true` |
+| `shouldRenderHotkeyIndicator` | `boolean` | Toggles the hotkey indicator on the right side of the input | `false` |
+| `textareaProps` | `TextareaAutosizeProps` | Props passed to the TextareaAutosize component. | |
+| `textareaRef` | `RefObject` | Ref object to access the textarea element directly | |
+| `state` | `'unset' \| 'error' \| 'loading'` | The current state of the InputBar. | |
+| `...` | `HTMLElementProps<'form'>` | Props spread on the root element | |
## TextareaAutosizeProps
diff --git a/chat/input-bar/src/InputBar/InputBar.spec.tsx b/chat/input-bar/src/InputBar/InputBar.spec.tsx
index 7db90d619a..8b9b01e2d6 100644
--- a/chat/input-bar/src/InputBar/InputBar.spec.tsx
+++ b/chat/input-bar/src/InputBar/InputBar.spec.tsx
@@ -384,4 +384,123 @@ describe('packages/input-bar', () => {
);
});
});
+
+ describe('onClickStopButton', () => {
+ test('renders stop button during loading state', () => {
+ renderInputBar({ state: State.Loading });
+
+ const stopButton = screen.getByRole('button', { name: 'Stop message' });
+ expect(stopButton).toBeInTheDocument();
+ expect(stopButton).not.toHaveAttribute('aria-disabled', 'true');
+ });
+
+ test('does not render send button during loading state', () => {
+ renderInputBar({ state: State.Loading });
+
+ const sendButton = screen.queryByRole('button', { name: 'Send message' });
+ expect(sendButton).not.toBeInTheDocument();
+ });
+
+ test('calls onClickStopButton when button is clicked during loading state', () => {
+ const onClickStopButton = jest.fn();
+ renderInputBar({ state: State.Loading, onClickStopButton });
+
+ const stopButton = screen.getByRole('button', { name: 'Stop message' });
+ userEvent.click(stopButton);
+
+ expect(onClickStopButton).toHaveBeenCalledTimes(1);
+ });
+
+ test('restores previous message when stop is clicked in uncontrolled mode', () => {
+ const onClickStopButton = jest.fn();
+ const onMessageSend = jest.fn();
+ const { rerender } = renderInputBar({ onClickStopButton, onMessageSend });
+
+ const textarea = screen.getByRole('textbox');
+ const sendButton = screen.getByRole('button', { name: 'Send message' });
+
+ userEvent.type(textarea, TEST_INPUT_TEXT);
+ expect(textarea).toHaveValue(TEST_INPUT_TEXT);
+
+ userEvent.click(sendButton);
+ expect(onMessageSend).toHaveBeenCalledTimes(1);
+ expect(textarea).toHaveValue('');
+
+ rerender(
+
+
+ ,
+ );
+
+ const stopButton = screen.getByRole('button', { name: 'Stop message' });
+
+ userEvent.click(stopButton);
+
+ expect(onClickStopButton).toHaveBeenCalledTimes(1);
+ expect(textarea).toHaveValue(TEST_INPUT_TEXT);
+ });
+
+ test('does not call onMessageSend when stopping during loading state', () => {
+ const onMessageSend = jest.fn();
+ const onClickStopButton = jest.fn();
+
+ renderInputBar({
+ state: State.Loading,
+ onMessageSend,
+ onClickStopButton,
+ textareaProps: { value: TEST_INPUT_TEXT },
+ });
+
+ const stopButton = screen.getByRole('button', { name: 'Stop message' });
+ userEvent.click(stopButton);
+
+ expect(onClickStopButton).toHaveBeenCalledTimes(1);
+ expect(onMessageSend).not.toHaveBeenCalled();
+ });
+
+ test('disabled prop takes precedence over loading state', () => {
+ const onClickStopButton = jest.fn();
+ renderInputBar({
+ state: State.Loading,
+ disabled: true,
+ onClickStopButton,
+ });
+
+ const stopButton = screen.getByRole('button', { name: 'Stop message' });
+ expect(stopButton).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ test('disableSend prop takes precedence over loading state', () => {
+ const onClickStopButton = jest.fn();
+ renderInputBar({
+ state: State.Loading,
+ disableSend: true,
+ onClickStopButton,
+ });
+
+ const stopButton = screen.getByRole('button', { name: 'Stop message' });
+ expect(stopButton).toHaveAttribute('aria-disabled', 'true');
+ });
+
+ test('does not call onClickStopButton if not in loading state', () => {
+ const onClickStopButton = jest.fn();
+ const onMessageSend = jest.fn();
+
+ renderInputBar({
+ onClickStopButton,
+ onMessageSend,
+ textareaProps: { value: TEST_INPUT_TEXT },
+ });
+
+ const sendButton = screen.getByRole('button', { name: 'Send message' });
+ userEvent.click(sendButton);
+
+ expect(onClickStopButton).not.toHaveBeenCalled();
+ expect(onMessageSend).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/chat/input-bar/src/InputBar/InputBar.tsx b/chat/input-bar/src/InputBar/InputBar.tsx
index 1d48b264a6..76f483dc62 100644
--- a/chat/input-bar/src/InputBar/InputBar.tsx
+++ b/chat/input-bar/src/InputBar/InputBar.tsx
@@ -48,6 +48,7 @@ import { breakpoints } from '@leafygreen-ui/tokens';
import { DisclaimerText } from '../DisclaimerText';
import { InputBarFeedback } from '../InputBarFeedback';
import { InputBarSendButton } from '../InputBarSendButton';
+import { InputBarStopButton } from '../InputBarStopButton';
import { State } from '../shared.types';
import { setReactTextAreaValue } from '../utils/setReactTextAreaValue';
@@ -76,6 +77,7 @@ export const InputBar = forwardRef(
dropdownFooterSlot,
dropdownProps,
errorMessage,
+ onClickStopButton,
onMessageSend,
onSubmit,
shouldRenderGradient: shouldRenderGradientProp = true,
@@ -142,8 +144,10 @@ export const InputBar = forwardRef(
const [shouldRenderButtonText, setShouldRenderButtonText] =
useState(false);
+ const isLoading = state === State.Loading;
const isSendButtonDisabled =
- disableSend || disabled || messageBody?.trim() === '';
+ disabled || disableSend || messageBody?.trim() === '';
+ const isStopButtonDisabled = disabled || !!disableSend;
const shouldRenderGradient =
!isCompact && shouldRenderGradientProp && isFocused && !disabled;
const showHotkeyIndicator =
@@ -377,6 +381,13 @@ export const InputBar = forwardRef(
onSubmit?.(e);
};
+ const handleStop = () => {
+ if (onClickStopButton) {
+ onClickStopButton();
+ }
+ restorePreviousMessage();
+ };
+
const handleFocus: FocusEventHandler = _ => {
setIsFocused(true);
openMenu();
@@ -390,6 +401,18 @@ export const InputBar = forwardRef(
closeMenu();
};
+ /**
+ * Helper function to restore the previous message body.
+ * Used when stopping during loading or when an error occurs.
+ */
+ const restorePreviousMessage = useCallback(() => {
+ if (!isControlled) {
+ updateValue(prevMessageBody, internalTextareaRef);
+ setPrevMessageBody('');
+ }
+ internalTextareaRef.current?.focus();
+ }, [isControlled, prevMessageBody, updateValue]);
+
useAutoScroll(highlightedElementRef, menuRef, 12);
useBackdropClick(handleBackdropClick, [focusContainerRef, menuRef], {
enabled: isOpen && withTypeAhead,
@@ -418,13 +441,8 @@ export const InputBar = forwardRef(
return;
}
- if (!isControlled) {
- updateValue(prevMessageBody, internalTextareaRef);
- setPrevMessageBody('');
- }
-
- internalTextareaRef.current?.focus();
- }, [state, prevState, isControlled, prevMessageBody, updateValue]);
+ restorePreviousMessage();
+ }, [state, prevState, restorePreviousMessage]);
return (
@@ -490,12 +508,18 @@ export const InputBar = forwardRef(
/
)}
-
+ {isLoading && isCompact ? (
+
+ ) : (
+
+ )}
diff --git a/chat/input-bar/src/InputBar/InputBar.types.ts b/chat/input-bar/src/InputBar/InputBar.types.ts
index 871e8ee7ac..b1501f9e1d 100644
--- a/chat/input-bar/src/InputBar/InputBar.types.ts
+++ b/chat/input-bar/src/InputBar/InputBar.types.ts
@@ -46,7 +46,13 @@ export type InputBarProps = React.ComponentPropsWithoutRef<'form'> &
>;
/**
- * Submit event handler.
+ * Callback fired when the stop button is clicked during a loading state.
+ * When triggered, the message input will be restored to the previous message body.
+ */
+ onClickStopButton?: () => void;
+
+ /**
+ * Callback fired when the user sends a message.
*/
onMessageSend?: (
messageBody: string,
diff --git a/chat/input-bar/src/InputBarSendButton/InputBarSendButton.styles.ts b/chat/input-bar/src/InputBarSendButton/InputBarSendButton.styles.ts
index 37bc5d186f..81907f8aca 100644
--- a/chat/input-bar/src/InputBarSendButton/InputBarSendButton.styles.ts
+++ b/chat/input-bar/src/InputBarSendButton/InputBarSendButton.styles.ts
@@ -1,21 +1,6 @@
-import { css, cx } from '@leafygreen-ui/emotion';
import { Theme } from '@leafygreen-ui/lib';
-import { palette } from '@leafygreen-ui/palette';
import { color, InteractionState, Variant } from '@leafygreen-ui/tokens';
-/**
- * Off-palette value specific to primary button instances
- * @todo Consolidate usage of #00593F
- * @see https://jira.mongodb.org/browse/LG-5388
- *
- * @remarks This is a temporary duplicate to avoid importing the
- * `PRIMARY_BUTTON_INTERACTIVE_GREEN` constant from
- * `@leafygreen-ui/button/constants`. Consumers are blocked from upgrading
- * to the latest version of button package due to type issues from
- * `@leafygreen-ui/button@23.0.0` https://jira.mongodb.org/browse/LG-5462
- */
-const PRIMARY_BUTTON_INTERACTIVE_GREEN = '#00593F';
-
export const getIconFill = ({
disabled,
theme,
@@ -29,61 +14,3 @@ export const getIconFill = ({
return color[theme].icon[Variant.Primary][InteractionState.Default];
};
-
-const getBaseIconButtonStyles = ({ theme }: { theme: Theme }) => {
- const darkMode = theme === Theme.Dark;
- return css`
- background-color: ${palette.green.dark2};
- border: 1px solid ${palette.green[darkMode ? 'base' : 'dark2']};
- color: ${palette.white};
-
- &:active,
- &:hover {
- background-color: ${PRIMARY_BUTTON_INTERACTIVE_GREEN};
- color: ${palette.white};
- box-shadow: 0 0 0 3px ${palette.green[darkMode ? 'dark3' : 'light2']};
- }
-
- &:focus-visible {
- background-color: ${PRIMARY_BUTTON_INTERACTIVE_GREEN};
- color: ${palette.white};
- }
- `;
-};
-
-const getDisabledIconButtonStyles = (theme: Theme) => css`
- background-color: ${color[theme].background[Variant.Disabled][
- InteractionState.Default
- ]};
- color: ${color[theme].icon[Variant.Disabled][InteractionState.Default]};
- border-color: ${color[theme].border[Variant.Disabled][
- InteractionState.Default
- ]};
-
- &:active,
- &:hover {
- background-color: ${color[theme].background[Variant.Disabled][
- InteractionState.Default
- ]};
- color: ${color[theme].icon[Variant.Disabled][InteractionState.Default]};
- box-shadow: none;
- }
-
- &:focus-visible {
- background-color: ${color[theme].background[Variant.Disabled][
- InteractionState.Default
- ]};
- color: ${color[theme].icon[Variant.Disabled][InteractionState.Default]};
- }
-`;
-
-export const getIconButtonStyles = ({
- disabled,
- theme,
-}: {
- disabled: boolean;
- theme: Theme;
-}) =>
- cx(getBaseIconButtonStyles({ theme }), {
- [getDisabledIconButtonStyles(theme)]: disabled,
- });
diff --git a/chat/input-bar/src/InputBarSendButton/InputBarSendButton.tsx b/chat/input-bar/src/InputBarSendButton/InputBarSendButton.tsx
index 4c04d8f81a..7d8596c57b 100644
--- a/chat/input-bar/src/InputBarSendButton/InputBarSendButton.tsx
+++ b/chat/input-bar/src/InputBarSendButton/InputBarSendButton.tsx
@@ -1,25 +1,22 @@
import React, { forwardRef } from 'react';
-import Button from '@leafygreen-ui/button';
+import { Button } from '@leafygreen-ui/button';
import ArrowUpIcon from '@leafygreen-ui/icon/dist/ArrowUp';
-import StopIcon from '@leafygreen-ui/icon/dist/Stop';
-import IconButton from '@leafygreen-ui/icon-button';
+import { IconButton } from '@leafygreen-ui/icon-button';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import { State } from '../shared.types';
+import { getIconButtonStyles } from '../shared.styles';
-import { getIconButtonStyles, getIconFill } from './InputBarSendButton.styles';
+import { getIconFill } from './InputBarSendButton.styles';
import { InputBarSendButtonProps } from './InputBarSendButton.types';
import { ReturnIcon } from './ReturnIcon';
export const InputBarSendButton = forwardRef<
HTMLButtonElement,
InputBarSendButtonProps
->(({ disabled, isCompact, shouldRenderButtonText, state, ...rest }, fwdRef) => {
+>(({ disabled, isCompact, shouldRenderButtonText, ...rest }, fwdRef) => {
const { theme } = useDarkMode();
- const isLoading = state === State.Loading;
-
if (!isCompact) {
return (