diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index a70746a..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,25 +0,0 @@ -module.exports = { - parser: "babel-eslint", - extends: "airbnb", - plugins: ["react", "react-native"], - env: { - jest: true, - "react-native/react-native": true - }, - rules: { - // allow js file extension - "react/jsx-filename-extension": [ - "error", - { - extensions: [".js", ".jsx"] - } - ], - // for post defining style object in react-native - "no-use-before-define": ["error", { variables: false }], - // react-native rules - "react-native/no-unused-styles": 2, - "react-native/split-platform-components": 2, - "react-native/no-inline-styles": 2, - "react-native/no-raw-text": 2 - } -}; diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c3798fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Report a problem with react-native-header-search-bar +title: "[Bug] " +labels: bug +--- + +## Describe the bug + +A clear and concise description of what the bug is. + +## Reproduction + +Steps to reproduce, ideally with a minimal code snippet or Expo Snack: + +```jsx +import { HeaderSearchBar } from "react-native-header-search-bar"; + +// ... +``` + +## Expected behavior + +What you expected to happen. + +## Screenshots / video + +If applicable, add screenshots or a screen recording. + +## Environment + +- `react-native-header-search-bar` version: +- `react-native` version: +- `react` version: +- Platform: iOS / Android +- New Architecture (Fabric) enabled: yes / no +- Expo: yes / no (SDK version if yes) + +## Additional context + +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8c15d22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea or enhancement +title: "[Feature] " +labels: enhancement +--- + +## Problem + +Is your feature request related to a problem? A clear and concise description of what the problem is. + +## Proposed solution + +A clear and concise description of what you want to happen, including any new props or API surface. + +## Alternatives considered + +Any alternative solutions or features you've considered. + +## Additional context + +Add any other context, mockups, or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c75259c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ + + +## Summary + + + +## Type of change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would change existing behavior/API) +- [ ] Documentation only +- [ ] Tooling / CI + +## Checklist + +- [ ] `npm run lint` passes +- [ ] `npm run typecheck` passes +- [ ] `npm test` passes +- [ ] `npm run build` succeeds +- [ ] I updated the `CHANGELOG.md` under `[Unreleased]` +- [ ] I did not change any public prop names or runtime defaults (or I documented the breaking change) + +## Related issues + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4872c5a..6bc2a4b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,26 @@ version: 2 updates: -- package-ecosystem: npm - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 10 + # Library root (devDependencies, tooling) + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - dependencies + # Example app + - package-ecosystem: npm + directory: "/example" + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - example + # GitHub Actions + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + labels: + - ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b030473 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + verify: + name: Lint, typecheck, test & build (Node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [18, 20, 22] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + + - name: Install dependencies + run: npm install --legacy-peer-deps --ignore-scripts + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test -- --ci --coverage + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index a7acc97..f9ed8a7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,12 @@ package-lock.json node_modules +# build output (generated by react-native-builder-bob on prepare) +lib/ + +# test coverage +coverage/ + # Xcode # build/ diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 0ddec1c..0000000 --- a/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -# Node Modules -**/node_modules -node_modules -# Example -example -# Assets -Assets -assets \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bb570f6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,16 @@ +# dependencies +node_modules/ + +# build output +lib/ +coverage/ + +# logs +npm-debug.log +yarn-error.log + +# os +.DS_Store + +# lockfile (library) +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d54500c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "arrowParens": "always", + "trailingComma": "all", + "tabWidth": 2, + "semi": true, + "singleQuote": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d34e95f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +> This release is a deep modernization of a library that had not been touched +> since 2020. It migrates the source to TypeScript and ships compiled output via +> `react-native-builder-bob`. The **public API is unchanged** (same named exports +> `HeaderSearchBar` / `HeaderClassicSearchBar`, same default object export, same +> prop names and runtime defaults), but the published package layout and minimum +> peer versions changed — see **Breaking** below. A major version bump is +> recommended. + +### Added + +- TypeScript source with shipped declaration files (`.d.ts`). Exported prop + interfaces: `HeaderSearchBarProps`, `HeaderClassicSearchBarProps`, `SearchBoxProps`. +- `react-native-builder-bob` build pipeline producing CommonJS, ES module and + TypeScript targets under `lib/`. +- `package.json` `exports`, `module`, `react-native`, `source`, `types` and a + `files` allowlist so only `src` + `lib` + docs are published. +- Real test suite: Jest + `@testing-library/react-native` (21 tests across both + header variants and `SearchBox`, including default props, `onChangeText` + wiring, `disableTextInput`, custom `iconComponent`, and snapshots). +- GitHub Actions CI (`.github/workflows/ci.yml`): install + lint + typecheck + + test + build on a Node 18/20/22 matrix for pushes and PRs. +- `CHANGELOG.md`, `CONTRIBUTING.md`, issue templates and a pull request template. +- Accessibility roles/labels on the hamburger button, filter ripple target and + search box. +- `react-native-material-ripple` is now declared in `peerDependencies` (it was + imported by `HeaderClassicSearchBar` but never declared). + +### Changed + +- Migrated linting from the deprecated `tslint`/`babel-eslint` + `airbnb` setup + to ESLint 9 flat config (`eslint.config.js`) with `typescript-eslint`, + `eslint-plugin-react` and Prettier, and added an actual `.prettierrc` + (the README advertised a Prettier badge but no config existed). +- `HeaderClassicSearchBar` now uses `useSafeAreaInsets()` instead of the removed + `useSafeArea()` API, so it works on `react-native-safe-area-context` v3/v4/v5+. +- Components are now wrapped in `React.memo` and `React.forwardRef`, and drop the + deprecated `defaultProps`/`PropTypes` in favor of typed default parameters. +- Stopped spreading `{...props}` blindly onto the menu `Icon`, the filter `Ripple` + and the filter `Icon`; only intended props are forwarded now. The deliberate + prop passthrough to the inner `SearchBox` is preserved. +- Repository, homepage and bugs URLs now point at `kuraydev`. + +### Fixed + +- `SearchBox` no longer creates an implicit global on every render — the inner + `renderTextInput` helper is now a proper local `const`. +- `secondTitleFontSize` is now honored. The second-title style previously + hard-coded `fontSize: 18` and silently ignored the documented prop. +- `prop-types` is no longer an undeclared import (PropTypes were removed in the + TypeScript migration). + +### Removed + +- Raw, uncompiled `lib/src/*.js` source is no longer the package entry point. + Consumers that deep-imported `react-native-header-search-bar/lib/src/...` must + switch to the package root import (see migration notes in the README/PR). +- Runtime `PropTypes` validation (replaced by compile-time TypeScript types). + +## [0.1.10] - 2020-03-09 + +- Last legacy release. See git history. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d3f6ef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +Thanks for your interest in improving **react-native-header-search-bar**! 🎉 + +## Development setup + +```sh +git clone https://github.com/kuraydev/react-native-header-search-bar.git +cd react-native-header-search-bar +npm install --legacy-peer-deps +``` + +> The native peer dependencies (`react-native-androw`, +> `react-native-dynamic-vector-icons`, `react-native-material-ripple`, +> `@freakycoder/react-native-helpers`) are intentionally **not** installed in the +> library's own `node_modules` — they are peer dependencies provided by the host +> app. They are mocked in the test suite (`jest.setup.js`). + +## Project layout + +- `src/` — TypeScript source (the only thing you edit). +- `lib/` — generated build output (CommonJS + ESM + types). **Do not edit**; it is + git-ignored and produced by `npm run build`. +- `example/` — a bare React Native example app. + +## Useful scripts + +| Script | Description | +| ------------------- | -------------------------------------------- | +| `npm run lint` | ESLint (flat config) | +| `npm run typecheck` | `tsc --noEmit` | +| `npm test` | Jest test suite | +| `npm run build` | Build `lib/` with `react-native-builder-bob` | +| `npm run clean` | Remove `lib/` | + +Please make sure `lint`, `typecheck`, `test` and `build` all pass before opening +a pull request — CI runs the same checks on Node 18, 20 and 22. + +## Pull requests + +- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit + messages (e.g. `fix:`, `feat:`, `chore:`, `docs:`). +- Add or update tests for any behavior change. +- Add an entry under `## [Unreleased]` in `CHANGELOG.md`. +- **Do not** rename public exports or change runtime prop defaults without + flagging it as a breaking change. + +## Code style + +This project uses Prettier and ESLint. Run `npm run lint:fix` and +`npx prettier --write .` before committing. diff --git a/README.md b/README.md index 00e4880..5081157 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,186 @@ React Native Header Search Bar -[![Battle Tested ✅](https://img.shields.io/badge/-Battle--Tested%20%E2%9C%85-03666e?style=for-the-badge)](https://github.com/WrathChaos/react-native-header-search-bar) - -[![Fully customizable header search bar for React Native](https://img.shields.io/badge/-Fully%20customizable%20header%20search%20bar%20for%20React%20Native-lightgrey?style=for-the-badge)](https://github.com/WrathChaos/react-native-header-search-bar) +[![Fully customizable header search bar for React Native](https://img.shields.io/badge/-Fully%20customizable%20header%20search%20bar%20for%20React%20Native-lightgrey?style=for-the-badge)](https://github.com/kuraydev/react-native-header-search-bar) [![npm version](https://img.shields.io/npm/v/react-native-header-search-bar.svg?style=for-the-badge)](https://www.npmjs.com/package/react-native-header-search-bar) -[![npm](https://img.shields.io/npm/dt/react-native-header-search-bar.svg?style=for-the-badge)](https://www.npmjs.com/package/react-native-header-search-bar) +[![npm downloads](https://img.shields.io/npm/dt/react-native-header-search-bar.svg?style=for-the-badge)](https://www.npmjs.com/package/react-native-header-search-bar) ![Platform - Android and iOS](https://img.shields.io/badge/platform-Android%20%7C%20iOS-blue.svg?style=for-the-badge) +![Written in TypeScript](https://img.shields.io/badge/types-TypeScript-3178c6.svg?style=for-the-badge) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=for-the-badge)](https://opensource.org/licenses/MIT) [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg?style=for-the-badge)](https://github.com/prettier/prettier) +Two pre-styled, fully customizable header search bars for React Native: +`HeaderSearchBar` (titles + hamburger) and `HeaderClassicSearchBar` (search + +filter ripple). Pure JavaScript/TypeScript — no native module of its own. + - - + +
- Header Search Bar - - Header Classic Search Bar - Header Search BarHeader Classic Search Bar
- React Native Header Search Bar + React Native Header Search Bar - React Native Header Classic Search Bar + React Native Header Classic Search Bar
## Installation -Add the dependency: - -### React Native: - -```ruby -npm i react-native-header-search-bar +```sh +npm install react-native-header-search-bar +# or +yarn add react-native-header-search-bar ``` -## Peer Dependencies +### Peer dependencies -###### IMPORTANT! You need install them. +This library relies on a few native peers. Install them in your app as well: -```js -"react-native-androw": ">= 0.0.33", -"react-native-vector-icons": ">= 6.6.0", -"react-native-safe-area-context": ">= 0.6.1", -"react-native-dynamic-vector-icons": ">= 0.2.1", -"@freakycoder/react-native-helpers": ">= 0.0.21" +```sh +npm install \ + react-native-androw \ + react-native-vector-icons \ + react-native-dynamic-vector-icons \ + react-native-material-ripple \ + react-native-safe-area-context \ + @freakycoder/react-native-helpers ``` -# Component Options +> `react-native-material-ripple` is required by `HeaderClassicSearchBar`. +> `react-native-androw` (Android drop shadow) and `react-native-vector-icons` +> are declared **optional** peers — install them if you use `HeaderSearchBar`'s +> shadow / the bundled icons. + +`HeaderClassicSearchBar` reads safe-area insets, so wrap your app in a +`SafeAreaProvider` (from `react-native-safe-area-context`) once, near the root: + +```jsx +import { SafeAreaProvider } from "react-native-safe-area-context"; -- HeaderSearchBar -- HeaderClassicSearchBar +export default function App() { + return {/* ... */}; +} +``` -## Import +## Usage -```js +```jsx import { HeaderSearchBar, - HeaderClassicSearchBar + HeaderClassicSearchBar, } from "react-native-header-search-bar"; -``` -# HeaderSearchBar Usage +// Titles + hamburger + console.log(text)} />; -```jsx - console.log(text)} /> +// Classic search + filter + console.log(text)} + onPress={() => console.log("filter tapped")} +/>; ``` -# HeaderClassicSearchBar Usage +### TypeScript -```jsx - console.log(text)} /> +Types ship with the package — no `@types/...` needed. The prop interfaces are +exported: + +```tsx +import { + HeaderSearchBar, + HeaderClassicSearchBar, + type HeaderSearchBarProps, + type HeaderClassicSearchBarProps, + type SearchBoxProps, +} from "react-native-header-search-bar"; ``` -# Configuration - Props - -## Main Props - -| Property | Type | Default | Description | -| -------------------- | :-------: | :---------------: | ---------------------------------------------------------- | -| backgroundColor | color | #fff | change the background color | -| shadowColor | color | #000 | change the main shadow color | -| firstTitle | string | Find your | set the first title itself | -| secondTitle | string | Favorite Art Work | set the second title itself | -| firstTitleColor | color | #959597 | change the first title's text color | -| secondTitleColor | color | #34343b | change the second title's text color | -| firstTitleFontSize | number | 18 | change the first title font size | -| secondTitleFontSize | number | 18 | change the second title font size | -| iconComponent | component | hamburger icon | set your own component instead of hamburger icon component | -| onPressHamburgerIcon | function | null | set your own function when hamburger icon is on pressed | - -## SearchBox Props - -| Property | Type | Default | Description | -| ------------------------ | :-------: | :-----------------------: | ----------------------------------------------------- | -| iconName | string | search | set the icon name | -| iconType | string | EvilIcons | set the icon type | -| iconSize | number | 25 | set the icon size | -| iconColor | color | #000 | set the icon color | -| onChangeText | function | function | set your own logic for changing text | -| value | string | undefined | set the value for search box's text input | -| searchBoxText | string | What are you looking for? | set the search box's text | -| searchBoxTextStyle | style | default | set your own style for text input's style | -| searchBoxOnPress | function | function | set your own logic when tapping the search box itself | -| searchBoxWidth | number | 95% | change the search box's width | -| searchBoxBorderRadius | number | 8 | change the search box's border radius | -| searchBoxBackgroundColor | color | #f5f5f5 | change the search box's background color | -| iconComponent | component | Icon | set your own icon component instead of Icon | -| disableTextInput | boolean | false | disable or enable the text input itself | - -## Future Plans - -- [x] ~~LICENSE~~ -- [x] ~~Header Classic Search Bar Component~~ -- [ ] Write an article about the lib on Medium - -## Inspiration - -Header Search Bar Component is designed by: [Jawadur Rahman](https://www.instagram.com/p/B3RSEG9APnH/) +## Props + +> Both header components also accept every [`SearchBox` prop](#searchbox-props) +> and forward it to the inner search box. + +### `HeaderSearchBar` + +| Property | Type | Default | Description | +| ---------------------- | :----------: | :-----------------: | ---------------------------------------- | +| `backgroundColor` | color | `#fff` | Header background color | +| `shadowColor` | color | `#000` | Drop shadow color | +| `firstTitle` | string | `Find your` | Top (first) title text | +| `secondTitle` | string | `Favorite Art Work` | Bottom (second) title text | +| `firstTitleColor` | color | `#959597` | First title color | +| `secondTitleColor` | color | `#34343b` | Second title color | +| `firstTitleFontSize` | number | `18` | First title font size | +| `secondTitleFontSize` | number | `18` | Second title font size | +| `iconComponent` | `ReactNode` | hamburger icon | Custom component for the hamburger slot | +| `onPressHamburgerIcon` | `() => void` | — | Called when the hamburger icon is tapped | + +### `HeaderClassicSearchBar` + +| Property | Type | Default | Description | +| ----------------- | :----------: | :-----: | ------------------------------------- | +| `backgroundColor` | color | `#fff` | Header background color | +| `shadowColor` | color | `#000` | Drop shadow color | +| `onPress` | `() => void` | — | Called when the filter icon is tapped | + +### `SearchBox` props + +| Property | Type | Default | Description | +| -------------------------- | :-------------------: | :-------------------------: | ---------------------------------------------- | +| `iconName` | string | `search` | Leading icon name | +| `iconType` | string | `EvilIcons` | Leading icon set/type | +| `iconSize` | number | `25` | Leading icon size | +| `iconColor` | color | `black` | Leading icon color | +| `iconComponent` | `ReactNode` | default `Icon` | Custom leading icon component | +| `value` | string | `undefined` | Controlled value of the text input | +| `onChangeText` | `(t: string) => void` | — | Called when the search text changes | +| `onFocus` | `() => void` | — | Called when the text input gains focus | +| `searchBoxText` | string | `What are you looking for?` | Placeholder (or label if `disableTextInput`) | +| `searchBoxTextStyle` | `TextStyle` | default | Style override for the text element | +| `searchBoxOnPress` | `() => void` | — | Called when the search box is pressed | +| `searchBoxWidth` | number \| string | `95%` | Search box container width | +| `searchBoxBorderRadius` | number | `8` | Search box border radius | +| `searchBoxBackgroundColor` | color | `#f5f5f5` | Search box background color | +| `disableTextInput` | boolean | `false` | Render a static label instead of a `TextInput` | + +## New Architecture & Expo + +This is a pure JS/TS component with **no native module of its own**, so it is +compatible with the React Native **New Architecture (Fabric)** out of the box. +Compatibility on the New Architecture therefore depends on the versions of the +native peer dependencies listed above. + +It also works with **Expo** (custom dev client / prebuild) as long as the native +peer dependencies are installed and built into your app. + +## Migrating from `0.1.x` + +The public API is unchanged (same exports, same prop names, same default values), +but `0.x` shipped raw `lib/src/*.js`. From the modernized release: + +- Import from the package root: `import { HeaderSearchBar } from "react-native-header-search-bar"`. + Deep imports such as `react-native-header-search-bar/lib/src/...` no longer + resolve — use the package root instead. +- The minimum `react-native-safe-area-context` is now `>= 3.0.0` + (`HeaderClassicSearchBar` uses `useSafeAreaInsets`). +- `secondTitleFontSize` is now actually applied (it was a no-op before). + +See [`CHANGELOG.md`](CHANGELOG.md) for the full list. + +## Contributing + +Contributions are welcome! Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) and +the [changelog](CHANGELOG.md) before opening a pull request. ## Author -FreakyCoder, kurayogun@gmail.com +Kuray (FreakyCoder), kurayogun@gmail.com ## License -React Native Header Search Bar is available under the MIT license. See the LICENSE file for more info. +React Native Header Search Bar is available under the MIT license. See the +[LICENSE](LICENSE) file for more info. diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..3c25e5d --- /dev/null +++ b/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ["module:@react-native/babel-preset"], +}; diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f065b52 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,54 @@ +const tsParser = require("@typescript-eslint/parser"); +const tsPlugin = require("@typescript-eslint/eslint-plugin"); +const reactPlugin = require("eslint-plugin-react"); +const prettierConfig = require("eslint-config-prettier"); +const globals = require("globals"); + +module.exports = [ + { + ignores: [ + "lib/**", + "example/**", + "node_modules/**", + "coverage/**", + "**/*.d.ts", + ], + }, + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaFeatures: { jsx: true }, + sourceType: "module", + }, + globals: { + ...globals.es2021, + ...globals.node, + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + react: reactPlugin, + }, + settings: { + react: { version: "detect" }, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...reactPlugin.configs.recommended.rules, + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "@typescript-eslint/no-explicit-any": "warn", + }, + }, + { + files: ["src/**/__tests__/**/*.{ts,tsx}"], + languageOptions: { + globals: { + ...globals.jest, + }, + }, + }, + prettierConfig, +]; diff --git a/example/App.js b/example/App.js index 616b183..4f1545c 100644 --- a/example/App.js +++ b/example/App.js @@ -1,11 +1,7 @@ import React from "react"; import { View, StatusBar } from "react-native"; import { SafeAreaProvider } from "react-native-safe-area-context"; -// import { -// HeaderSearchBar, -// HeaderClassicSearchBar -// } from "react-native-header-search-bar"; -import HeaderClassicSearchBar from "./lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar"; +import { HeaderClassicSearchBar } from "react-native-header-search-bar"; const App = () => { return ( diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..b5495c8 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,59 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ + +// These native peer dependencies are intentionally NOT installed in the +// library's own node_modules (they are peerDependencies the consumer provides), +// so they are registered as virtual mocks. +jest.mock( + "react-native-androw", + () => { + const React = require("react"); + const { View } = require("react-native"); + const Androw = (props) => React.createElement(View, props, props.children); + return { __esModule: true, default: Androw }; + }, + { virtual: true }, +); + +jest.mock( + "react-native-dynamic-vector-icons", + () => { + const React = require("react"); + const { Text } = require("react-native"); + const Icon = ({ name }) => + React.createElement(Text, { testID: `icon-${name}` }, name); + return { __esModule: true, default: Icon }; + }, + { virtual: true }, +); + +jest.mock( + "react-native-material-ripple", + () => { + const React = require("react"); + const { TouchableOpacity } = require("react-native"); + const Ripple = (props) => + React.createElement(TouchableOpacity, props, props.children); + return { __esModule: true, default: Ripple }; + }, + { virtual: true }, +); + +jest.mock( + "@freakycoder/react-native-helpers", + () => ({ + isAndroid: false, + hasNotch: () => false, + isIPhoneXFamily: () => false, + getStatusBarHeight: () => 20, + }), + { virtual: true }, +); + +jest.mock("react-native-safe-area-context", () => { + const React = require("react"); + return { + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + SafeAreaProvider: ({ children }) => + React.createElement(React.Fragment, null, children), + }; +}); diff --git a/lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar.js b/lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar.js deleted file mode 100644 index 4f0cb31..0000000 --- a/lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { View } from "react-native"; -import styles, { - shadowStyle, - container, - searchboxContainer -} from "../HeaderClassicSearchBar/HeaderClassicSearchBar.style"; -import Androw from "react-native-androw"; -import SearchBox from "../components/SearchBox/SearchBox"; -import Icon from "react-native-dynamic-vector-icons"; -import Ripple from "react-native-material-ripple"; -import { useSafeArea } from "react-native-safe-area-context"; - -const HeaderClassicSearchBar = props => { - const { shadowColor, backgroundColor, onPress } = props; - const insets = useSafeArea(); - return ( - - - - - - - - - - - ); -}; - -HeaderClassicSearchBar.propTypes = { - shadowColor: PropTypes.string, - backgroundColor: PropTypes.string -}; - -HeaderClassicSearchBar.defaultProps = { - shadowColor: "#000", - backgroundColor: "#fff" -}; - -export default HeaderClassicSearchBar; diff --git a/lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar.style.js b/lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar.style.js deleted file mode 100644 index 94fb4eb..0000000 --- a/lib/src/HeaderClassicSearchBar/HeaderClassicSearchBar.style.js +++ /dev/null @@ -1,52 +0,0 @@ -import { Platform } from "react-native"; -import { - getStatusBarHeight, - isIPhoneXFamily, - isAndroid -} from "@freakycoder/react-native-helpers"; - -export const shadowStyle = shadowColor => { - return { - shadowColor, - shadowRadius: 9, - shadowOpacity: 0.2, - shadowOffset: { - width: 0, - height: 4 - } - }; -}; - -export const container = (backgroundColor, insets) => { - return { - width: "100%", - backgroundColor, - borderBottomEndRadius: 24, - borderBottomStartRadius: 24, - height: isAndroid ? insets.top + 75 : isIPhoneXFamily() ? 115 : 85 - }; -}; - -export const searchboxContainer = insets => ({ - flexDirection: "row", - alignItems: "center", - justifyContent: "space-around", - ...Platform.select({ - ios: { - marginTop: isIPhoneXFamily() ? getStatusBarHeight() + 23 : 38 - }, - android: { - marginTop: insets.top + 25 - } - }) -}); - -export default { - rippleContainer: { - bottom: 2, - width: 35, - height: 35, - alignItems: "center", - justifyContent: "center" - } -}; diff --git a/lib/src/HeaderSearchBar/HeaderSearchBar.js b/lib/src/HeaderSearchBar/HeaderSearchBar.js deleted file mode 100644 index 24a45a6..0000000 --- a/lib/src/HeaderSearchBar/HeaderSearchBar.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { Text, View, TouchableOpacity } from "react-native"; -import Androw from "react-native-androw"; -import Icon from "react-native-dynamic-vector-icons"; -import SearchBox from "../components/SearchBox/SearchBox"; -import styles, { - shadowStyle, - container, - firstTitleStyle, - secondTitleStyle -} from "./HeaderSearchBar.style"; - -const HeaderSearchBar = props => { - const { - firstTitle, - secondTitle, - shadowColor, - iconComponent, - backgroundColor, - firstTitleColor, - secondTitleColor, - firstTitleFontSize, - secondTitleFontSize, - onPressHamburgerIcon - } = props; - return ( - - - - - {iconComponent || ( - - )} - - - - {firstTitle} - - - {secondTitle} - - - - - - - - - ); -}; - -HeaderSearchBar.propTypes = { - firstTitle: PropTypes.string, - secondTitle: PropTypes.string, - shadowColor: PropTypes.string, - backgroundColor: PropTypes.string, - firstTitleColor: PropTypes.string, - secondTitleColor: PropTypes.string, - firstTitleFontSize: PropTypes.number, - secondTitleFontSize: PropTypes.number -}; - -HeaderSearchBar.defaultProps = { - shadowColor: "#000", - firstTitleFontSize: 18, - secondTitleFontSize: 18, - backgroundColor: "#fff", - firstTitle: "Find your", - firstTitleColor: "#959597", - secondTitleColor: "#34343b", - secondTitle: "Favorite Art Work" -}; - -export default HeaderSearchBar; diff --git a/lib/src/HeaderSearchBar/HeaderSearchBar.style.js b/lib/src/HeaderSearchBar/HeaderSearchBar.style.js deleted file mode 100644 index d136f15..0000000 --- a/lib/src/HeaderSearchBar/HeaderSearchBar.style.js +++ /dev/null @@ -1,58 +0,0 @@ -import { - hasNotch, - getStatusBarHeight -} from "@freakycoder/react-native-helpers"; - -export const shadowStyle = shadowColor => { - return { - shadowColor, - shadowRadius: 9, - shadowOpacity: 0.2, - shadowOffset: { - width: 0, - height: 4 - } - }; -}; - -export const container = backgroundColor => { - return { - height: 210, - width: "100%", - backgroundColor, - borderBottomEndRadius: 24, - borderBottomStartRadius: 24 - }; -}; - -export const firstTitleStyle = (color, fontSize) => { - return { - color, - fontSize, - fontWeight: "bold" - }; -}; - -export const secondTitleStyle = (color, fontSize) => { - return { - fontSize: 18, - marginTop: 3, - color: color, - fontWeight: "bold" - }; -}; - -export default { - containerGlue: { - marginLeft: 24, - marginTop: hasNotch() ? getStatusBarHeight() : 0 - }, - titleContainer: { - marginTop: 12, - marginLeft: 3 - }, - searchboxContainer: { - top: 8, - margin: 16 - } -}; diff --git a/lib/src/components/SearchBox/SearchBox.js b/lib/src/components/SearchBox/SearchBox.js deleted file mode 100644 index 0aa9f14..0000000 --- a/lib/src/components/SearchBox/SearchBox.js +++ /dev/null @@ -1,87 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import Icon from "react-native-dynamic-vector-icons"; -import { TextInput, TouchableOpacity, Text, View } from "react-native"; -import styles, { searchBoxContainer } from "./SearchBox.style"; - -const SearchBox = props => { - const { - value, - onFocus, - iconName, - iconType, - iconSize, - iconColor, - onChangeText, - searchBoxText, - iconComponent, - searchBoxWidth, - searchBoxOnPress, - disableTextInput, - searchBoxTextStyle, - searchBoxBorderRadius, - searchBoxBackgroundColor - } = props; - - renderTextInput = () => { - if (disableTextInput) - return {searchBoxText}; - return ( - - ); - }; - - return ( - - - {iconComponent || ( - - )} - {renderTextInput()} - - - ); -}; - -SearchBox.propTypes = { - iconName: PropTypes.string, - iconType: PropTypes.string, - iconColor: PropTypes.string, - iconSize: PropTypes.number, - searchBoxText: PropTypes.string, - disableTextInput: PropTypes.bool, - searchBoxBorderRadius: PropTypes.number, - searchBoxBackgroundColor: PropTypes.string -}; - -SearchBox.defaultProps = { - iconSize: 25, - iconName: "search", - iconColor: "black", - iconType: "EvilIcons", - disableTextInput: false, - searchBoxWidth: "95%", - searchBoxBorderRadius: 8, - searchBoxBackgroundColor: "#f5f5f5", - searchBoxText: "What are you looking for?" -}; - -export default SearchBox; diff --git a/lib/src/components/SearchBox/SearchBox.style.js b/lib/src/components/SearchBox/SearchBox.style.js deleted file mode 100644 index 360b60c..0000000 --- a/lib/src/components/SearchBox/SearchBox.style.js +++ /dev/null @@ -1,33 +0,0 @@ -import { isAndroid } from "@freakycoder/react-native-helpers"; - -export function searchBoxContainer( - searchBoxWidth, - searchBoxBorderRadius, - searchBoxBackgroundColor -) { - return { - left: 8, - padding: 6, - alignItems: "center", - width: searchBoxWidth, - justifyContent: "center", - height: isAndroid ? 30 : null, - borderRadius: searchBoxBorderRadius, - backgroundColor: searchBoxBackgroundColor - }; -} - -export default { - searchBoxGlue: { - alignItems: "center", - flexDirection: "row" - }, - searchBoxTextStyle: { - left: 3, - width: "85%", - fontSize: 13, - color: "#000", - height: isAndroid ? 50 : null, - backgroundColor: "transparent" - } -}; diff --git a/package.json b/package.json index 769d112..28cf723 100644 --- a/package.json +++ b/package.json @@ -1,37 +1,133 @@ { "name": "react-native-header-search-bar", - "version": "0.1.10", + "version": "1.0.0", "description": "Fully customizable header search bar for React Native", "keywords": [ + "react-native", + "react", "header", "search", - "bar", + "search-bar", "header-search", "header-search-bar", - "search-bar", - "FreakyCoder", - "freakycoder", - "kuray", - "Kuray", - "react", - "react-native", - "javascript", - "ui-lib", - "rn" + "ios", + "android", + "typescript", + "ui-component" ], - "homepage": "https://www.freakycoder.com", - "bugs": "https://github.com/WrathChaos/react-native-header-search-bar/issues", - "main": "./lib/src/index.js", - "repository": "git@github.com:WrathChaos/react-native-header-search-bar.git", - "author": "Kuray (FreakyCoder) ", + "homepage": "https://github.com/kuraydev/react-native-header-search-bar#readme", + "bugs": { + "url": "https://github.com/kuraydev/react-native-header-search-bar/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kuraydev/react-native-header-search-bar.git" + }, "license": "MIT", + "author": "Kuray (FreakyCoder) ", + "main": "./lib/commonjs/index.js", + "module": "./lib/module/index.js", + "react-native": "./src/index.tsx", + "source": "./src/index.tsx", + "types": "./lib/typescript/index.d.ts", + "exports": { + ".": { + "types": "./lib/typescript/index.d.ts", + "react-native": "./src/index.tsx", + "default": "./lib/commonjs/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "README.md", + "LICENSE", + "CHANGELOG.md", + "!**/__tests__", + "!**/__mocks__", + "!**/*.test.ts", + "!**/*.test.tsx" + ], + "scripts": { + "build": "bob build", + "clean": "del-cli lib", + "typecheck": "tsc --noEmit", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", + "test": "jest", + "prepare": "bob build", + "prepack": "bob build" + }, "peerDependencies": { - "react": ">= 16.x.x", - "react-native": ">= 0.55.x", - "react-native-androw": ">= 0.0.33", - "react-native-vector-icons": ">= 6.6.0", - "react-native-safe-area-context": ">= 0.6.1", - "react-native-dynamic-vector-icons": ">= 0.2.1", - "@freakycoder/react-native-helpers": ">= 0.0.21" + "react": ">=16.8.0", + "react-native": ">=0.60.0", + "react-native-androw": ">=0.0.33", + "react-native-dynamic-vector-icons": ">=0.2.0", + "react-native-material-ripple": ">=0.9.0", + "react-native-safe-area-context": ">=3.0.0", + "react-native-vector-icons": ">=6.0.0", + "@freakycoder/react-native-helpers": ">=0.0.21" + }, + "peerDependenciesMeta": { + "react-native-androw": { + "optional": true + }, + "react-native-vector-icons": { + "optional": true + } + }, + "devDependencies": { + "@babel/core": "^7.25.0", + "@babel/preset-env": "^7.25.0", + "@babel/runtime": "^7.25.0", + "@react-native/babel-preset": "0.76.5", + "@testing-library/react-native": "^12.5.0", + "@types/jest": "^29.5.12", + "@types/react": "^18.3.0", + "@types/react-test-renderer": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "babel-jest": "^29.7.0", + "del-cli": "^5.1.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react": "^7.35.0", + "globals": "^15.9.0", + "jest": "^29.7.0", + "prettier": "^3.3.0", + "react": "18.3.1", + "react-native": "0.76.5", + "react-native-builder-bob": "^0.30.0", + "react-native-safe-area-context": "^4.11.0", + "react-test-renderer": "18.3.1", + "typescript": "^5.5.0" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + "commonjs", + "module", + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "jest": { + "preset": "react-native", + "setupFiles": [ + "/jest.setup.js" + ], + "modulePathIgnorePatterns": [ + "/lib/", + "/example/" + ], + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|@react-native(-community)?|react-native-safe-area-context)/)" + ] } } diff --git a/src/HeaderClassicSearchBar/HeaderClassicSearchBar.style.ts b/src/HeaderClassicSearchBar/HeaderClassicSearchBar.style.ts new file mode 100644 index 0000000..23e61f6 --- /dev/null +++ b/src/HeaderClassicSearchBar/HeaderClassicSearchBar.style.ts @@ -0,0 +1,55 @@ +import { Platform } from "react-native"; +import { + getStatusBarHeight, + isIPhoneXFamily, + isAndroid, +} from "@freakycoder/react-native-helpers"; +import type { EdgeInsets } from "react-native-safe-area-context"; +import type { ViewStyle } from "react-native"; + +export const shadowStyle = (shadowColor: string | undefined): ViewStyle => ({ + shadowColor, + shadowRadius: 9, + shadowOpacity: 0.2, + shadowOffset: { + width: 0, + height: 4, + }, +}); + +export const container = ( + backgroundColor: string | undefined, + insets: EdgeInsets, +): ViewStyle => ({ + width: "100%", + backgroundColor, + borderBottomEndRadius: 24, + borderBottomStartRadius: 24, + height: isAndroid ? insets.top + 75 : isIPhoneXFamily() ? 115 : 85, +}); + +export const searchboxContainer = (insets: EdgeInsets): ViewStyle => ({ + flexDirection: "row", + alignItems: "center", + justifyContent: "space-around", + ...Platform.select({ + ios: { + marginTop: isIPhoneXFamily() ? getStatusBarHeight() + 23 : 38, + }, + android: { + marginTop: insets.top + 25, + }, + }), +}); + +const styles: { rippleContainer: ViewStyle } = { + rippleContainer: { + bottom: 2, + width: 35, + height: 35, + alignItems: "center", + justifyContent: "center", + }, +}; + +export default styles; diff --git a/src/HeaderClassicSearchBar/HeaderClassicSearchBar.tsx b/src/HeaderClassicSearchBar/HeaderClassicSearchBar.tsx new file mode 100644 index 0000000..5e3d3bf --- /dev/null +++ b/src/HeaderClassicSearchBar/HeaderClassicSearchBar.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { View } from "react-native"; +import Androw from "react-native-androw"; +import Icon from "react-native-dynamic-vector-icons"; +import Ripple from "react-native-material-ripple"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import SearchBox, { + type SearchBoxProps, +} from "../components/SearchBox/SearchBox"; +import styles, { + shadowStyle, + container, + searchboxContainer, +} from "./HeaderClassicSearchBar.style"; + +export interface HeaderClassicSearchBarProps extends SearchBoxProps { + /** Drop shadow color of the header. */ + shadowColor?: string; + /** Header background color. */ + backgroundColor?: string; + /** Called when the trailing filter icon is pressed. */ + onPress?: () => void; +} + +const HeaderClassicSearchBar = React.forwardRef< + React.ComponentRef, + HeaderClassicSearchBarProps +>((props, ref) => { + const { shadowColor = "#000", backgroundColor = "#fff", onPress } = props; + const insets = useSafeAreaInsets(); + + return ( + + + + + + + + + + + ); +}); + +HeaderClassicSearchBar.displayName = "HeaderClassicSearchBar"; + +export default React.memo(HeaderClassicSearchBar); diff --git a/src/HeaderSearchBar/HeaderSearchBar.style.ts b/src/HeaderSearchBar/HeaderSearchBar.style.ts new file mode 100644 index 0000000..9451663 --- /dev/null +++ b/src/HeaderSearchBar/HeaderSearchBar.style.ts @@ -0,0 +1,63 @@ +import { + hasNotch, + getStatusBarHeight, +} from "@freakycoder/react-native-helpers"; +import type { TextStyle, ViewStyle } from "react-native"; + +export const shadowStyle = (shadowColor: string | undefined): ViewStyle => ({ + shadowColor, + shadowRadius: 9, + shadowOpacity: 0.2, + shadowOffset: { + width: 0, + height: 4, + }, +}); + +export const container = (backgroundColor: string | undefined): ViewStyle => ({ + height: 210, + width: "100%", + backgroundColor, + borderBottomEndRadius: 24, + borderBottomStartRadius: 24, +}); + +export const firstTitleStyle = ( + color: string | undefined, + fontSize: number | undefined, +): TextStyle => ({ + color, + fontSize, + fontWeight: "bold", +}); + +export const secondTitleStyle = ( + color: string | undefined, + fontSize: number | undefined, +): TextStyle => ({ + color, + fontSize, + marginTop: 3, + fontWeight: "bold", +}); + +const styles: { + containerGlue: ViewStyle; + titleContainer: ViewStyle; + searchboxContainer: ViewStyle; +} = { + containerGlue: { + marginLeft: 24, + marginTop: hasNotch() ? getStatusBarHeight() : 0, + }, + titleContainer: { + marginTop: 12, + marginLeft: 3, + }, + searchboxContainer: { + top: 8, + margin: 16, + }, +}; + +export default styles; diff --git a/src/HeaderSearchBar/HeaderSearchBar.tsx b/src/HeaderSearchBar/HeaderSearchBar.tsx new file mode 100644 index 0000000..35a3856 --- /dev/null +++ b/src/HeaderSearchBar/HeaderSearchBar.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { Text, View, TouchableOpacity } from "react-native"; +import Androw from "react-native-androw"; +import Icon from "react-native-dynamic-vector-icons"; +import SearchBox, { + type SearchBoxProps, +} from "../components/SearchBox/SearchBox"; +import styles, { + shadowStyle, + container, + firstTitleStyle, + secondTitleStyle, +} from "./HeaderSearchBar.style"; + +export interface HeaderSearchBarProps extends SearchBoxProps { + /** Bold leading title (top line). */ + firstTitle?: string; + /** Bold trailing title (bottom line). */ + secondTitle?: string; + /** Drop shadow color of the header. */ + shadowColor?: string; + /** Replace the hamburger icon with a custom component. */ + iconComponent?: React.ReactNode; + /** Header background color. */ + backgroundColor?: string; + /** Color of the first title. */ + firstTitleColor?: string; + /** Color of the second title. */ + secondTitleColor?: string; + /** Font size of the first title. */ + firstTitleFontSize?: number; + /** Font size of the second title. */ + secondTitleFontSize?: number; + /** Called when the hamburger icon is pressed. */ + onPressHamburgerIcon?: () => void; +} + +const HeaderSearchBar = React.forwardRef< + React.ComponentRef, + HeaderSearchBarProps +>((props, ref) => { + const { + firstTitle = "Find your", + secondTitle = "Favorite Art Work", + shadowColor = "#000", + iconComponent, + backgroundColor = "#fff", + firstTitleColor = "#959597", + secondTitleColor = "#34343b", + firstTitleFontSize = 18, + secondTitleFontSize = 18, + onPressHamburgerIcon, + } = props; + + return ( + + + + + {iconComponent || ( + + )} + + + + {firstTitle} + + + {secondTitle} + + + + + + + + + ); +}); + +HeaderSearchBar.displayName = "HeaderSearchBar"; + +export default React.memo(HeaderSearchBar); diff --git a/src/__tests__/HeaderClassicSearchBar.test.tsx b/src/__tests__/HeaderClassicSearchBar.test.tsx new file mode 100644 index 0000000..d264c11 --- /dev/null +++ b/src/__tests__/HeaderClassicSearchBar.test.tsx @@ -0,0 +1,34 @@ +import { render, fireEvent } from "@testing-library/react-native"; +import HeaderClassicSearchBar from "../HeaderClassicSearchBar/HeaderClassicSearchBar"; + +describe("HeaderClassicSearchBar", () => { + it("renders the default search placeholder", () => { + const { getByPlaceholderText } = render(); + expect(getByPlaceholderText("What are you looking for?")).toBeTruthy(); + }); + + it("wires onChangeText through to the SearchBox", () => { + const onChangeText = jest.fn(); + const { getByPlaceholderText } = render( + , + ); + fireEvent.changeText(getByPlaceholderText("Find"), "query"); + expect(onChangeText).toHaveBeenCalledWith("query"); + }); + + it("renders the filter icon", () => { + const { getByText } = render(); + expect(getByText("filter")).toBeTruthy(); + }); + + it("does not crash without a SafeAreaProvider (uses useSafeAreaInsets)", () => { + expect(() => render()).not.toThrow(); + }); + + it("matches snapshot", () => { + expect(render().toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/HeaderSearchBar.test.tsx b/src/__tests__/HeaderSearchBar.test.tsx new file mode 100644 index 0000000..1257896 --- /dev/null +++ b/src/__tests__/HeaderSearchBar.test.tsx @@ -0,0 +1,60 @@ +import { Text } from "react-native"; +import { render, fireEvent } from "@testing-library/react-native"; +import HeaderSearchBar from "../HeaderSearchBar/HeaderSearchBar"; + +describe("HeaderSearchBar", () => { + it("renders the default first and second titles", () => { + const { getByText } = render(); + expect(getByText("Find your")).toBeTruthy(); + expect(getByText("Favorite Art Work")).toBeTruthy(); + }); + + it("honors custom title props", () => { + const { getByText } = render( + , + ); + expect(getByText("Hello")).toBeTruthy(); + expect(getByText("World")).toBeTruthy(); + }); + + it("applies secondTitleFontSize to the second title (regression: it used to be ignored)", () => { + const { getByText } = render( + , + ); + const node = getByText("Sized"); + const flat = Array.isArray(node.props.style) + ? Object.assign({}, ...node.props.style) + : node.props.style; + expect(flat.fontSize).toBe(42); + }); + + it("calls onPressHamburgerIcon when the menu button is pressed", () => { + const onPress = jest.fn(); + const { getByLabelText } = render( + , + ); + fireEvent.press(getByLabelText("Open menu")); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it("forwards search box props through to the inner SearchBox", () => { + const onChangeText = jest.fn(); + const { getByPlaceholderText } = render( + , + ); + fireEvent.changeText(getByPlaceholderText("Inner"), "abc"); + expect(onChangeText).toHaveBeenCalledWith("abc"); + }); + + it("renders a custom iconComponent (shared by the hamburger and the inner SearchBox)", () => { + const { getAllByText } = render( + my-menu} />, + ); + // iconComponent is forwarded to both the hamburger slot and SearchBox. + expect(getAllByText("my-menu").length).toBeGreaterThanOrEqual(1); + }); + + it("matches snapshot", () => { + expect(render().toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/SearchBox.test.tsx b/src/__tests__/SearchBox.test.tsx new file mode 100644 index 0000000..66993cc --- /dev/null +++ b/src/__tests__/SearchBox.test.tsx @@ -0,0 +1,61 @@ +import { Text } from "react-native"; +import { render, fireEvent } from "@testing-library/react-native"; +import SearchBox from "../components/SearchBox/SearchBox"; + +describe("SearchBox", () => { + it("renders the default placeholder text", () => { + const { getByPlaceholderText } = render(); + expect(getByPlaceholderText("What are you looking for?")).toBeTruthy(); + }); + + it("renders a custom placeholder via searchBoxText", () => { + const { getByPlaceholderText } = render( + , + ); + expect(getByPlaceholderText("Search products")).toBeTruthy(); + }); + + it("wires onChangeText to the TextInput", () => { + const onChangeText = jest.fn(); + const { getByPlaceholderText } = render( + , + ); + fireEvent.changeText(getByPlaceholderText("Search"), "hello"); + expect(onChangeText).toHaveBeenCalledWith("hello"); + }); + + it("renders a static label instead of a TextInput when disableTextInput is true", () => { + const { queryByPlaceholderText, getByText } = render( + , + ); + expect(queryByPlaceholderText("Static label")).toBeNull(); + expect(getByText("Static label")).toBeTruthy(); + }); + + it("renders a custom iconComponent in place of the default icon", () => { + const { getByText, queryByText } = render( + custom-icon} />, + ); + expect(getByText("custom-icon")).toBeTruthy(); + // default EvilIcons "search" icon should not be rendered + expect(queryByText("search")).toBeNull(); + }); + + it("fires searchBoxOnPress when the container is pressed", () => { + const searchBoxOnPress = jest.fn(); + const { getByText } = render( + , + ); + fireEvent.press(getByText("press-target")); + expect(searchBoxOnPress).toHaveBeenCalledTimes(1); + }); + + it("matches snapshot", () => { + const tree = render().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/__tests__/__snapshots__/HeaderClassicSearchBar.test.tsx.snap b/src/__tests__/__snapshots__/HeaderClassicSearchBar.test.tsx.snap new file mode 100644 index 0000000..19ae42d --- /dev/null +++ b/src/__tests__/__snapshots__/HeaderClassicSearchBar.test.tsx.snap @@ -0,0 +1,158 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderClassicSearchBar matches snapshot 1`] = ` + + + + + + + search + + + + + + + filter + + + + + +`; diff --git a/src/__tests__/__snapshots__/HeaderSearchBar.test.tsx.snap b/src/__tests__/__snapshots__/HeaderSearchBar.test.tsx.snap new file mode 100644 index 0000000..612d906 --- /dev/null +++ b/src/__tests__/__snapshots__/HeaderSearchBar.test.tsx.snap @@ -0,0 +1,194 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`HeaderSearchBar matches snapshot 1`] = ` + + + + + + menu + + + + + Find your + + + Favorite Art Work + + + + + + + + search + + + + + + + +`; diff --git a/src/__tests__/__snapshots__/SearchBox.test.tsx.snap b/src/__tests__/__snapshots__/SearchBox.test.tsx.snap new file mode 100644 index 0000000..56476ad --- /dev/null +++ b/src/__tests__/__snapshots__/SearchBox.test.tsx.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchBox matches snapshot 1`] = ` + + + + search + + + + +`; diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 0000000..329a6d7 --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,18 @@ +import DefaultExport, { + HeaderSearchBar, + HeaderClassicSearchBar, +} from "../index"; + +describe("public API surface", () => { + it("exposes the named component exports", () => { + expect(HeaderSearchBar).toBeDefined(); + expect(HeaderClassicSearchBar).toBeDefined(); + }); + + it("preserves the default object export shape", () => { + expect(DefaultExport).toEqual({ + HeaderSearchBar, + HeaderClassicSearchBar, + }); + }); +}); diff --git a/src/components/SearchBox/SearchBox.style.ts b/src/components/SearchBox/SearchBox.style.ts new file mode 100644 index 0000000..1999e70 --- /dev/null +++ b/src/components/SearchBox/SearchBox.style.ts @@ -0,0 +1,41 @@ +import { isAndroid } from "@freakycoder/react-native-helpers"; +import type { + DimensionValue, + StyleProp, + TextStyle, + ViewStyle, +} from "react-native"; + +export function searchBoxContainer( + searchBoxWidth: DimensionValue | undefined, + searchBoxBorderRadius: number | undefined, + searchBoxBackgroundColor: string | undefined, +): StyleProp { + return { + left: 8, + padding: 6, + alignItems: "center", + width: searchBoxWidth, + justifyContent: "center", + height: isAndroid ? 30 : undefined, + borderRadius: searchBoxBorderRadius, + backgroundColor: searchBoxBackgroundColor, + }; +} + +const styles: { searchBoxGlue: ViewStyle; searchBoxTextStyle: TextStyle } = { + searchBoxGlue: { + alignItems: "center", + flexDirection: "row", + }, + searchBoxTextStyle: { + left: 3, + width: "85%", + fontSize: 13, + color: "#000", + height: isAndroid ? 50 : undefined, + backgroundColor: "transparent", + }, +}; + +export default styles; diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx new file mode 100644 index 0000000..686a9ce --- /dev/null +++ b/src/components/SearchBox/SearchBox.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import Icon from "react-native-dynamic-vector-icons"; +import { + Text, + View, + TextInput, + TouchableOpacity, + type DimensionValue, + type StyleProp, + type TextStyle, +} from "react-native"; +import styles, { searchBoxContainer } from "./SearchBox.style"; + +export interface SearchBoxProps { + /** Controlled value for the inner TextInput. */ + value?: string; + /** Called when the inner TextInput receives focus. */ + onFocus?: () => void; + /** Icon name passed to the leading icon. */ + iconName?: string; + /** Icon set/type passed to the leading icon. */ + iconType?: string; + /** Leading icon size. */ + iconSize?: number; + /** Leading icon color. */ + iconColor?: string; + /** Called whenever the search text changes. */ + onChangeText?: (text: string) => void; + /** Placeholder text (or static label when `disableTextInput` is true). */ + searchBoxText?: string; + /** Replace the leading icon with a custom component. */ + iconComponent?: React.ReactNode; + /** Width of the search box container. */ + searchBoxWidth?: DimensionValue; + /** Called when the search box container itself is pressed. */ + searchBoxOnPress?: () => void; + /** Render a static label instead of an editable TextInput. */ + disableTextInput?: boolean; + /** Style override for the inner text element. */ + searchBoxTextStyle?: StyleProp; + /** Border radius of the search box container. */ + searchBoxBorderRadius?: number; + /** Background color of the search box container. */ + searchBoxBackgroundColor?: string; +} + +const SearchBox = React.forwardRef< + React.ComponentRef, + SearchBoxProps +>( + ( + { + value, + onFocus, + iconName = "search", + iconType = "EvilIcons", + iconSize = 25, + iconColor = "black", + onChangeText, + searchBoxText = "What are you looking for?", + iconComponent, + searchBoxWidth = "95%", + searchBoxOnPress, + disableTextInput = false, + searchBoxTextStyle, + searchBoxBorderRadius = 8, + searchBoxBackgroundColor = "#f5f5f5", + }, + ref, + ) => { + const renderTextInput = () => { + if (disableTextInput) { + return {searchBoxText}; + } + return ( + + ); + }; + + return ( + + + {iconComponent || ( + + )} + {renderTextInput()} + + + ); + }, +); + +SearchBox.displayName = "SearchBox"; + +export default React.memo(SearchBox); diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..e9f6cc2 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,49 @@ +/** + * Ambient module declarations for native peer dependencies that do not ship + * their own TypeScript types. These keep `tsc --noEmit` honest without + * pretending to fully type third-party libraries. + */ +declare module "react-native-androw" { + import type { ComponentType } from "react"; + import type { ViewProps } from "react-native"; + + const Androw: ComponentType; + export default Androw; +} + +declare module "react-native-dynamic-vector-icons" { + import type { ComponentType } from "react"; + import type { TextProps } from "react-native"; + + export interface DynamicVectorIconProps extends TextProps { + name: string; + type?: string; + size?: number; + color?: string; + } + + const Icon: ComponentType; + export default Icon; +} + +declare module "react-native-material-ripple" { + import type { ComponentType, ReactNode } from "react"; + import type { ViewProps } from "react-native"; + + export interface RippleProps extends ViewProps { + rippleColor?: string; + rippleContainerBorderRadius?: number; + onPress?: () => void; + children?: ReactNode; + } + + const Ripple: ComponentType; + export default Ripple; +} + +declare module "@freakycoder/react-native-helpers" { + export const isAndroid: boolean; + export function hasNotch(): boolean; + export function isIPhoneXFamily(): boolean; + export function getStatusBarHeight(safe?: boolean): number; +} diff --git a/lib/src/index.js b/src/index.tsx similarity index 51% rename from lib/src/index.js rename to src/index.tsx index d9c8d1c..4bb2795 100644 --- a/lib/src/index.js +++ b/src/index.tsx @@ -2,4 +2,8 @@ import HeaderSearchBar from "./HeaderSearchBar/HeaderSearchBar"; import HeaderClassicSearchBar from "./HeaderClassicSearchBar/HeaderClassicSearchBar"; export { HeaderSearchBar, HeaderClassicSearchBar }; +export type { HeaderSearchBarProps } from "./HeaderSearchBar/HeaderSearchBar"; +export type { HeaderClassicSearchBarProps } from "./HeaderClassicSearchBar/HeaderClassicSearchBar"; +export type { SearchBoxProps } from "./components/SearchBox/SearchBox"; + export default { HeaderSearchBar, HeaderClassicSearchBar }; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..6a95b63 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "node_modules", + "lib", + "example", + "**/__tests__/**", + "**/*.test.tsx", + "**/*.test.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e538f90 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "lib": ["esnext"], + "jsx": "react-jsx", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "declaration": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "lib", "example"] +}