Skip to content

Commit 2d31506

Browse files
authored
[LG-5600] feat(input-bar): add onClickStop prop and update button disabled logic (#3195)
* feat(input-bar): add onClickStop prop and fix disabled state * docs(input-bar): README * refactor(input-bar): reorg buttons and rename prop
1 parent 9f05151 commit 2d31506

File tree

12 files changed

+334
-139
lines changed

12 files changed

+334
-139
lines changed

.changeset/large-lands-worry.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@lg-chat/input-bar': minor
3+
---
4+
5+
[LG-5600](https://jira.mongodb.org/browse/LG-5600)
6+
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.
7+
8+
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).

chat/input-bar/README.md

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,54 +22,59 @@ npm install @lg-chat/input-bar
2222

2323
## Example
2424

25-
### Compact
26-
27-
```tsx
28-
import { InputBar } from '@lg-chat/input-bar';
29-
import {
30-
LeafyGreenChatProvider,
31-
Variant,
32-
} from '@lg-chat/leafygreen-chat-provider';
33-
34-
return (
35-
<LeafyGreenChatProvider variant={Variant.Compact}>
36-
<InputBar />
37-
</LeafyGreenChatProvider>
38-
);
39-
```
40-
41-
### Spacious
42-
4325
```tsx
44-
import { InputBar } from '@lg-chat/input-bar';
26+
import { useState } from 'react';
27+
import { InputBar, State } from '@lg-chat/input-bar';
4528
import {
4629
LeafyGreenChatProvider,
4730
Variant,
4831
} from '@lg-chat/leafygreen-chat-provider';
4932

50-
return (
51-
<LeafyGreenChatProvider variant={Variant.Spacious}>
52-
<InputBar />
53-
</LeafyGreenChatProvider>
54-
);
33+
const Example = () => {
34+
const [state, setState] = useState<State>(State.Unset);
35+
36+
const handleMessageSend = (messageBody: string) => {
37+
console.log('Sending:', messageBody);
38+
setState(State.Loading);
39+
// Simulate API call
40+
};
41+
42+
const handleClickStopButton = () => {
43+
console.log('Stopping request');
44+
setState(State.Unset);
45+
// Cancel your API request here
46+
};
47+
48+
return (
49+
<LeafyGreenChatProvider variant={Variant.Compact}>
50+
<InputBar
51+
onMessageSend={handleMessageSend}
52+
onClickStopButton={handleClickStopButton}
53+
state={state}
54+
errorMessage="Failed to send message. Please try again."
55+
/>
56+
</LeafyGreenChatProvider>
57+
);
58+
};
5559
```
5660

5761
## Properties
5862

59-
| Prop | Type | Description | Default |
60-
| ----------------------------- | ---------------------------------------------- | ----------------------------------------------------------- | ------- |
61-
| `badgeText` | `string` | Determines the text inside the rendered Badge | |
62-
| `darkMode` | `boolean` | Determines if the component will render in dark mode | `false` |
63-
| `disabled` | `boolean` | Determines whether the user can interact with the InputBar | `false` |
64-
| `disableSend` | `boolean` | When defined as `true`, disables the send action and button | |
65-
| `errorMessage` | `ReactNode` | Custom error message to display when `state='error'` | |
66-
| `onMessageSend` | `(messageBody: string, e?: FormEvent) => void` | Submit event handler. | |
67-
| `shouldRenderGradient` | `boolean` | Toggles the gradient animation around the input | `true` |
68-
| `shouldRenderHotkeyIndicator` | `boolean` | Toggles the hotkey indicator on the right side of the input | `false` |
69-
| `textareaProps` | `TextareaAutosizeProps` | Props passed to the TextareaAutosize component. | |
70-
| `textareaRef` | `RefObject<HTMLTextAreaElement>` | Ref object to access the textarea element directly | |
71-
| `state` | `'unset' \| 'error' \| 'loading'` | The current state of the InputBar. | |
72-
| `...` | `HTMLElementProps<'form'>` | Props spread on the root element | |
63+
| Prop | Type | Description | Default |
64+
| ----------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
65+
| `badgeText` | `string` | Determines the text inside the rendered Badge | |
66+
| `darkMode` | `boolean` | Determines if the component will render in dark mode | `false` |
67+
| `disabled` | `boolean` | Determines whether the user can interact with the InputBar | `false` |
68+
| `disableSend` | `boolean` | When defined as `true`, disables the send action and button | |
69+
| `errorMessage` | `ReactNode` | Custom error message to display when `state='error'` | |
70+
| `onClickStopButton` | `() => void` | Callback fired when the stop button is clicked during a loading state. Restores the previous message body. Only applies in compact variant. | |
71+
| `onMessageSend` | `(messageBody: string, e?: FormEvent) => void` | Callback fired when the user sends a message. | |
72+
| `shouldRenderGradient` | `boolean` | Toggles the gradient animation around the input | `true` |
73+
| `shouldRenderHotkeyIndicator` | `boolean` | Toggles the hotkey indicator on the right side of the input | `false` |
74+
| `textareaProps` | `TextareaAutosizeProps` | Props passed to the TextareaAutosize component. | |
75+
| `textareaRef` | `RefObject<HTMLTextAreaElement>` | Ref object to access the textarea element directly | |
76+
| `state` | `'unset' \| 'error' \| 'loading'` | The current state of the InputBar. | |
77+
| `...` | `HTMLElementProps<'form'>` | Props spread on the root element | |
7378

7479
## TextareaAutosizeProps
7580

chat/input-bar/src/InputBar/InputBar.spec.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,4 +384,123 @@ describe('packages/input-bar', () => {
384384
);
385385
});
386386
});
387+
388+
describe('onClickStopButton', () => {
389+
test('renders stop button during loading state', () => {
390+
renderInputBar({ state: State.Loading });
391+
392+
const stopButton = screen.getByRole('button', { name: 'Stop message' });
393+
expect(stopButton).toBeInTheDocument();
394+
expect(stopButton).not.toHaveAttribute('aria-disabled', 'true');
395+
});
396+
397+
test('does not render send button during loading state', () => {
398+
renderInputBar({ state: State.Loading });
399+
400+
const sendButton = screen.queryByRole('button', { name: 'Send message' });
401+
expect(sendButton).not.toBeInTheDocument();
402+
});
403+
404+
test('calls onClickStopButton when button is clicked during loading state', () => {
405+
const onClickStopButton = jest.fn();
406+
renderInputBar({ state: State.Loading, onClickStopButton });
407+
408+
const stopButton = screen.getByRole('button', { name: 'Stop message' });
409+
userEvent.click(stopButton);
410+
411+
expect(onClickStopButton).toHaveBeenCalledTimes(1);
412+
});
413+
414+
test('restores previous message when stop is clicked in uncontrolled mode', () => {
415+
const onClickStopButton = jest.fn();
416+
const onMessageSend = jest.fn();
417+
const { rerender } = renderInputBar({ onClickStopButton, onMessageSend });
418+
419+
const textarea = screen.getByRole('textbox');
420+
const sendButton = screen.getByRole('button', { name: 'Send message' });
421+
422+
userEvent.type(textarea, TEST_INPUT_TEXT);
423+
expect(textarea).toHaveValue(TEST_INPUT_TEXT);
424+
425+
userEvent.click(sendButton);
426+
expect(onMessageSend).toHaveBeenCalledTimes(1);
427+
expect(textarea).toHaveValue('');
428+
429+
rerender(
430+
<LeafyGreenChatProvider variant={Variant.Compact}>
431+
<InputBar
432+
state={State.Loading}
433+
onClickStopButton={onClickStopButton}
434+
onMessageSend={onMessageSend}
435+
/>
436+
</LeafyGreenChatProvider>,
437+
);
438+
439+
const stopButton = screen.getByRole('button', { name: 'Stop message' });
440+
441+
userEvent.click(stopButton);
442+
443+
expect(onClickStopButton).toHaveBeenCalledTimes(1);
444+
expect(textarea).toHaveValue(TEST_INPUT_TEXT);
445+
});
446+
447+
test('does not call onMessageSend when stopping during loading state', () => {
448+
const onMessageSend = jest.fn();
449+
const onClickStopButton = jest.fn();
450+
451+
renderInputBar({
452+
state: State.Loading,
453+
onMessageSend,
454+
onClickStopButton,
455+
textareaProps: { value: TEST_INPUT_TEXT },
456+
});
457+
458+
const stopButton = screen.getByRole('button', { name: 'Stop message' });
459+
userEvent.click(stopButton);
460+
461+
expect(onClickStopButton).toHaveBeenCalledTimes(1);
462+
expect(onMessageSend).not.toHaveBeenCalled();
463+
});
464+
465+
test('disabled prop takes precedence over loading state', () => {
466+
const onClickStopButton = jest.fn();
467+
renderInputBar({
468+
state: State.Loading,
469+
disabled: true,
470+
onClickStopButton,
471+
});
472+
473+
const stopButton = screen.getByRole('button', { name: 'Stop message' });
474+
expect(stopButton).toHaveAttribute('aria-disabled', 'true');
475+
});
476+
477+
test('disableSend prop takes precedence over loading state', () => {
478+
const onClickStopButton = jest.fn();
479+
renderInputBar({
480+
state: State.Loading,
481+
disableSend: true,
482+
onClickStopButton,
483+
});
484+
485+
const stopButton = screen.getByRole('button', { name: 'Stop message' });
486+
expect(stopButton).toHaveAttribute('aria-disabled', 'true');
487+
});
488+
489+
test('does not call onClickStopButton if not in loading state', () => {
490+
const onClickStopButton = jest.fn();
491+
const onMessageSend = jest.fn();
492+
493+
renderInputBar({
494+
onClickStopButton,
495+
onMessageSend,
496+
textareaProps: { value: TEST_INPUT_TEXT },
497+
});
498+
499+
const sendButton = screen.getByRole('button', { name: 'Send message' });
500+
userEvent.click(sendButton);
501+
502+
expect(onClickStopButton).not.toHaveBeenCalled();
503+
expect(onMessageSend).toHaveBeenCalledTimes(1);
504+
});
505+
});
387506
});

chat/input-bar/src/InputBar/InputBar.tsx

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { breakpoints } from '@leafygreen-ui/tokens';
4848
import { DisclaimerText } from '../DisclaimerText';
4949
import { InputBarFeedback } from '../InputBarFeedback';
5050
import { InputBarSendButton } from '../InputBarSendButton';
51+
import { InputBarStopButton } from '../InputBarStopButton';
5152
import { State } from '../shared.types';
5253
import { setReactTextAreaValue } from '../utils/setReactTextAreaValue';
5354

@@ -76,6 +77,7 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
7677
dropdownFooterSlot,
7778
dropdownProps,
7879
errorMessage,
80+
onClickStopButton,
7981
onMessageSend,
8082
onSubmit,
8183
shouldRenderGradient: shouldRenderGradientProp = true,
@@ -142,8 +144,10 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
142144
const [shouldRenderButtonText, setShouldRenderButtonText] =
143145
useState<boolean>(false);
144146

147+
const isLoading = state === State.Loading;
145148
const isSendButtonDisabled =
146-
disableSend || disabled || messageBody?.trim() === '';
149+
disabled || disableSend || messageBody?.trim() === '';
150+
const isStopButtonDisabled = disabled || !!disableSend;
147151
const shouldRenderGradient =
148152
!isCompact && shouldRenderGradientProp && isFocused && !disabled;
149153
const showHotkeyIndicator =
@@ -377,6 +381,13 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
377381
onSubmit?.(e);
378382
};
379383

384+
const handleStop = () => {
385+
if (onClickStopButton) {
386+
onClickStopButton();
387+
}
388+
restorePreviousMessage();
389+
};
390+
380391
const handleFocus: FocusEventHandler<HTMLTextAreaElement> = _ => {
381392
setIsFocused(true);
382393
openMenu();
@@ -390,6 +401,18 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
390401
closeMenu();
391402
};
392403

404+
/**
405+
* Helper function to restore the previous message body.
406+
* Used when stopping during loading or when an error occurs.
407+
*/
408+
const restorePreviousMessage = useCallback(() => {
409+
if (!isControlled) {
410+
updateValue(prevMessageBody, internalTextareaRef);
411+
setPrevMessageBody('');
412+
}
413+
internalTextareaRef.current?.focus();
414+
}, [isControlled, prevMessageBody, updateValue]);
415+
393416
useAutoScroll(highlightedElementRef, menuRef, 12);
394417
useBackdropClick(handleBackdropClick, [focusContainerRef, menuRef], {
395418
enabled: isOpen && withTypeAhead,
@@ -418,13 +441,8 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
418441
return;
419442
}
420443

421-
if (!isControlled) {
422-
updateValue(prevMessageBody, internalTextareaRef);
423-
setPrevMessageBody('');
424-
}
425-
426-
internalTextareaRef.current?.focus();
427-
}, [state, prevState, isControlled, prevMessageBody, updateValue]);
444+
restorePreviousMessage();
445+
}, [state, prevState, restorePreviousMessage]);
428446

429447
return (
430448
<LeafyGreenProvider darkMode={darkMode}>
@@ -490,12 +508,18 @@ export const InputBar = forwardRef<HTMLFormElement, InputBarProps>(
490508
/
491509
</div>
492510
)}
493-
<InputBarSendButton
494-
disabled={isSendButtonDisabled}
495-
isCompact={isCompact}
496-
shouldRenderButtonText={shouldRenderButtonText}
497-
state={state}
498-
/>
511+
{isLoading && isCompact ? (
512+
<InputBarStopButton
513+
disabled={isStopButtonDisabled}
514+
onClick={handleStop}
515+
/>
516+
) : (
517+
<InputBarSendButton
518+
disabled={isSendButtonDisabled}
519+
isCompact={isCompact}
520+
shouldRenderButtonText={shouldRenderButtonText}
521+
/>
522+
)}
499523
</div>
500524
</div>
501525
</div>

chat/input-bar/src/InputBar/InputBar.types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,13 @@ export type InputBarProps = React.ComponentPropsWithoutRef<'form'> &
4646
>;
4747

4848
/**
49-
* Submit event handler.
49+
* Callback fired when the stop button is clicked during a loading state.
50+
* When triggered, the message input will be restored to the previous message body.
51+
*/
52+
onClickStopButton?: () => void;
53+
54+
/**
55+
* Callback fired when the user sends a message.
5056
*/
5157
onMessageSend?: (
5258
messageBody: string,

0 commit comments

Comments
 (0)