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 (