Skip to content

Commit d43ab25

Browse files
rzp-slash[bot]rohankokane-dev
andcommitted
feat(blade-svelte): address friction fixes for TextInput, OTPInput and BottomSheet
- CB-039: add readOnly prop to TextInput via BaseInput - CB-060: add spellCheck prop to TextInput via BaseInput - CB-043: add autoCompleteSuggestionType 'newOtp' to OTPInput for non-Safari OTP auto-read - CB-044: add onKeyDown prop to OTPInput for custom keyboard navigation - CB-046: make BottomSheet isOpen $bindable and write back closed state - add component tests for the new props Co-authored-by: rohankokane-dev <rohan.kokane@razorpay.com>
1 parent ecdbf92 commit d43ab25

12 files changed

Lines changed: 142 additions & 2 deletions

File tree

packages/blade-svelte/src/components/BottomSheet/BottomSheet.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
void getBottomSheetTemplateClasses();
4343
4444
let {
45-
isOpen = false,
45+
isOpen = $bindable(false),
4646
onDismiss,
4747
children,
4848
initialFocusRef = null,
@@ -166,6 +166,7 @@
166166
167167
function close(): void {
168168
if (isDismissible) {
169+
isOpen = false;
169170
onDismiss?.();
170171
}
171172
returnFocus();

packages/blade-svelte/src/components/BottomSheet/__tests__/BottomSheet.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
22
import { describe, it, expect, vi, afterEach } from 'vitest';
33
import BottomSheetFocusTestHarness from './BottomSheetFocusTestHarness.svelte';
4+
import BottomSheetBindableTestHarness from './BottomSheetBindableTestHarness.svelte';
45

56
/* Spy on the prototype so the focus call is captured regardless of when the
67
* target element mounts (the sheet portals + defers focus across two rAFs). */
@@ -56,4 +57,16 @@ describe('<BottomSheet /> focus management', () => {
5657
await waitFor(() => expect(focusSpy.mock.contexts).toContain(trigger));
5758
expect(focusOptionsFor(focusSpy, trigger)).toEqual({ preventScroll: true });
5859
});
60+
61+
it('writes isOpen back into the bound variable on dismiss (bind:isOpen)', async () => {
62+
render(BottomSheetBindableTestHarness, { props: { isOpen: false } });
63+
64+
/* Open the sheet by setting the bound variable to true. */
65+
await fireEvent.click(screen.getByTestId('open-sheet'));
66+
await waitFor(() => expect(screen.getByTestId('bound-is-open')).toHaveTextContent('true'));
67+
68+
/* Escape dismisses the dismissible sheet — the bound `isOpen` must flip back. */
69+
await fireEvent.keyDown(window, { key: 'Escape' });
70+
await waitFor(() => expect(screen.getByTestId('bound-is-open')).toHaveTextContent('false'));
71+
});
5972
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import BottomSheet from '../BottomSheet.svelte';
3+
4+
let {
5+
isOpen = false,
6+
isDismissible = true,
7+
}: {
8+
isOpen?: boolean;
9+
isDismissible?: boolean;
10+
} = $props();
11+
</script>
12+
13+
<span data-testid="bound-is-open">{String(isOpen)}</span>
14+
15+
<button type="button" data-testid="open-sheet" onclick={() => (isOpen = true)}>open</button>
16+
<BottomSheet bind:isOpen {isDismissible}>
17+
{#snippet children()}
18+
<button type="button" data-testid="inside">inside</button>
19+
{/snippet}
20+
</BottomSheet>

packages/blade-svelte/src/components/Input/BaseInput/BaseInput.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
onBlur,
5555
isDisabled = false,
5656
isRequired = false,
57+
readOnly = false,
58+
spellCheck,
5759
leadingIcon,
5860
prefix,
5961
trailingInteractionElement,
@@ -357,6 +359,8 @@
357359
value={currentValue}
358360
disabled={effectiveDisabled || undefined}
359361
required={isRequired || undefined}
362+
readonly={readOnly || undefined}
363+
spellcheck={spellCheck}
360364
maxlength={maxCharacters}
361365
tabindex={tabIndex}
362366
autocomplete={domAutoComplete}
@@ -383,6 +387,8 @@
383387
value={currentValue}
384388
disabled={effectiveDisabled || undefined}
385389
required={isRequired || undefined}
390+
readonly={readOnly || undefined}
391+
spellcheck={spellCheck}
386392
maxlength={maxCharacters}
387393
tabindex={tabIndex}
388394
autocomplete={domAutoComplete}

packages/blade-svelte/src/components/Input/BaseInput/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type AutoCompleteSuggestionType =
3838
| 'password'
3939
| 'newPassword'
4040
| 'oneTimeCode'
41+
| 'newOtp'
4142
| 'telephone'
4243
| 'postalCode'
4344
| 'countryName'
@@ -156,6 +157,10 @@ export type BaseInputCommonProps = FormInputLabelProps &
156157
isDisabled?: boolean;
157158
/** Marks the input required (adds `required`). */
158159
isRequired?: boolean;
160+
/** Makes the input read-only (adds `readonly`, keeps it focusable). */
161+
readOnly?: boolean;
162+
/** Controls the browser spellcheck on the input. */
163+
spellCheck?: boolean;
159164
/** Leading icon component. */
160165
leadingIcon?: IconComponent;
161166
/** Prefix text rendered at the start of the input. */

packages/blade-svelte/src/components/Input/BaseInput/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const autoCompleteSuggestionTypeMap: Record<AutoCompleteSuggestionType, string>
101101
password: 'current-password',
102102
newPassword: 'new-password',
103103
oneTimeCode: 'one-time-code',
104+
newOtp: 'one-time-code',
104105
telephone: 'tel',
105106
postalCode: 'postal-code',
106107
countryName: 'country',

packages/blade-svelte/src/components/Input/OTPInput/OTPInput.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
onFocus,
3232
onBlur,
3333
onOTPFilled,
34+
onKeyDown,
3435
value,
3536
isDisabled = false,
3637
autoFocus = false,
@@ -147,6 +148,7 @@
147148
{ key, code, event }: Parameters<FormInputOnKeyDownEvent>[0],
148149
currentOtpIndex: number,
149150
): void => {
151+
onKeyDown?.({ name, key, code, event, inputIndex: currentOtpIndex });
150152
if (key === 'Backspace' || code === 'Backspace' || code === 'Delete' || key === 'Delete') {
151153
event.preventDefault?.();
152154
if (isControlled ? value?.[currentOtpIndex] : otpValue[currentOtpIndex]) {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { render, screen, waitFor } from '@testing-library/svelte';
2+
import userEvent from '@testing-library/user-event';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import OTPInput from '../OTPInput.svelte';
5+
6+
async function findFields(): Promise<HTMLInputElement[]> {
7+
return screen.findAllByRole('textbox');
8+
}
9+
10+
describe('<OTPInput />', () => {
11+
it('fires onKeyDown with the field index', async () => {
12+
const user = userEvent.setup();
13+
const onKeyDown = vi.fn();
14+
render(OTPInput, {
15+
props: { label: 'OTP', otpLength: 6, onKeyDown },
16+
});
17+
18+
const fields = await findFields();
19+
await user.click(fields[2]);
20+
await user.keyboard('{ArrowRight}');
21+
22+
await waitFor(() => {
23+
const call = onKeyDown.mock.calls.find(([payload]) => payload.event instanceof KeyboardEvent);
24+
expect(call).toBeDefined();
25+
expect(call[0].inputIndex).toBe(2);
26+
expect(call[0].key).toBe('ArrowRight');
27+
});
28+
});
29+
30+
it('maps autoCompleteSuggestionType=newOtp to the one-time-code autocomplete', async () => {
31+
render(OTPInput, {
32+
props: { accessibilityLabel: 'OTP', otpLength: 6, autoCompleteSuggestionType: 'newOtp' },
33+
});
34+
35+
const fields = await findFields();
36+
// The first field (and every field) receives the autocomplete value mapped
37+
// from `newOtp` → `one-time-code`.
38+
expect(fields[0]).toHaveAttribute('autocomplete', 'one-time-code');
39+
});
40+
});

packages/blade-svelte/src/components/Input/OTPInput/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ export type OTPInputOnEventWithIndex = (event: {
1616
inputIndex: number;
1717
}) => void;
1818

19+
/** Event payload for OTP `onKeyDown`, including the field index. */
20+
export type OTPInputOnKeyDownEvent = (event: {
21+
name?: string;
22+
key?: string;
23+
code?: string;
24+
event: KeyboardEvent;
25+
inputIndex: number;
26+
}) => void;
27+
1928
type OTPInputPropsWithLabel = {
2029
/** Label shown above/beside the OTP fields. */
2130
label: string;
@@ -72,7 +81,9 @@ interface OTPInputCommonProps extends StyledPropsBlade, DataAnalyticsAttribute {
7281
/** Masks the entered characters (renders `password` after entry). */
7382
isMasked?: boolean;
7483
/** Autocomplete suggestion type. @default 'oneTimeCode' */
75-
autoCompleteSuggestionType?: 'none' | 'oneTimeCode';
84+
autoCompleteSuggestionType?: 'none' | 'oneTimeCode' | 'newOtp';
85+
/** KeyDown callback for custom keyboard navigation between fields. */
86+
onKeyDown?: OTPInputOnKeyDownEvent;
7687
/** Input size. @default 'medium' */
7788
size?: BaseInputSize;
7889
/** Test ID for the outer wrapper. */

packages/blade-svelte/src/components/Input/TextInput/TextInput.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
onKeyDown,
3030
isDisabled = false,
3131
isRequired = false,
32+
readOnly = false,
33+
spellCheck,
3234
prefix,
3335
suffix,
3436
maxCharacters,
@@ -171,6 +173,8 @@
171173
{onKeyDown}
172174
{isDisabled}
173175
{isRequired}
176+
{readOnly}
177+
{spellCheck}
174178
{prefix}
175179
{suffix}
176180
{leadingIcon}

0 commit comments

Comments
 (0)