Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/large-lands-worry.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions .changeset/real-ears-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lg-chat/input-bar': patch
---

Remove console warning when `shouldRenderGradient` prop is true to avoid it requiring an explicit false value.
81 changes: 43 additions & 38 deletions chat/input-bar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LeafyGreenChatProvider variant={Variant.Compact}>
<InputBar />
</LeafyGreenChatProvider>
);
```

### 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 (
<LeafyGreenChatProvider variant={Variant.Spacious}>
<InputBar />
</LeafyGreenChatProvider>
);
const Example = () => {
const [state, setState] = useState<State>(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 (
<LeafyGreenChatProvider variant={Variant.Compact}>
<InputBar
onMessageSend={handleMessageSend}
onClickStopButton={handleClickStopButton}
state={state}
errorMessage="Failed to send message. Please try again."
/>
</LeafyGreenChatProvider>
);
};
```

## 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<HTMLTextAreaElement>` | 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<HTMLTextAreaElement>` | 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

Expand Down
119 changes: 119 additions & 0 deletions chat/input-bar/src/InputBar/InputBar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<LeafyGreenChatProvider variant={Variant.Compact}>
<InputBar
state={State.Loading}
onClickStopButton={onClickStopButton}
onMessageSend={onMessageSend}
/>
</LeafyGreenChatProvider>,
);

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);
});
});
});
60 changes: 42 additions & 18 deletions chat/input-bar/src/InputBar/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -76,6 +77,7 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
dropdownFooterSlot,
dropdownProps,
errorMessage,
onClickStopButton,
onMessageSend,
onSubmit,
shouldRenderGradient: shouldRenderGradientProp = true,
Expand All @@ -91,10 +93,10 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
const { containerWidth, variant } = useLeafyGreenChatContext();
const isCompact = variant === Variant.Compact;

if (
isCompact &&
(shouldRenderHotkeyIndicator || shouldRenderGradientProp || badgeText)
) {
// Note: `shouldRenderGradient` is intentionally excluded from this warning check
// as the prop is scheduled for removal in a future update.
// See: https://jira.mongodb.org/browse/LG-5575
if (isCompact && (shouldRenderHotkeyIndicator || badgeText)) {
consoleOnce.warn(
`@lg-chat/input-bar: The InputBar component's props 'shouldRenderHotkeyIndicator', 'shouldRenderGradient', and 'badgeText' are only used in the 'spacious' variant. They will not be rendered in the 'compact' variant set by the provider.`,
);
Expand Down Expand Up @@ -142,8 +144,10 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
const [shouldRenderButtonText, setShouldRenderButtonText] =
useState<boolean>(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 =
Expand Down Expand Up @@ -377,6 +381,13 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
onSubmit?.(e);
};

const handleStop = () => {
if (onClickStopButton) {
onClickStopButton();
}
restorePreviousMessage();
};

const handleFocus: FocusEventHandler<HTMLTextAreaElement> = _ => {
setIsFocused(true);
openMenu();
Expand All @@ -390,6 +401,18 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
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,
Expand Down Expand Up @@ -418,13 +441,8 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
return;
}

if (!isControlled) {
updateValue(prevMessageBody, internalTextareaRef);
setPrevMessageBody('');
}

internalTextareaRef.current?.focus();
}, [state, prevState, isControlled, prevMessageBody, updateValue]);
restorePreviousMessage();
}, [state, prevState, restorePreviousMessage]);

return (
<LeafyGreenProvider darkMode={darkMode}>
Expand Down Expand Up @@ -490,12 +508,18 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
/
</div>
)}
<InputBarSendButton
disabled={isSendButtonDisabled}
isCompact={isCompact}
shouldRenderButtonText={shouldRenderButtonText}
state={state}
/>
{isLoading && isCompact ? (
<InputBarStopButton
disabled={isStopButtonDisabled}
onClick={handleStop}
/>
) : (
<InputBarSendButton
disabled={isSendButtonDisabled}
isCompact={isCompact}
shouldRenderButtonText={shouldRenderButtonText}
/>
)}
</div>
</div>
</div>
Expand Down
Loading
Loading