diff --git a/packages/components/search/package.json b/packages/components/search/package.json new file mode 100644 index 0000000000..f5fbf3f0a2 --- /dev/null +++ b/packages/components/search/package.json @@ -0,0 +1,73 @@ +{ + "name": "@vibe/search", + "version": "3.0.0", + "description": "Vibe sub-package for search component", + "repository": { + "type": "git", + "url": "git+https://github.com/mondaycom/vibe.git", + "directory": "packages/components/search" + }, + "bugs": { + "url": "https://github.com/mondaycom/vibe/issues" + }, + "homepage": "https://github.com/mondaycom/vibe#readme", + "author": "monday.com", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./mockedClassNames": { + "import": "./dist/mocked_classnames/index.js", + "default": "./dist/mocked_classnames/index.js" + } + }, + "scripts": { + "build": "rollup -c && mock_classnames=on rollup -c", + "test": "vitest run", + "lint": "eslint \"./src/**/*.{js,jsx,ts,tsx}\"" + }, + "dependencies": { + "@vibe/base": "3.0.4", + "@vibe/icon": "3.0.9", + "@vibe/icon-button": "3.0.0", + "@vibe/icons": "1.15.0", + "@vibe/loader": "3.0.9", + "@vibe/shared": "3.0.8", + "classnames": "^2.5.1" + }, + "devDependencies": { + "@testing-library/react": "^12.1.2", + "@testing-library/user-event": "^13.5.0", + "@vibe/config": "3.0.5", + "react": "^16.13.0", + "react-dom": "^16.13.0", + "react-test-renderer": "16", + "typescript": "^4.7.3", + "vitest": "^1.6.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "sideEffects": [ + "*.scss", + "*.css", + "*.scss.js", + "*.css.js" + ], + "eslintConfig": { + "extends": [ + "../../../node_modules/@vibe/config/.eslintrc.cjs" + ] + } +} diff --git a/packages/components/search/rollup.config.mjs b/packages/components/search/rollup.config.mjs new file mode 100644 index 0000000000..d290842c68 --- /dev/null +++ b/packages/components/search/rollup.config.mjs @@ -0,0 +1,3 @@ +import config from "@vibe/config/rollup.config"; + +export default config; diff --git a/packages/core/src/components/Search/Search.module.scss b/packages/components/search/src/Search/Search.module.scss similarity index 100% rename from packages/core/src/components/Search/Search.module.scss rename to packages/components/search/src/Search/Search.module.scss diff --git a/packages/core/src/components/Search/Search.tsx b/packages/components/search/src/Search/Search.tsx similarity index 93% rename from packages/core/src/components/Search/Search.tsx rename to packages/components/search/src/Search/Search.tsx index a9a57df3ba..1c75363d03 100644 --- a/packages/core/src/components/Search/Search.tsx +++ b/packages/components/search/src/Search/Search.tsx @@ -1,16 +1,13 @@ import cx from "classnames"; import React, { forwardRef, useCallback, useRef } from "react"; -import useMergeRef from "../../hooks/useMergeRef"; +import { useMergeRef, useDebounceEvent, ComponentDefaultTestId, getTestId, ComponentVibeId } from "@vibe/shared"; import { CloseSmall as CloseSmallIcon, Search as SearchIcon } from "@vibe/icons"; -import { ComponentDefaultTestId, getTestId } from "../../tests/test-ids-utils"; import styles from "./Search.module.scss"; import { BaseInput } from "@vibe/base"; -import useDebounceEvent from "../../hooks/useDebounceEvent"; import { IconButton } from "@vibe/icon-button"; import { Icon } from "@vibe/icon"; import { type SearchProps } from "./Search.types"; import { Loader } from "@vibe/loader"; -import { ComponentVibeId } from "../../tests/constants"; const Search = forwardRef( ( diff --git a/packages/core/src/components/Search/Search.types.ts b/packages/components/search/src/Search/Search.types.ts similarity index 91% rename from packages/core/src/components/Search/Search.types.ts rename to packages/components/search/src/Search/Search.types.ts index a6fae6d278..d3efedfcc4 100644 --- a/packages/core/src/components/Search/Search.types.ts +++ b/packages/components/search/src/Search/Search.types.ts @@ -1,9 +1,7 @@ import type React from "react"; -import { type VibeComponentProps } from "../../types"; +import { type VibeComponentProps } from "@vibe/shared"; import { type SubIcon } from "@vibe/icon"; import { type InputSize } from "@vibe/base"; -import type { IconButton } from "@vibe/icon-button"; -import type MenuButton from "../MenuButton/MenuButton"; export interface SearchProps extends VibeComponentProps { /** @@ -21,7 +19,7 @@ export interface SearchProps extends VibeComponentProps { /** * Renders an additional action button in the search input. */ - renderAction?: React.ReactElement; + renderAction?: React.ReactElement; /** * If true, hides the additional action button when input has text. */ diff --git a/packages/core/src/components/Search/__tests__/Search.test.tsx b/packages/components/search/src/Search/__tests__/Search.test.tsx similarity index 99% rename from packages/core/src/components/Search/__tests__/Search.test.tsx rename to packages/components/search/src/Search/__tests__/Search.test.tsx index a14e0cfa57..90c4b48934 100644 --- a/packages/core/src/components/Search/__tests__/Search.test.tsx +++ b/packages/components/search/src/Search/__tests__/Search.test.tsx @@ -190,3 +190,4 @@ describe("Search", () => { }); }); }); + diff --git a/packages/core/src/components/Search/index.ts b/packages/components/search/src/Search/index.ts similarity index 100% rename from packages/core/src/components/Search/index.ts rename to packages/components/search/src/Search/index.ts diff --git a/packages/components/search/src/index.ts b/packages/components/search/src/index.ts new file mode 100644 index 0000000000..f3cfe1bbad --- /dev/null +++ b/packages/components/search/src/index.ts @@ -0,0 +1 @@ +export * from "./Search"; diff --git a/packages/components/search/src/types/files.d.ts b/packages/components/search/src/types/files.d.ts new file mode 100644 index 0000000000..26595ff21a --- /dev/null +++ b/packages/components/search/src/types/files.d.ts @@ -0,0 +1,4 @@ +declare module "*.scss" { + const content: { [className: string]: string }; + export default content; +} diff --git a/packages/components/search/tsconfig.json b/packages/components/search/tsconfig.json new file mode 100644 index 0000000000..53fa14b4a9 --- /dev/null +++ b/packages/components/search/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@vibe/config/tsconfig", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/packages/components/search/vitest.config.mjs b/packages/components/search/vitest.config.mjs new file mode 100644 index 0000000000..26e6fb9794 --- /dev/null +++ b/packages/components/search/vitest.config.mjs @@ -0,0 +1,6 @@ +import config from "@vibe/config/vitest.config"; +import { defineConfig } from "vite"; + +export default defineConfig({ + ...config +}); diff --git a/packages/components/search/vitest.setup.mjs b/packages/components/search/vitest.setup.mjs new file mode 100644 index 0000000000..5e79ae32c4 --- /dev/null +++ b/packages/components/search/vitest.setup.mjs @@ -0,0 +1,21 @@ +import { vi } from "vitest"; +import "@testing-library/jest-dom"; +import React from "react"; + +// Mock ResizeObserver +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +global.ResizeObserver = ResizeObserver; + +// Mock react-inlinesvg +vi.mock("react-inlinesvg", () => ({ + default: ({ src, ...props }) => + React.createElement("div", { + "data-testid": "mock-svg", + "data-src": src, + ...props + }) +})); diff --git a/packages/components/typography/src/hooks/useIsOverflowing.ts b/packages/components/typography/src/hooks/useIsOverflowing.ts new file mode 100644 index 0000000000..975cfa9a8c --- /dev/null +++ b/packages/components/typography/src/hooks/useIsOverflowing.ts @@ -0,0 +1,54 @@ +import { type RefObject, useCallback, useState } from "react"; +import useResizeObserver from "./useResizeObserver"; + +function checkOverflow(element: HTMLElement, ignoreHeightOverflow: boolean, heightTolerance = 0, widthTolerance = 0) { + if (!element) { + return false; + } + const isOverflowingWidth = element.clientWidth + widthTolerance < element.scrollWidth; + const isOverflowingHeight = !ignoreHeightOverflow && element.clientHeight + heightTolerance < element.scrollHeight; + return isOverflowingWidth || isOverflowingHeight; +} + +export default function useIsOverflowing({ + ref, + ignoreHeightOverflow = false, + tolerance: heightTolerance, + widthTolerance +}: { + /** + * The ref of the element to check for overflow. + */ + ref: RefObject; + /** + * Whether to ignore height overflow. + */ + ignoreHeightOverflow?: boolean; + /** + * The tolerance value to consider the element as overflowing (height overflow). + */ + tolerance?: number; + /** + * The tolerance value to consider the element as overflowing (width overflow). + */ + widthTolerance?: number; +}) { + const [isOverflowing, setIsOverflowing] = useState(() => + checkOverflow(ref?.current, ignoreHeightOverflow, heightTolerance, widthTolerance) + ); + const callback = useCallback(() => { + const element = ref?.current; + if (!element) return; + + const newOverflowState = checkOverflow(element, ignoreHeightOverflow, heightTolerance, widthTolerance); + setIsOverflowing(prevState => (prevState !== newOverflowState ? newOverflowState : prevState)); + }, [ignoreHeightOverflow, ref, heightTolerance, widthTolerance]); + + useResizeObserver({ + ref, + callback, + debounceTime: 0 + }); + + return isOverflowing; +} diff --git a/packages/components/typography/src/hooks/useResizeObserver.ts b/packages/components/typography/src/hooks/useResizeObserver.ts new file mode 100644 index 0000000000..2d157e5196 --- /dev/null +++ b/packages/components/typography/src/hooks/useResizeObserver.ts @@ -0,0 +1,67 @@ +import { type RefObject, useCallback, useEffect } from "react"; +import { debounce } from "es-toolkit"; + +type ResizeCallback = ({ borderBoxSize }: { borderBoxSize: ResizeObserverSize }) => void; + +export default function useResizeObserver({ + ref, + callback, + debounceTime = 200 +}: { + ref: RefObject; + callback: ResizeCallback; + debounceTime?: number; +}) { + const debouncedCallback = useCallback(debounce(callback, debounceTime), [callback, debounceTime]); + + useEffect(() => { + if (!window.ResizeObserver) { + return; + } + if (!ref?.current) return; + + function borderBoxSizeCallback(borderBoxSize: ResizeObserverSize | ReadonlyArray): number { + const value = Array.isArray(borderBoxSize) ? borderBoxSize[0] : borderBoxSize; + return window.requestAnimationFrame(() => { + debouncedCallback({ borderBoxSize: value }); + }); + } + + let animationFrameId: number | null = null; + + const resizeObserver = new ResizeObserver(entries => { + const entry = entries[0]; + if (entry && entry.borderBoxSize) { + // handle chrome (entry.borderBoxSize[0]) + // handle ff (entry.borderBoxSize) + if (!Array.isArray(entry.borderBoxSize)) { + animationFrameId = borderBoxSizeCallback(entry.borderBoxSize); + } else { + const borderBoxEntry = entry.borderBoxSize[0]; + animationFrameId = borderBoxSizeCallback(borderBoxEntry); + } + } else if (entry.contentRect) { + // handle safari (entry.contentRect) + const borderBoxSize = { blockSize: entry.contentRect.height, inlineSize: entry?.contentRect?.width || 0 }; + animationFrameId = borderBoxSizeCallback(borderBoxSize); + } else { + return; + } + }); + + resizeObserver.observe(ref?.current); + + return () => { + if (debounceTime !== 0) { + debouncedCallback.cancel(); + } + + if (animationFrameId) { + window.cancelAnimationFrame(animationFrameId); + } + + resizeObserver.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ref?.current, callback, debounceTime, debouncedCallback]); +} diff --git a/packages/core/package.json b/packages/core/package.json index 5cafc6b019..b4aa9c5d84 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -101,6 +101,7 @@ "@vibe/layer": "3.0.7", "@vibe/layout": "3.0.2", "@vibe/loader": "3.0.9", + "@vibe/search": "3.0.0", "@vibe/shared": "3.0.8", "@vibe/tooltip": "3.0.0", "@vibe/typography": "3.0.0", diff --git a/packages/core/src/components/Combobox/Combobox.tsx b/packages/core/src/components/Combobox/Combobox.tsx index ddd12abab6..e68387fd6b 100644 --- a/packages/core/src/components/Combobox/Combobox.tsx +++ b/packages/core/src/components/Combobox/Combobox.tsx @@ -5,7 +5,7 @@ import { noop as NOOP, camelCase } from "es-toolkit"; import { getStyle } from "../../helpers/typesciptCssModulesHelper"; import { ComponentDefaultTestId, getTestId } from "../../tests/test-ids-utils"; import useMergeRef from "../../hooks/useMergeRef"; -import Search from "../Search/Search"; +import { Search } from "@vibe/search"; import { BASE_SIZES } from "../../constants"; import { Button } from "@vibe/button"; import { Text } from "@vibe/typography"; diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index a6e84679fb..c2976b99af 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -59,7 +59,7 @@ export * from "./LegacyModal"; export * from "./MultiStepIndicator"; export * from "./ProgressBars"; export * from "./RadioButton"; -export * from "./Search"; +export * from "@vibe/search"; export * from "./Skeleton"; export * from "./Slider"; export * from "./SplitButton"; diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index d797387450..7342419afd 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -4,3 +4,4 @@ export * from "./useEventListener"; export * from "./ssr/useIsMounted"; export * from "./ssr/useIsomorphicLayoutEffect"; export * from "./useKeyboardButtonPressedFunc"; +export * from "./useDebounceEvent"; diff --git a/packages/shared/src/hooks/useDebounceEvent.ts b/packages/shared/src/hooks/useDebounceEvent.ts new file mode 100644 index 0000000000..f269da563f --- /dev/null +++ b/packages/shared/src/hooks/useDebounceEvent.ts @@ -0,0 +1,72 @@ +import { + useMemo, + useCallback, + useState, + useRef, + useEffect, + type ChangeEvent, + type Dispatch, + type SetStateAction +} from "react"; +import { debounce, noop } from "es-toolkit"; + +export type UseDebounceResult = { + inputValue: string; + onEventChanged: (event: ChangeEvent | Partial>) => void; + clearValue: () => void; + updateValue: Dispatch>; +}; + +export function useDebounceEvent({ + delay = 0, + onChange, + initialStateValue = "", + trim +}: { + onChange: (value: string) => void; + initialStateValue?: string; + delay?: number; + trim?: boolean; +}) { + const [inputValue, setValue] = useState(initialStateValue); + const previousValue = useRef(null); + + useEffect(() => { + previousValue.current = initialStateValue; + }); + + const debounceCallback = useMemo(() => { + if (!delay) { + return onChange; + } + + if (!onChange) { + return noop; + } + + return debounce(onChange, delay); + }, [onChange, delay]); + + const onEventChanged = useCallback( + (event: ChangeEvent | Partial>) => { + const { value } = event.target; + const finalValue = trim ? value.trim() : value; + setValue(finalValue); + debounceCallback(finalValue); + }, + [debounceCallback, setValue, trim] + ); + + const clearValue = useCallback(() => { + setValue(""); + if (onChange) { + onChange(""); + } + }, [setValue, onChange]); + + if (initialStateValue !== previousValue.current && initialStateValue !== inputValue) { + setValue(initialStateValue); + } + + return { inputValue, onEventChanged, clearValue, updateValue: setValue }; +}