From 3127f1436387719c4027222ef0f8ba4b152086bc Mon Sep 17 00:00:00 2001 From: Matt Warman Date: Wed, 2 Oct 2024 12:39:21 -0400 Subject: [PATCH] 83 Show-hide user search bar on scroll (#85) * initial scroll context provider * hide/show search toolbar on scroll * tests * tests * fixes --- src/App.tsx | 9 +- .../hooks/__tests__/useScrollContext.test.ts | 85 +++++++++++++++++++ src/common/hooks/useScrollContext.ts | 15 ++++ src/common/providers/ScrollProvider.tsx | 69 +++++++++++++++ .../__tests__/ProgressProvider.test.tsx | 2 +- .../__tests__/ScrollProvider.test.tsx | 20 +++++ .../components/UserList/UserListPage.tsx | 9 +- src/test/wrappers/WithAllProviders.tsx | 5 +- 8 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 src/common/hooks/__tests__/useScrollContext.test.ts create mode 100644 src/common/hooks/useScrollContext.ts create mode 100644 src/common/providers/ScrollProvider.tsx create mode 100644 src/common/providers/__tests__/ScrollProvider.test.tsx diff --git a/src/App.tsx b/src/App.tsx index ff2ed27..e460113 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { queryClient } from 'common/utils/query-client'; import AuthProvider from 'common/providers/AuthProvider'; import AxiosProvider from 'common/providers/AxiosProvider'; import ToastProvider from 'common/providers/ToastProvider'; +import ScrollProvider from 'common/providers/ScrollProvider'; import Toasts from 'common/components/Toast/Toasts'; import AppRouter from 'common/components/Router/AppRouter'; @@ -26,9 +27,11 @@ const App = (): JSX.Element => ( - - - + + + + + diff --git a/src/common/hooks/__tests__/useScrollContext.test.ts b/src/common/hooks/__tests__/useScrollContext.test.ts new file mode 100644 index 0000000..3fa740b --- /dev/null +++ b/src/common/hooks/__tests__/useScrollContext.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook as renderHookWithoutWrapper } from '@testing-library/react'; + +import { act, renderHook, waitFor } from 'test/test-utils'; + +import { useScrollContext } from '../useScrollContext'; + +describe('useScrollContext', () => { + it('should return context', async () => { + // ARRANGE + const { result } = renderHook(() => useScrollContext()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.handleIonScroll).toBeDefined(); + expect(result.current.scrollDirection).toBeUndefined(); + }); + + it('should return default context', async () => { + // ARRANGE + const { result } = renderHookWithoutWrapper(() => useScrollContext()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + act(() => + result.current.handleIonScroll({ + // @ts-expect-error required detail attributes only + detail: { + startY: 0, + currentY: 100, + }, + }), + ); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.handleIonScroll).toBeDefined(); + expect(result.current.scrollDirection).toBeUndefined(); + }); + + it('should set scroll direction down', async () => { + // ARRANGE + const { result } = renderHook(() => useScrollContext()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + act(() => + result.current.handleIonScroll({ + // @ts-expect-error required detail attributes only + detail: { + startY: 0, + currentY: 100, + }, + }), + ); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.handleIonScroll).toBeDefined(); + expect(result.current.scrollDirection).toBe('down'); + }); + + it('should set scroll direction up', async () => { + // ARRANGE + const { result } = renderHook(() => useScrollContext()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + act(() => + result.current.handleIonScroll({ + // @ts-expect-error required detail attributes only + detail: { + startY: 100, + currentY: 0, + }, + }), + ); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.handleIonScroll).toBeDefined(); + expect(result.current.scrollDirection).toBe('up'); + }); +}); diff --git a/src/common/hooks/useScrollContext.ts b/src/common/hooks/useScrollContext.ts new file mode 100644 index 0000000..d0e9fe9 --- /dev/null +++ b/src/common/hooks/useScrollContext.ts @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import { ScrollContext, ScrollContextValue } from 'common/providers/ScrollProvider'; + +/** + * The `useScrollContext` hook returns the current `ScrollContext` value. + * @returns {ScrollContextValue} The current `ScrollContext` value, a + * `ScrollContextValue` object. + * @see {@link ScrollContextValue} + */ +export const useScrollContext = (): ScrollContextValue => { + const context = useContext(ScrollContext); + + return context; +}; diff --git a/src/common/providers/ScrollProvider.tsx b/src/common/providers/ScrollProvider.tsx new file mode 100644 index 0000000..b773499 --- /dev/null +++ b/src/common/providers/ScrollProvider.tsx @@ -0,0 +1,69 @@ +import { createContext, PropsWithChildren, useState } from 'react'; +import { ScrollCustomEvent } from '@ionic/core'; + +/** + * The `ScrollDirection` type describes the direction of the scroll. + */ +export type ScrollDirection = 'down' | 'up'; + +/** + * The `value` provided by the `ScrollContext`. + */ +export type ScrollContextValue = { + scrollDirection?: ScrollDirection; + handleIonScroll: (e: ScrollCustomEvent) => void; +}; + +/** + * Default value for the `ScrollContext`. + */ +const DEFAULT_VALUE: ScrollContextValue = { + handleIonScroll: () => {}, +}; + +/** + * The `ScrollContext` instance. + */ +export const ScrollContext = createContext(DEFAULT_VALUE); + +/** + * The `ScrollProvider` component creates and provides access to the `ScrollContext` + * value. Provides information regarding scroll events. + * + * Useful when integrated with scroll events emitted by components such as `IonContent`. + * + * *Example:* + * ``` + * const { handleIonScroll, scrollDirection } = useScrollContext(); + * ... + * + * + * I am hidden when scrolling down! + * + * + * ``` + * @param {PropsWithChildren} props - Component properties. + * @returns {JSX.Element} JSX + */ +const ScrollProvider = ({ children }: PropsWithChildren): JSX.Element => { + const [scrollDirection, setScrollDirection] = useState(undefined); + + const handleIonScroll = (event: ScrollCustomEvent) => { + const { currentY, startY } = event.detail; + const scrollY = currentY - startY; + if (scrollY > 0) { + setScrollDirection('down'); + } else if (scrollY < 0) { + setScrollDirection('up'); + } + }; + + const contextValue: ScrollContextValue = { + scrollDirection: scrollDirection, + handleIonScroll: handleIonScroll, + }; + + return {children}; +}; + +export default ScrollProvider; diff --git a/src/common/providers/__tests__/ProgressProvider.test.tsx b/src/common/providers/__tests__/ProgressProvider.test.tsx index 494301e..ee4fc99 100644 --- a/src/common/providers/__tests__/ProgressProvider.test.tsx +++ b/src/common/providers/__tests__/ProgressProvider.test.tsx @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from 'test/test-utils'; import ProgressProvider from '../ProgressProvider'; -import userEvent from '@testing-library/user-event'; describe('ProgressProvider', () => { it('should render successfully', async () => { diff --git a/src/common/providers/__tests__/ScrollProvider.test.tsx b/src/common/providers/__tests__/ScrollProvider.test.tsx new file mode 100644 index 0000000..4f8d3af --- /dev/null +++ b/src/common/providers/__tests__/ScrollProvider.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import ScrollProvider from '../ScrollProvider'; + +describe('ScrollProvider', () => { + it('should render successfully', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('scroll-provider-children'); + + // ASSERT + expect(screen.getByTestId('scroll-provider-children')).toBeDefined(); + }); +}); diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx index edbfc0a..f3cab18 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -11,10 +11,12 @@ import { } from '@ionic/react'; import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import classNames from 'classnames'; import './UserListPage.scss'; import { PropsWithTestId } from 'common/components/types'; import { QueryKey } from 'common/utils/constants'; +import { useScrollContext } from 'common/hooks/useScrollContext'; import Header from 'common/components/Header/Header'; import Searchbar from 'common/components/Searchbar/Searchbar'; import Container from 'common/components/Content/Container'; @@ -34,6 +36,7 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS const [isOpenModal, setIsOpenModal] = useState(false); const [search, setSearch] = useState(''); const queryClient = useQueryClient(); + const { handleIonScroll, scrollDirection } = useScrollContext(); /** * Handle pull to refresh events. @@ -60,12 +63,14 @@ export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JS toolbars={[ { children: , - className: 'ls-user-list-page__searchbar', + className: classNames('ls-user-list-page__searchbar', { + 'ion-hide': scrollDirection === 'down', + }), }, ]} /> - + diff --git a/src/test/wrappers/WithAllProviders.tsx b/src/test/wrappers/WithAllProviders.tsx index 25af128..85124a2 100644 --- a/src/test/wrappers/WithAllProviders.tsx +++ b/src/test/wrappers/WithAllProviders.tsx @@ -7,6 +7,7 @@ import ConfigContextProvider from 'common/providers/ConfigProvider'; import ToastProvider from 'common/providers/ToastProvider'; import AxiosProvider from 'common/providers/AxiosProvider'; import AuthProvider from 'common/providers/AuthProvider'; +import ScrollProvider from 'common/providers/ScrollProvider'; const WithAllProviders = ({ children }: PropsWithChildren): JSX.Element => { return ( @@ -15,7 +16,9 @@ const WithAllProviders = ({ children }: PropsWithChildren): JSX.Element => { - {children} + + {children} +