npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@spa-tools/interaction-hooks

v1.0.3

Published

Small package of specialized hooks meant to solve interaction scenarios in modern React applications.

Downloads

16

Readme

Interaction Hooks

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.