@spa-tools/interaction-hooks

1.0.3 • Public • Published

@spa-tools

@spa-tools

Interaction Hooks

NPM version Test and Build Code Coverage

The @spa-tools/interaction-hooks package is a small library of specialized hooks aimed at solving interaction scenarios in modern-day React applications.

Feature highlights include:

  • Time-saving functionality
  • Production-tested
  • TypeScript First
  • Zero Dependencies
  • Tree-shakable

Hooks in this package:

  • useCallOnce
  • useDetectKeyDown
  • useInfiniteScroll
  • useIsHovered
  • useIsOverflowed
  • useQueryState

It's highly advised to first checkout the @spa-tools documentation site for a complete list of features, usage scenarios, guidance, and reference.

Installation

npm install @spa-tools/interaction-hooks

Hooks / Usage

useCallOnce

Hook that ensures code runs once and only once no matter how many times your component re-renders.

import { useEffect, useState } from 'react';
import { useCallOnce } from '@spa-tools/interaction-hooks';

// this is the function we want to call only once
function logOnce(message: string) {
  console.log('This will only log once:', message);
}

export function UseCallOnceHookExample() {
  const [time, setTime] = useState(new Date());

  // here we setup some code to force a re-render every second
  useEffect(() => {
    const interval = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(interval);
  }, []);

  // here we use the hook to call the function
  // so that it's guaranteed to only execute once
  useCallOnce(logOnce, 'Hello, world!');

  return <p>Current time: {time.toLocaleTimeString()}</p>;
}

useDetectKeyDown

Hook that watches for requeste keys/modifiers to be pressed.

import { useEffect, useRef } from 'react';
import { useDetectKeyDown } from '@spa-tools/interaction-hooks';

function UseDetectKeyDownExample() {
  // here we wire up a ref for the submit button that we will auto-click
  const submitButtonRef = useRef<HTMLButtonElement>(null);
  // here we ask the hook to set onKeyDownInput1KeyDetected to true
  // when the Shift-Ctrl-P keys are pressed
  const [onKeyDownInput1, pShiftControlKeysDetected] = useDetectKeyDown('P', ['Shift', 'Control']);
  // here we ask the hook to auto-click the submit button when
  // the Enter key is pressed
  const [onKeyDownInput2] = useDetectKeyDown('Enter', submitButtonRef);

  useEffect(() => {
    // we simply alert when the Shift-Ctrl-P keys are detected
    if (pShiftControlKeysDetected) {
      alert('Shift-Ctrl-P detected!');
    }
  }, [pShiftControlKeysDetected]);

  return (
    <div>
      <div>
        <input onKeyDown={onKeyDownInput1} placeholder='Focus here and press Shift-Ctrl-P' />
      </div>
      <div>
        <input onKeyDown={onKeyDownInput2} placeholder='Type something here and press Enter' />
        <button
          onClick={() => {
            alert('Submit button clicked!');
          }}
          ref={submitButtonRef}
        >
          Submit
        </button>
      </div>
    </div>
  );
}

useInfiniteScroll

Hook that enables infinite scroll behavior.

import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallEndpoint } from '@spa-tools/api-client';
import { useInfiniteScroll } from '@spa-tools/interaction-hooks';

// here we create a custom hook to fetch recipes from a server
// using the useCallEndpoint hook from the @spa-tools/api-client package
function useGetRecipes() {
  return useCallEndpoint(
    'https://dummyjson.com/recipes',
    {
      requestOptions: { recordLimit: 10 },
      serverModelOptions: { jsonDataDotPath: 'recipes' },
    },
    // we pass true to enable appending of new records
    true
  );
}

function UseInfiniteScrollExample() {
  // this will hold the ref to our scroll target, which is just a div that we
  // place below our list of recipes to act as a sentinel for scroll intersection
  const scrollTargetRef = useRef<HTMLDivElement>(null);
  const [total, setTotal] = useState(0);
  const [count, setCount] = useState(0);
  const [getRecipes, recipesResult, isRecipesCallPending] = useGetRecipes();

  // anytime our scroll target is intersected for vertical scroll, the hook
  // will return true, which is how we know to fetch the next page of recipes
  const isScrolling = useInfiniteScroll(scrollTargetRef);

  const handleGetRecipes = useCallback(() => {
    const recordCount = recipesResult?.data?.length ?? -1;
    const totalCount = recipesResult?.total ?? 0;

    setCount(recordCount);
    setTotal(totalCount);

    if (!isRecipesCallPending && recordCount < totalCount) {
      getRecipes();
    }
  }, [getRecipes, isRecipesCallPending, recipesResult?.data?.length, recipesResult?.total]);

  useEffect(() => {
    if (isScrolling) {
      // if the infinite scroll says we're scrolling,
      // then we retrieve the next page of recipes
      handleGetRecipes();
    }
  }, [handleGetRecipes, isScrolling]);

  return (
    <div>
      <h4>
        {count && total ? `${count === total ? `All ${count}` : `${count} of ${total}`} recipes retrieved!` : ''}
        {count && total && count < total ? ' (scroll recipe list to load more)' : ''}
      </h4>
      <div style={{ height: '300px', overflowY: 'auto', padding: '1rem', width: '100%' }}>
        <ul>{recipesResult?.data?.map((recipe) => <li key={recipe.id}>{recipe.name}</li>)}</ul>
        {isRecipesCallPending && <div>Loading recipes...</div>}
        <div ref={scrollTargetRef} />
      </div>
    </div>
  );
}

useIsHovered

Hook that watches elements for hover state.

import { useRef } from 'react';
import { useIsHovered } from '@spa-tools/interaction-hooks';

function UseIsHoveredExample() {
  // here we simply setup refs to all elements we want
  // to track hover state for
  const buttonRef = useRef<HTMLButtonElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const spanRef = useRef<HTMLSpanElement>(null);
  // yes! you can also track hover state for an array of elements
  const arrayRef = useRef<HTMLButtonElement[]>([]);

  // then we use different hook instances to track the
  // hover state for the above element refs
  const isButtonHovered = useIsHovered(buttonRef);
  const isInputHovered = useIsHovered(inputRef);
  const isSpanHovered = useIsHovered(spanRef);
  const isButtonArrayHovered = useIsHovered(arrayRef);

  const getHoverStateText = () => {
    if (isButtonHovered) {
      return 'Very first button is hovered!';
    }
    if (isInputHovered) {
      return 'Input is hovered!';
    }
    if (isSpanHovered) {
      return 'Text is hovered!';
    }
    if (isButtonArrayHovered) {
      return 'One of the six buttons from cluster is hovered!';
    }

    return 'Nothing is hovered!';
  };

  return (
    <div>
      <div>
        <button ref={buttonRef}>Hover me!</button>
        <input ref={inputRef} value='No, hover over me!' />
        <div>
          Don&apos;t listen to them, hover over{' '}
          <span ref={spanRef} style={{ color: 'purple', cursor: 'pointer', fontWeight: 800 }}>
            this text
          </span>{' '}
          instead!
        </div>
      </div>
      <div>
        <button ref={(el: HTMLButtonElement) => (arrayRef.current[0] = el)}/>Hover</button>
        <button ref={(el: HTMLButtonElement) => (arrayRef.current[1] = el)}/>Over</button>
        <button ref={(el: HTMLButtonElement) => (arrayRef.current[2] = el)}/>Any</button>
        <button ref={(el: HTMLButtonElement) => (arrayRef.current[3] = el)}/>One</button>
        <button ref={(el: HTMLButtonElement) => (arrayRef.current[4] = el)}/>Of</button>
        <button ref={(el: HTMLButtonElement) => (arrayRef.current[5] = el)}/>Us</button>
      </div>
      <h2>{getHoverStateText()}</h2>
    </div>
  );
}

useIsOverflowed

Hook that watches for when an element's content is overflowed.

import { useRef } from 'react';
import { useIsOverflowed } from '@spa-tools/interaction-hooks';

function UseIsOverflowedExample() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const isVerticallyOverflowed = useIsOverflowed(sectionRef);

  return (
    <div ref={sectionRef} style={{ height: '100vh', overflowY: 'auto' }}>
      <div style={{ height: '200vh' }}>
        <h1>Scroll me</h1>
        <h2>{isVerticallyOverflowed ? 'Overflowed vertically' : 'Not overflowed vertically'}</h2>
      </div>
    </div>
  );
}

useQueryState

Hook that manages interaction with the URLs querystring.

import { useQueryState } from '@spa-tools/interaction-hooks';

// here we edefine the shape for our query state
interface SortColumnInfo {
  sortColumn: string;
  sortDirection: 'ASC' | 'DESC';
}

function UseQueryStateExample() {
  // we pass true to the useQueryState hook to enable the cache (i.e. localStorage)
  // feature so that the query state for this view is remembered across page reloads
  const { queryState, setQueryState } = useQueryState<SortColumnInfo>(true);

  return (
    <div>
      <div>
        <button
          onClick={() => {
            // here we set the query state to sort by age in descending order
            setQueryState({ sortColumn: 'age', sortDirection: 'DESC' });
          }}
        >
          Sort by Age (DESC)
        </button>
        <button
          onClick={() => {
            // here we set the query state to sort by name in ascending order
            setQueryState({ sortColumn: 'name', sortDirection: 'ASC' });
          }}
        >
          Sort by Name (ASC)
        </button>
        <button
          onClick={() => {
            // here we hard reload the page with querystring removed
            // to test the cache feature
            window.location.href = window.location.href.split('?')[0];
          }}
        >
          Hard reload to test cache
        </button>
        <button
          onClick={() => {
            // here we clear the query state
            setQueryState(null);
          }}
        >
          Clear sort settings
        </button>
      </div>
      <div>
        {queryState === null ? (
          `Click one of the "Sort by" buttons and watch the browser's URL and also how this text changes!`
        ) : (
          <span>
            Sort column <strong>{queryState.sortColumn}</strong> in <em>{queryState.sortDirection}</em> direction!
          </span>
        )}
      </div>
    </div>
  );
}

Docsite

View the @spa-tools documentation site for complete reference.

Contributing

If you're interested in contributing to @spa-tools, please first create an issue on the @spa-tools monorepo in GitHub or comment on an already open issue. From there we can discuss the feature or bugfix you're interested in and how best to approach it.

Unit Test Coverage

All packages in @spa-tools require 100% unit test coverage. This is a condition for all PRs to be merged whether you're adding a new feature or fixing a bug.

License

All packages in @spa-tools are licensed under the MIT license. Copyright © 2024, Ryan Howard (rollercodester). All rights reserved.

Package Sidebar

Install

npm i @spa-tools/interaction-hooks

Weekly Downloads

2

Version

1.0.3

License

MIT

Unpacked Size

33.2 kB

Total Files

47

Last publish

Collaborators

  • rollercodester