React Explore Kit

React Explore Kit, a comprehensive library designed to add step-by-step introductions and hints to React applications. This tool enhances user onboarding by offering guided tours, tooltips, and highlighted areas to help users navigate complex interfaces.

React Explore Kit

https://res.cloudinary.com/supertramp69420/image/upload/v1716776904/Screenshot_2024-05-27_at_7.58.08_AM_b7o9kj.png

Case Study: Building the React Explore Kit

Introduction

In this blog post, I’ll take you through the journey of developing the React Explore Kit, a comprehensive library designed to add step-by-step introductions and hints to React applications. This tool enhances user onboarding by offering guided tours, tooltips, and highlighted areas to help users navigate complex interfaces.

Project Overview

React Explore Kit is a collection of three main packages: Mask, Popover, and Tour. These components can be used individually or in combination to create interactive and user-friendly guided tours within any React application. My goal was to provide a flexible, easy-to-integrate solution that developers could customize to fit their needs.

Key Features

  1. Mask: Dims the rest of the page to highlight specific elements.
  2. Popover: Displays contextual information near highlighted elements.
  3. Tour: Combines Mask and Popover to guide users through a sequence of steps.

Tools and Technologies Used

  • React: Core library for building user interfaces.
  • TypeScript: For type safety and better code quality.
  • TSDX: A zero-config CLI for TypeScript package development.
  • Prettier: Code formatter to maintain consistent code style.
  • ESLint: Linter to identify and fix problematic patterns in the code.
  • Husky: Git hooks to enforce code quality before commits.
  • @babel: JavaScript compiler to use the latest JavaScript features.

Development Process

Initial Setup

The project began with setting up the basic structure using TSDX to streamline the development of TypeScript libraries. The initial setup included configuring Babel, ESLint, and Prettier to ensure code quality and consistency.

Babel Configuration:

{ "presets": ["@babel/env", "@babel/react"], "plugins": [ "@babel/plugin-proposal-class-properties", "@babel/plugin-syntax-dynamic-import" ] }

ESLint Configuration:

module.exports = { extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended-type-checked", ], parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], root: true, parserOptions: { tsconfigRootDir: __dirname, }, rules: { "@typescript-eslint/no-explicit-any": "off", }, };

Building Core Components

Mask Component

The Mask component was designed to dim the rest of the page, highlighting specific elements. Here’s a look at how I built it:

import React from "react"; import { safe, getWindow, getPadding } from "@react-explore-kit/utils"; // Utility functions for safety and padding calculations const Mask: React.FC<MaskProps> = ({ padding = 10, // Default padding around the highlighted area onClick, styles = {}, // Custom styles passed as props sizes, // Sizes of the element to be highlighted className, }) => { const maskID = uniqueId("mask__"); // Unique ID for the mask const clipID = uniqueId("clip__"); // Unique ID for the clip path const getStyles = stylesMatcher(styles); // Function to get the combined default and custom styles const [px, py] = getPadding(padding); // Calculate padding values const { w, h } = getWindow(); // Get window dimensions const width = safe(sizes?.width + px * 2); // Calculate width with padding const height = safe(sizes?.height + py * 2); // Calculate height with padding const top = safe(sizes?.top - py); // Calculate top position const left = safe(sizes?.left - px); // Calculate left position return ( <div style={getStyles("maskWrapper", {})} onClick={onClick} className={className} > <svg width={w} height={h} xmlns="<http://www.w3.org/2000/svg>" style={getStyles("svgWrapper", {})} > <defs> <mask id={maskID}> <rect x={0} y={0} width={w} height={h} fill="white" /> <rect style={getStyles("maskArea", { x: left, y: top, width, height })} /> </mask> </defs> <rect style={getStyles("maskRect", { windowWidth: w, windowHeight: h, maskID, })} /> <rect style={getStyles("highlightedArea", { x: left, y: top, width, height, })} /> </svg> </div> ); }; export default Mask;

Explanation:

  • Imports: The necessary utilities and React are imported.
  • Props: Default values for padding and styles are provided. Sizes are passed as props to determine the dimensions of the highlighted area.
  • Unique IDs: Unique IDs are generated for the mask and clip path to avoid conflicts.
  • Styles: A function stylesMatcher is used to combine default and custom styles.
  • Dimensions: The dimensions of the mask and highlighted area are calculated based on the window size and provided sizes.
  • SVG Elements: SVG elements are used to create the mask and highlighted area, ensuring they are positioned correctly and styled appropriately.

Customization and Flexibility

One of the primary goals was to ensure high customization. I achieved this by allowing users to override default components and styles. For example, the Mask component accepts custom styles and padding values to adapt to different use cases.

Customizing the Tour Provider:

import React from "react"; import { TourProvider } from "@react-explore-kit/tour"; import { steps } from "./steps.js"; import Main from "./Main.js"; export default function App() { return ( <TourProvider steps={steps} onClickClose={({ setCurrentStep, currentStep, steps, setIsOpen }) => { if (steps) { if (currentStep === steps.length - 1) { setIsOpen(false); // Close the tour if it's the last step } else { setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); // Move to the next step } } }} > <Main /> </TourProvider> ); }

Explanation:

  • TourProvider: The TourProvider wraps the application to provide context for the tour.
  • Steps: The steps of the tour are passed as props.
  • onClickClose: A custom function is provided to handle the close button click, either moving to the next step or closing the tour.

Concepts and Techniques Learned:

  1. Context API: Used to manage and share state across the application, providing a global state for the tour.
  2. Custom Hooks: Created custom hooks like useTour to encapsulate and reuse logic related to the tour state.
  3. Component Customization: Allowed users to pass custom components and styles to tailor the tour to their specific needs.

Example of Popover Component Customization:

import React, { useRef } from "react"; import { useRect, isHoriz, bestPositionOf, isOutsideX, isOutsideY, } from "@react-explore-kit/utils"; import { StylesObj, stylesMatcher } from "./styles"; const Popover: React.FC<PopoverProps> = ({ children, position: providedPosition = "bottom", padding = 10, styles = {}, sizes, refresher, ...props }) => { const helperRef = useRef(null); const positionRef = useRef(""); const verticalAlignRef = useRef(""); const horizontalAlignRef = useRef(""); const { w: windowWidth, h: windowHeight } = getWindow(); const getStyles = stylesMatcher(styles); const helperRect = useRect(helperRef, refresher); const { width: helperWidth, height: helperHeight } = helperRect; const targetLeft = sizes?.left; const targetTop = sizes?.top; const targetRight = sizes?.right; const targetBottom = sizes?.bottom; const position = typeof providedPosition === "function" ? providedPosition( { width: helperWidth, height: helperHeight, windowWidth, windowHeight, top: targetTop, left: targetLeft, right: targetRight, bottom: targetBottom, x: sizes.x, y: sizes.y, }, helperRect, ) : providedPosition; const available = { left: targetLeft, right: windowWidth - targetRight, top: targetTop, bottom: windowHeight - targetBottom, }; const [px, py] = getPadding(padding); const couldPositionAt = (position) => { return ( available[position] > (isHoriz(position) ? helperWidth + px * 2 : helperHeight + py * 2) ); }; const autoPosition = (coords) => { const positionsOrder = bestPositionOf(available); for (let j = 0; j < positionsOrder.length; j++) { if (couldPositionAt(positionsOrder[j])) { positionRef.current = positionsOrder[j]; return coords[positionsOrder[j]]; } } positionRef.current = "center"; return coords.center; }; const pos = (helperPosition) => { if (Array.isArray(helperPosition)) { const isOutX = isOutsideX(helperPosition[0], windowWidth); const isOutY = isOutsideY(helperPosition[1], windowHeight); positionRef.current = "custom"; return [ isOutX ? windowWidth / 2 - helperWidth / 2 : helperPosition[0], isOutY ? windowHeight / 2 - helperHeight / 2 : helperPosition[1], ]; } const hX = isOutsideX(targetLeft + helperWidth, windowWidth) ? targetRight - helperWidth + px : targetLeft - px; const x = hX > px ? hX : px; const hY = isOutsideY(targetTop + helperHeight, windowHeight) ? targetBottom - helperHeight + py : targetTop - py; const y = hY > py ? hY : py; if (isOutsideY(targetTop + helperHeight, windowHeight)) { verticalAlignRef.current = "bottom"; } else { verticalAlignRef.current = "top"; } if (isOutsideX(targetLeft + helperWidth, windowWidth)) { horizontalAlignRef.current = "left"; } else { horizontalAlignRef.current = "right"; } const coords = { top: [x, targetTop - helperHeight - py * 2], right: [targetRight + px * 2, y], bottom: [x, targetBottom + py * 2], left: [targetLeft - helperWidth - px * 2, y], center: [ windowWidth / 2 - helperWidth / 2, windowHeight / 2 - helperHeight / 2, ], }; if (helperPosition === "center" || couldPositionAt(helperPosition)) { positionRef.current = helperPosition; return coords[helperPosition]; } return autoPosition(coords); }; const p = pos(position); return ( <div style={{ ...getStyles("popover", { position: positionRef.current, verticalAlign: verticalAlignRef.current, horizontalAlign: horizontalAlignRef.current, }), transform: `translate(${Math.round(p[0])}px, ${Math.round(p[1])}px)`, }} ref={helperRef} {...props} > {children} </div> ); }; export default Popover;

Explanation:

  • Position Calculation: The pos function calculates the position of the popover based on the available space and the desired position.
  • Auto Positioning: If the desired position is not feasible (e.g., it would be off-screen), the function calculates the best alternative position.
  • Custom Styles: Custom styles are merged with default styles using stylesMatcher, allowing for a high degree of customization.

Concepts and Techniques Learned:

  1. Responsive Design: Ensured the library components adapt to various screen sizes and resolutions.
  2. Dynamic Positioning: Developed algorithms to dynamically calculate and adjust the positions of popovers based on available space.
  3. State Management: Managed complex state using hooks and context to synchronize component behavior.

Challenges and Solutions

State Management

Challenge: Managing the state across various steps and ensuring synchronization between different components.

Solution: Utilized React's context and hooks to manage and synchronize state effectively. The TourProvider component was key in maintaining the state and providing context to child components.

Example of State Management in TourProvider:

import React, { useState, useContext } from "react"; import Tour from "./Tour"; import { ProviderProps, TourProps } from "./types"; const defaultState = { isOpen: false, setIsOpen: () => false, currentStep: 0, setCurrentStep: () => 0, steps: [], setSteps: () => [], disabledActions: false, setDisabledActions: () => false, components: {}, }; const TourContext = React.createContext<TourProps>(defaultState); const TourProvider: React.FC<ProviderProps> = ({ children, defaultOpen = false, startAt = 0, steps: defaultSteps, setCurrentStep: customSetCurrentStep, currentStep: customCurrentStep, ...props }) => { const [isOpen, setIsOpen] = useState(defaultOpen); const [currentStep, setCurrentStep] = useState(startAt); const [steps, setSteps] = useState(defaultSteps); const [disabledActions, setDisabledActions] = useState(false); const value = { isOpen, setIsOpen, currentStep: customCurrentStep || currentStep, setCurrentStep: customSetCurrentStep || setCurrentStep, steps, setSteps, disabledActions, setDisabledActions, ...props, }; return ( <TourContext.Provider value={value}> {children} {isOpen ? <Tour {...value} /> : null} </TourContext.Provider> ); }; export { TourProvider }; export default TourContext; export function useTour() { return useContext(TourContext); }

Explanation:

  • State Management: The TourProvider manages the state for the tour, including whether it is open, the current step, and the steps.
  • Context: React's context API is used to provide state and functions to the entire application.
  • Custom Hooks: A custom hook useTour is provided to access the tour's state and functions easily.

Concepts and Techniques Learned:

  1. React Context API: Leveraged context to manage global state across the application.
  2. React Hooks: Used hooks for state management and side effects, making the code more modular and reusable.

Customization and Flexibility

One of the primary goals was to ensure high customization. I achieved this by allowing users to override default components and styles. For example, the Mask component accepts custom styles and padding values to adapt to different use cases.

Customizing the Tour Provider:

import React from "react"; import { TourProvider } from "@react-explore-kit/tour"; import { steps } from "./steps.js"; import Main from "./Main.js"; export default function App() { return ( <TourProvider steps={steps} onClickClose={({ setCurrentStep, currentStep, steps, setIsOpen }) => { if (steps) { if (currentStep === steps.length - 1) { setIsOpen(false); // Close the tour if it's the last step } else { setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); // Move to the next step } } }} > <Main /> </TourProvider> ); }

Explanation:

  • TourProvider: The TourProvider wraps the application to provide context for the tour.
  • Steps: The steps of the tour are passed as props.
  • onClickClose: A custom function is provided to handle the close button click, either moving to the next step or closing the tour.

Concepts and Techniques Learned:

  1. Context API: Used to manage and share state across the application, providing a global state for the tour.
  2. Custom Hooks: Created custom hooks like useTour to encapsulate and reuse logic related to the tour state.
  3. Component Customization: Allowed users to pass custom components and styles to tailor the tour to their specific needs.

Handling Edge Cases

Challenge: Ensuring smooth transitions, handling different screen sizes, and providing accessibility features.

Solution: Implemented smooth scrolling, responsive design techniques, and ARIA attributes to enhance user experience and accessibility.

Example of Handling Positioning Edge Cases:

const pos = (helperPosition: Position) => { if (Array.isArray(helperPosition)) { const isOutX = isOutsideX(helperPosition[0], windowWidth); const isOutY = isOutsideY(helperPosition[1], windowHeight); positionRef.current = 'custom'; return [ isOutX ? windowWidth / 2 - helperWidth / 2 : helperPosition[0], isOutY ? windowHeight / 2 - helperHeight / 2 : helperPosition[1], ]; } const hX = isOutsideX(targetLeft + helperWidth, windowWidth) ? targetRight - helperWidth + px : targetLeft - px; const x = hX > px ? hX : px; const hY = isOutsideY(targetTop + helperHeight, windowHeight) ? targetBottom - helperHeight + py : targetTop - py; const y = hY > py ? hY : py; if (isOutsideY(targetTop + helperHeight, windowHeight)) { verticalAlignRef.current = 'bottom'; } else { verticalAlignRef.current = 'top'; } if (isOutsideX(targetLeft + helperWidth, windowWidth)) { horizontalAlignRef.current = 'left'; } else { horizontalAlignRef.current = 'right'; } const coords = { top: [x, targetTop - helperHeight - py * 2], right: [targetRight + px * 2, y], bottom: [x, targetBottom + py * 2], left: [targetLeft - helperWidth - px * 2, y], center: [windowWidth / 2 - helperWidth / 2, windowHeight / 2 - helperHeight / 2], }; if (helperPosition === 'center' or couldPositionAt(helperPosition)) { positionRef.current = helperPosition; return coords[helperPosition]; } return autoPosition(coords); };

Explanation:

  • Position Calculation: The pos function calculates the position of the popover based on the available space and the desired position.
  • Auto Positioning: If the desired position is not feasible (e.g., it would be off-screen), the function calculates the best alternative position.
  • Edge Cases Handling: The function ensures that the popover is always within the viewport, adjusting its position if necessary.

Concepts and Techniques Learned:

  1. Dynamic Positioning: Developed robust logic to dynamically adjust element positions based on viewport constraints.
  2. Edge Case Handling: Learned to identify and handle various edge cases to ensure a smooth user experience across different scenarios.
  3. Smooth Scrolling: Implemented smooth scrolling techniques to enhance user experience during navigation.

Conclusion

Developing the React Explore Kit provided valuable insights into managing complex state in React and the importance of customization and accessibility. The library stands as a robust tool for developers looking to enhance user interaction and engagement through guided tours and tooltips.