diff --git a/packages/blade/src/components/DatePicker/DatePicker.web.tsx b/packages/blade/src/components/DatePicker/DatePicker.web.tsx index 881bd8119f6..d677df711f8 100644 --- a/packages/blade/src/components/DatePicker/DatePicker.web.tsx +++ b/packages/blade/src/components/DatePicker/DatePicker.web.tsx @@ -34,6 +34,7 @@ import type { StyledPropsBlade } from '~components/Box/styledProps'; import { getStyledProps } from '~components/Box/styledProps'; import { metaAttribute, MetaConstants } from '~utils/metaAttribute'; import { componentZIndices } from '~utils/componentZIndices'; +import { fireNativeEvent } from '~utils/fireNativeEvent'; const DatePicker = ({ selectionType, @@ -71,6 +72,7 @@ const DatePicker = ({ const isSingle = _selectionType === 'single'; const [_, forceRerender] = React.useReducer((x: number) => x + 1, 0); const [selectedPreset, setSelectedPreset] = React.useState(null); + const referenceRef = React.useRef(null); const [_picker, setPicker] = useControllableState({ defaultValue: defaultPicker, @@ -97,6 +99,7 @@ const DatePicker = ({ defaultValue, onChange: (date) => { onChange?.(date as never); + fireNativeEvent(referenceRef, ['input']); if (isSingle) return; // sync selected preset with value setSelectedPreset(date as DatesRangeValue); @@ -124,6 +127,7 @@ const DatePicker = ({ const handleApply = (): void => { if (isSingle) { onChange?.(controlledValue); + fireNativeEvent(referenceRef, ['change']); setOldValue(controlledValue); onApply?.(controlledValue); close(); @@ -132,6 +136,7 @@ const DatePicker = ({ // only apply if both dates are selected if (hasBothDatesSelected) { onChange?.(controlledValue); + fireNativeEvent(referenceRef, ['change']); setOldValue(controlledValue); onApply?.(controlledValue); close(); @@ -140,6 +145,7 @@ const DatePicker = ({ const handleCancel = (): void => { setControlledValue(oldValue); + fireNativeEvent(referenceRef, ['change']); setPickedDate(null); close(); }; @@ -147,7 +153,6 @@ const DatePicker = ({ const isMobile = useIsMobile(); const defaultInitialFocusRef = React.useRef(null); const titleId = useId('datepicker-title'); - const referenceRef = React.useRef(null); const { context, refs, diff --git a/packages/blade/src/components/DatePicker/__tests__/DatePicker.web.test.tsx b/packages/blade/src/components/DatePicker/__tests__/DatePicker.web.test.tsx new file mode 100644 index 00000000000..4862c74ce74 --- /dev/null +++ b/packages/blade/src/components/DatePicker/__tests__/DatePicker.web.test.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef } from 'react'; +import dayjs from 'dayjs'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/react'; +import { DatePicker as DatePickerComponent } from '..'; +import { Box } from '~components/Box'; +import renderWithTheme from '~utils/testing/renderWithTheme.web'; + +describe(' ', () => { + jest.setTimeout(10000); + it('should fire native events like input and change', async () => { + const handleInput = jest.fn(); + const handleChange = jest.fn(); + + const DatePicker = (): React.ReactElement => { + const ref = useRef(null); + + const addEventListeners = (): void => { + if (ref.current) { + ref.current.addEventListener('input', handleInput); + ref.current.addEventListener('change', handleChange); + } + }; + + const removeEventListeners = (): void => { + if (ref.current) { + ref.current.removeEventListener('input', handleInput); + ref.current.removeEventListener('change', handleChange); + } + }; + + useEffect(() => { + addEventListeners(); + return removeEventListeners; + }, []); + + return ( + + + + ); + }; + + const user = userEvent.setup(); + const { getByRole, queryByText } = renderWithTheme(); + + const input = getByRole('combobox', { name: /Select Date/i }); + await user.click(input); + + await waitFor(() => expect(queryByText('Sun')).toBeVisible()); + + const dateToSelect = dayjs().add(1, 'day'); + const date = getByRole('button', { name: dateToSelect.format('DD MMMM YYYY') }); + await user.click(date); + + const applyButton = getByRole('button', { name: /Apply/i }); + await user.click(applyButton); + expect(handleChange).toBeCalled(); + expect(handleInput).toBeCalled(); + }); +}); diff --git a/packages/blade/src/components/Dropdown/useDropdown.ts b/packages/blade/src/components/Dropdown/useDropdown.ts index 7eefd21011c..8841cc16860 100644 --- a/packages/blade/src/components/Dropdown/useDropdown.ts +++ b/packages/blade/src/components/Dropdown/useDropdown.ts @@ -14,8 +14,9 @@ import type { DropdownProps } from './types'; import { dropdownComponentIds } from './dropdownComponentIds'; import type { FormInputHandleOnKeyDownEvent } from '~components/Form/FormTypes'; -import { isReactNative } from '~utils'; +import { isReactNative, isBrowser } from '~utils'; import type { ContainerElementType } from '~utils/types'; +import { fireNativeEvent } from '~utils/fireNativeEvent'; // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = (): void => {}; @@ -355,6 +356,9 @@ const useDropdown = (): UseDropdownReturnValue => { const optionValues = options.map((option) => option.value); ensureScrollVisiblity(updatedIndex, rest.actionListItemRef.current, optionValues); + if (isBrowser()) { + fireNativeEvent(rest.actionListItemRef as React.RefObject, ['change', 'input']); + } }; /** diff --git a/packages/blade/src/components/FileUpload/FileUpload.web.tsx b/packages/blade/src/components/FileUpload/FileUpload.web.tsx index 64fc3fb1fcd..687e24516bb 100644 --- a/packages/blade/src/components/FileUpload/FileUpload.web.tsx +++ b/packages/blade/src/components/FileUpload/FileUpload.web.tsx @@ -26,6 +26,7 @@ import { makeAccessible } from '~utils/makeAccessible'; import { formHintLeftLabelMarginLeft } from '~components/Input/BaseInput/baseInputTokens'; import { useMergeRefs } from '~utils/useMergeRefs'; import { useControllableState } from '~utils/useControllable'; +import { fireNativeEvent } from '~utils/fireNativeEvent'; const _FileUpload: React.ForwardRefRenderFunction = ( { @@ -158,6 +159,7 @@ const _FileUpload: React.ForwardRefRenderFunction id !== selectedFiles[0].id); setSelectedFiles(() => newFiles); onRemove?.({ file: selectedFiles[0] }); + fireNativeEvent(inputRef, ['change', 'input']); }} onReupload={() => { const newFiles = selectedFiles.filter(({ id }) => id !== selectedFiles[0].id); @@ -368,6 +371,7 @@ const _FileUpload: React.ForwardRefRenderFunction id !== file.id); setSelectedFiles(() => newFiles); onRemove?.({ file }); + fireNativeEvent(inputRef, ['change', 'input']); }} onReupload={() => { const newFiles = selectedFiles.filter(({ id }) => id !== file.id); @@ -386,6 +390,7 @@ const _FileUpload: React.ForwardRefRenderFunction id !== file.id); setSelectedFiles(() => newFiles); onDismiss?.({ file }); + fireNativeEvent(inputRef, ['change', 'input']); }} onPreview={onPreview} /> diff --git a/packages/blade/src/components/FileUpload/__tests__/FileUpload.web.test.tsx b/packages/blade/src/components/FileUpload/__tests__/FileUpload.web.test.tsx index c9820708904..48839b3a1ac 100644 --- a/packages/blade/src/components/FileUpload/__tests__/FileUpload.web.test.tsx +++ b/packages/blade/src/components/FileUpload/__tests__/FileUpload.web.test.tsx @@ -1,5 +1,7 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; +import userEvent from '@testing-library/user-event'; import { FileUpload } from '../FileUpload'; +import { Box } from '~components/Box'; import renderWithTheme from '~utils/testing/renderWithTheme.web'; import assertAccessible from '~utils/testing/assertAccessible.web'; @@ -102,4 +104,56 @@ describe('', () => { expect(getByTestId('file-upload-test')).toBeTruthy(); }); + it('Should fire native events like input and change', async () => { + const blob = new Blob(['']); + const filename = 'my-image.png'; + const file = new File([blob], filename, { + type: 'image/png', + }); + const user = userEvent.setup(); + const handleInput = jest.fn(); + const handleChange = jest.fn(); + + const DatePicker = (): React.ReactElement => { + const ref = useRef(null); + const addEventListeners = (): void => { + if (ref.current) { + ref.current.addEventListener('input', handleInput); + ref.current.addEventListener('change', handleChange); + } + }; + + const removeEventListeners = (): void => { + if (ref.current) { + ref.current.removeEventListener('input', handleInput); + ref.current.removeEventListener('change', handleChange); + } + }; + + useEffect(() => { + addEventListeners(); + return removeEventListeners; + }, []); + return ( + + + + ); + }; + const { getByText } = renderWithTheme(); + + const input = getByText('Drag files here or').closest('div')?.querySelector('input'); + + await user.upload(input as HTMLElement, file); + expect(getByText(filename)).toBeVisible(); + + expect(handleChange).toBeCalled(); + expect(handleInput).toBeCalled(); + }); }); diff --git a/packages/blade/src/components/Input/DropdownInputTriggers/AutoComplete.tsx b/packages/blade/src/components/Input/DropdownInputTriggers/AutoComplete.tsx index 7a7bfbf34a6..635b8173caa 100644 --- a/packages/blade/src/components/Input/DropdownInputTriggers/AutoComplete.tsx +++ b/packages/blade/src/components/Input/DropdownInputTriggers/AutoComplete.tsx @@ -134,7 +134,6 @@ const useAutoComplete = ({ } props.onChange?.({ name: props.name, values }); }; - return { onSelectionChange, onTriggerKeydown, diff --git a/packages/blade/src/components/Input/DropdownInputTriggers/BaseDropdownInputTrigger.tsx b/packages/blade/src/components/Input/DropdownInputTriggers/BaseDropdownInputTrigger.tsx index 649566c48f7..ece946ddc09 100644 --- a/packages/blade/src/components/Input/DropdownInputTriggers/BaseDropdownInputTrigger.tsx +++ b/packages/blade/src/components/Input/DropdownInputTriggers/BaseDropdownInputTrigger.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { BaseInput } from '../BaseInput'; import type { BaseInputProps } from '../BaseInput'; import { InputChevronIcon } from './InputChevronIcon'; -import type { BaseDropdownInputTriggerProps } from './types'; +import type { BaseDropdownInputTriggerProps, useControlledDropdownInputProps } from './types'; import isEmpty from '~utils/lodashButBetter/isEmpty'; import { useDropdown } from '~components/Dropdown/useDropdown'; -import { isReactNative } from '~utils'; +import { isReactNative, isBrowser } from '~utils'; import { getActionListContainerRole } from '~components/ActionList/getA11yRoles'; import { MetaConstants } from '~utils/metaAttribute'; import { getTagsGroup } from '~components/Tag/getTagsGroup'; @@ -18,19 +18,9 @@ import { validationStateToInputTrailingIconMap, } from '~components/Table/tokens'; import { useTableEditableCell } from '~components/Table/TableEditableCellContext'; +import { fireNativeEvent } from '~utils/fireNativeEvent'; -const useControlledDropdownInput = ( - props: Pick< - BaseDropdownInputTriggerProps, - | 'onChange' - | 'name' - | 'value' - | 'defaultValue' - | 'onInputValueChange' - | 'syncInputValueWithSelection' - | 'isSelectInput' - >, -): void => { +const useControlledDropdownInput = (props: useControlledDropdownInputProps): void => { const isFirstRender = useFirstRender(); const { changeCallbackTriggerer, @@ -116,6 +106,9 @@ const useControlledDropdownInput = ( name: props.name, values: getValuesArrayFromIndices(), }); + if (isBrowser()) { + fireNativeEvent(props.triggererRef, ['change', 'input']); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [changeCallbackTriggerer]); @@ -176,6 +169,7 @@ const _BaseDropdownInputTrigger = ( defaultValue: props.defaultValue, syncInputValueWithSelection: props.syncInputValueWithSelection, isSelectInput: props.isSelectInput, + triggererRef, }); const getValue = (): string | undefined => { diff --git a/packages/blade/src/components/Input/DropdownInputTriggers/__tests__/AutoComplete.web.test.tsx b/packages/blade/src/components/Input/DropdownInputTriggers/__tests__/AutoComplete.web.test.tsx index 002351bb663..eb87167166b 100644 --- a/packages/blade/src/components/Input/DropdownInputTriggers/__tests__/AutoComplete.web.test.tsx +++ b/packages/blade/src/components/Input/DropdownInputTriggers/__tests__/AutoComplete.web.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import userEvent from '@testing-library/user-event'; import { waitFor, screen, act } from '@testing-library/react'; import { Dropdown, DropdownOverlay } from '~components/Dropdown'; @@ -470,4 +470,122 @@ describe(' & with ', () => { await user.click(getByRole('option', { name: 'Pune' })); expect(getByRole('option', { name: 'Pune' })).toHaveAttribute('aria-selected', 'true'); }, 10000); + it('should fire native events like input and change', async () => { + const cities = [ + { + title: 'Mumbai', + value: 'mumbai', + keywords: ['maharashtra'], + }, + { + title: 'Pune', + value: 'pune', + keywords: ['maharashtra'], + }, + { + title: 'Bengaluru', + value: 'bengaluru', + keywords: ['karnataka', 'bangalore'], + }, + ]; + const handleInput = jest.fn(); + const handleChange = jest.fn(); + + const ControlledFiltering = (): React.ReactElement => { + const cityValues = cities.map((city) => city.value); + const [filteredValues, setFilteredValues] = React.useState(cityValues); + const ref = React.useRef(null); + + useEffect(() => { + if (ref?.current) { + ref.current.addEventListener('input', () => { + handleInput(); + }); + ref.current.addEventListener('change', () => { + handleChange(); + }); + } + return () => { + if (ref?.current) { + ref.current.removeEventListener('input', () => { + handleInput(); + }); + ref.current.removeEventListener('change', () => { + handleChange(); + }); + } + }; + }, []); + + return ( + + + + + + { + if (value) { + const filteredItems = cities + .filter( + (city) => + city.title.toLowerCase().startsWith(value.toLowerCase()) || + city.keywords.find((keyword) => + keyword.toLowerCase().includes(value.toLowerCase()), + ), + ) + .map((city) => city.value); + + if (filteredItems.length > 0) { + setFilteredValues(filteredItems); + } else { + setFilteredValues([]); + } + } else { + setFilteredValues(cityValues); + } + }} + filteredValues={filteredValues} + helpText="Try typing 'maharashtra' in input" + /> + + + + {cities.map((city) => ( + + ))} + + + + + + ); + }; + + const user = userEvent.setup(); + const { getByRole, getByLabelText, queryByTestId } = renderWithTheme(); + + const selectInput = getByLabelText('Cities') as HTMLInputElement; + const autoComplete = queryByTestId('cities-autocomplete') as HTMLInputElement; + + expect(selectInput).toBeInTheDocument(); + expect(queryByTestId('bottomsheet-body')).not.toBeVisible(); + + await user.click(selectInput); + await waitFor(() => expect(queryByTestId('bottomsheet-body')).toBeVisible()); + + act(() => { + autoComplete.focus(); + }); + + expect(getByRole('option', { name: 'Mumbai' })).toBeVisible(); + expect(getByRole('option', { name: 'Pune' })).toBeVisible(); + expect(getByRole('option', { name: 'Bengaluru' })).toBeVisible(); + + await user.click(getByRole('option', { name: 'Pune' })); + expect(handleInput).toBeCalled(); + expect(handleChange).toBeCalled(); + }, 10000); }); diff --git a/packages/blade/src/components/Input/DropdownInputTriggers/types.ts b/packages/blade/src/components/Input/DropdownInputTriggers/types.ts index 9ae131d9f24..90490ec49a7 100644 --- a/packages/blade/src/components/Input/DropdownInputTriggers/types.ts +++ b/packages/blade/src/components/Input/DropdownInputTriggers/types.ts @@ -127,6 +127,19 @@ export type BaseDropdownInputTriggerProps = Omit< onTriggerClick: BaseInputProps['onClick']; }; +export type useControlledDropdownInputProps = Pick< + BaseDropdownInputTriggerProps, + | 'onChange' + | 'name' + | 'value' + | 'defaultValue' + | 'onInputValueChange' + | 'syncInputValueWithSelection' + | 'isSelectInput' +> & { + triggererRef: React.RefObject; +}; + export type SelectInputProps = DropdownInputTriggersProps; export type AutoCompleteProps = DropdownInputTriggersCommonProps & { diff --git a/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.native.test.ts b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.native.test.ts new file mode 100644 index 00000000000..01d1482a13a --- /dev/null +++ b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.native.test.ts @@ -0,0 +1,10 @@ +import { fireNativeEvent } from './fireNativeEvent.native'; + +describe('fireNativeEvent', () => { + const ref: React.RefObject = { current: null }; + it('should throw specific error', () => { + expect(() => fireNativeEvent(ref, ['change'])).toThrowError( + '[Blade: FireNativeEvent]: FireNativeEvent is not supported on react-native', + ); + }); +}); diff --git a/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.native.ts b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.native.ts new file mode 100644 index 00000000000..c91d5cf9394 --- /dev/null +++ b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.native.ts @@ -0,0 +1,14 @@ +import { throwBladeError } from '../logger'; +/** + * FireNativeEvent is not supported on react-native + */ + +export const fireNativeEvent = ( + _ref: React.RefObject | null, + _eventTypes: Array<'change' | 'input'>, +): void => { + throwBladeError({ + message: 'FireNativeEvent is not supported on react-native', + moduleName: 'FireNativeEvent', + }); +}; diff --git a/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.web.test.ts b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.web.test.ts new file mode 100644 index 00000000000..9320da76fb3 --- /dev/null +++ b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.web.test.ts @@ -0,0 +1,45 @@ +import React from 'react'; +import { fireNativeEvent } from './fireNativeEvent.web'; + +describe('fireNativeEvent', () => { + let ref: React.RefObject; + + beforeEach(() => { + ref = React.createRef(); + // using div as a test element because it don't have both "change" and "input" events + document.body.innerHTML = '
'; + Object.defineProperty(ref, 'current', { value: document.getElementById('test-element') }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should dispatch "change" event on the element', () => { + const handleChange = jest.fn(); + ref.current?.addEventListener('change', handleChange); + + fireNativeEvent(ref, ['change']); + expect(handleChange).toHaveBeenCalled(); + }); + + it('should dispatch "input" event on the element', () => { + const handleInput = jest.fn(); + ref.current?.addEventListener('input', handleInput); + + fireNativeEvent(ref, ['input']); + expect(handleInput).toHaveBeenCalled(); + }); + + it('should dispatch both "change" and "input" events on the element', () => { + const handleChange = jest.fn(); + const handleInput = jest.fn(); + + ref.current?.addEventListener('change', handleChange); + ref.current?.addEventListener('input', handleInput); + + fireNativeEvent(ref, ['change', 'input']); + expect(handleChange).toHaveBeenCalled(); + expect(handleInput).toHaveBeenCalled(); + }); +}); diff --git a/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.web.ts b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.web.ts new file mode 100644 index 00000000000..26577674a25 --- /dev/null +++ b/packages/blade/src/utils/fireNativeEvent/fireNativeEvent.web.ts @@ -0,0 +1,28 @@ +/** + * Fires native events on a given HTML element reference. + * + * @param ref - A React ref object pointing to an HTML element or null. + * @param eventTypes - An array of event types to be dispatched. Supported event types are 'change' and 'input'. + * + * @remarks + * This function creates and dispatches native events of the specified types on the element referenced by `ref`. + * If `ref` is null, a warning is logged to the console. + * + * @example + * ```typescript + * const inputRef = React.createRef(); + * fireNativeEvent(inputRef, ['change', 'input']); + * ``` + */ + +export const fireNativeEvent = ( + ref: React.RefObject | null, + eventTypes: Array<'change' | 'input'>, +): void => { + if (!ref) return; + + eventTypes.forEach((eventType) => { + const event = new Event(eventType, { bubbles: true }); + ref.current?.dispatchEvent(event); + }); +}; diff --git a/packages/blade/src/utils/fireNativeEvent/index.ts b/packages/blade/src/utils/fireNativeEvent/index.ts new file mode 100644 index 00000000000..945d5383130 --- /dev/null +++ b/packages/blade/src/utils/fireNativeEvent/index.ts @@ -0,0 +1 @@ +export * from './fireNativeEvent'; diff --git a/packages/blade/src/utils/platform/index.all.ts b/packages/blade/src/utils/platform/index.all.ts index 640f5651e48..ab3dd132729 100644 --- a/packages/blade/src/utils/platform/index.all.ts +++ b/packages/blade/src/utils/platform/index.all.ts @@ -1,4 +1,5 @@ export { isReactNative } from './isReactNative'; +export { isBrowser } from './isBrowser'; export * from './getOS'; export type { Platform } from './platform.all'; export * from './castUtils'; diff --git a/packages/blade/src/utils/platform/index.native.ts b/packages/blade/src/utils/platform/index.native.ts index e8f448c3136..2e04d2600a3 100644 --- a/packages/blade/src/utils/platform/index.native.ts +++ b/packages/blade/src/utils/platform/index.native.ts @@ -1,4 +1,5 @@ export { isReactNative } from './isReactNative'; +export { isBrowser } from './isBrowser'; export * from './getOS'; export type { Platform } from './platform.native'; export * from './castUtils'; diff --git a/packages/blade/src/utils/platform/index.ts b/packages/blade/src/utils/platform/index.ts index 95171c55cbc..1248971bad2 100644 --- a/packages/blade/src/utils/platform/index.ts +++ b/packages/blade/src/utils/platform/index.ts @@ -1,4 +1,5 @@ export { isReactNative } from './isReactNative'; +export { isBrowser } from './isBrowser'; export * from './getOS'; export type { Platform } from './platform'; export * from './castUtils'; diff --git a/packages/blade/src/utils/platform/isBrowser.ts b/packages/blade/src/utils/platform/isBrowser.ts new file mode 100644 index 00000000000..993d7ce1b3f --- /dev/null +++ b/packages/blade/src/utils/platform/isBrowser.ts @@ -0,0 +1,7 @@ +import { getPlatformType } from '../getPlatformType'; + +const isBrowser = (): boolean => { + return getPlatformType() === 'browser'; +}; + +export { isBrowser };