Repository: coryhouse/react-switchboard
Branch: main
Commit: 07a28f1adbf8
Files: 47
Total size: 49.9 KB
Directory structure:
gitextract_27h3g6k8/
├── .gitignore
├── LICENSE
├── README.md
├── examples/
│ └── vite-hello-world/
│ ├── .gitignore
│ ├── README.md
│ ├── eslint.config.js
│ ├── index.html
│ ├── package.json
│ ├── src/
│ │ ├── App.css
│ │ ├── App.tsx
│ │ ├── index.css
│ │ ├── main.tsx
│ │ └── vite-env.d.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── package.json
├── src/
│ ├── ErrorFallback.tsx
│ ├── GeneralSettings.tsx
│ ├── Http.tsx
│ ├── Switchboard.tsx
│ ├── clipboardUtils.ts
│ ├── components/
│ │ ├── Button.tsx
│ │ ├── Checkbox.tsx
│ │ ├── CloseButton.tsx
│ │ ├── CopySettingsButton.tsx
│ │ ├── DeleteButton.tsx
│ │ ├── Field.tsx
│ │ ├── HttpCustomResponseForm.tsx
│ │ ├── Input.tsx
│ │ ├── Label.tsx
│ │ ├── OpenButton.tsx
│ │ └── Select.tsx
│ ├── http.types.ts
│ ├── index.ts
│ ├── input.css
│ ├── localStorage.utils.ts
│ ├── switchboard.types.ts
│ ├── types/
│ │ └── react-use-keypress.d.ts
│ ├── useHttp.ts
│ ├── useOutsideClick.ts
│ ├── useSwitchboard.ts
│ └── useSwitchboardState.ts
├── tailwind.config.cjs
├── tsconfig.json
└── tsup.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Generated CSS
src/index.css
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 Cory House
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# React Switchboard 🎛
Quickly create custom DevTools for your React app.
- [Live Demo](https://switchboard-beta.vercel.app/) 🚀
- [Demo repo using Vite](https://github.com/coryhouse/switchboard-with-vite-demo)
## Quick Start
```
npm install react-switchboard -D
```
### Vite Example
Call `Switchboard` in your project root. Pass your app's main component to `Switchboard's` `appSlot` prop. Import `Switchboard` CSS. Lazy load `Switchboard` via an environment variable so it's excluded from your production bundle.
```tsx
import { lazy } from "react";
import { createRoot } from "react-dom/client";
import "react-switchboard/dist/index.css";
const Switchboard = lazy(() => import("react-switchboard"));
createRoot(document.getElementById("root")!).render(
import.meta.env.PROD ? (
) : (
} />
)
);
```
## Headless
The `Switchboard` component accepts children so you can specify what it renders. If you want complete control over the UI, use the `useSwitchboard` and `useSwitchboardState` hooks instead of the `Switchboard` component.
```tsx
function CustomSwitchboard() {
const { generalSettings, switchboardWindowRef, copySettingsUrlToClipboard } =
useSwitchboard();
// Use useSwitchboardState hook for custom settings.
const [user, setUser] = useSwitchboardState("sb-user", null);
return {
/* Your custom JSX to render your desired UI */
};
}
```
## Why Switchboard?
Code faster.
Reproduce edge cases.
Do real-time demos.
Use Switchboard to configure automated tests.
### Common Uses
- Login / switch users instantly
- Change feature toggles
- Configure mock APIs
- Force errors
- Simulate network slowness for specific requests
- Configure automated test scenarios
- Simulate incoming traffic and write conflicts
More info in this 25 minute conference talk: [Creating Custom Dev Tools for Your React App at React Rally](https://www.youtube.com/live/DGG6xpllTiE?si=vq7z35p3V_2ce68H&t=24527)
## API
### Components
- `Switchboard` - The main component that renders your app and the Switchboard UI.
### Hooks
- `useSwitchboard` - Logic for running Switchboard. Useful to create a custom Switchboard UI.
- `useSwitchboardState` - Declare Switchboard state. This state is automatically initialized from the URL, and written to localStorage so that it persists between sessions. Useful to extend Switchboard's features with custom settings for your app, or if you want to create a custom Switchboard UI.
## FAQ
- **How does mocking work?** Switchboard intercepts fetch requests via [Mock Service Worker](https://mswjs.io/), and displays a UI for configuring the mock responses.
- **Why does `Switchboard` render my app?** If you configure Switchboard to force the app to throw an error, Switchboard continues to render so you can change Switchboard's settings.
- **Why lazy loading?** Lazy load `Switchboard` via `React.lazy` and `Suspense` so that it's excluded your app's prod bundle.
- **How can I toggle Switchboard?** Use an environment variable to enable `Switchboard`. For example, tweak Vite's dev npm script to set an environment variable using [cross-env](https://www.npmjs.com/package/cross-env):
```bash
"dev": "cross-env VITE_ENABLE_SWITCHBOARD=Y vite",
```
Then, read this environment variable in your app's entry point.
## Acknowledgements
- [Mock Service Worker](https://mswjs.io/) - Switchboard mocks HTTP requests via msw.
## Inspiration
- [React Query Devtools](http://react-query.tanstack.com/devtools)
- [https://github.com/dataarts/dat.gui](https://github.com/dataarts/dat.gui?tab=readme-ov-file)
================================================
FILE: examples/vite-hello-world/.gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
================================================
FILE: examples/vite-hello-world/README.md
================================================
# React Switchboard with Vite - Hello World
See [main.tsx](https://github.com/coryhouse/react-switchboard/blob/main/examples/vite-hello-world/src/main.tsx). The rest of the project is a standard Vite project. Run via `npm run dev`.
================================================
FILE: examples/vite-hello-world/eslint.config.js
================================================
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config({
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
ignores: ['dist'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
})
================================================
FILE: examples/vite-hello-world/index.html
================================================
;
}
================================================
FILE: src/components/HttpCustomResponseForm.tsx
================================================
import { CustomResponse } from "../http.types";
import DeleteButton from "./DeleteButton";
import Input from "./Input";
export const customResponseDefaults = {
delay: 0,
status: 200,
response: undefined,
};
type CustomResponseFormProps = {
customResponse: CustomResponse;
setCustomResponses: React.Dispatch>;
};
export default function HttpCustomResponseForm({
customResponse,
setCustomResponses,
}: Readonly) {
const { handler, delay, status, response } = customResponse;
// TODO: Support all response properties: https://mswjs.io/docs/api/response#properties
return (
);
}
================================================
FILE: src/components/Input.tsx
================================================
import cx from "clsx";
import Label from "./Label";
interface InputProps extends React.ComponentPropsWithoutRef<"input"> {
/** Input ID - Specifying here so it's required by TypeScript */
id: string;
/** Input label */
label: string;
/** Set to true to highlight the label so that it is visually marked as changed from the default. */
changed?: boolean;
/** Specify input's width */
width?: "full" | "default";
}
export default function Input(props: InputProps) {
const {
id,
onChange,
label,
value,
changed = false,
className,
width = "default",
...rest
} = props;
return (
);
}
================================================
FILE: src/components/Label.tsx
================================================
interface LabelProps extends React.ComponentPropsWithoutRef<"label"> {
/** Label */
children: React.ReactNode;
}
export default function Label({ children, htmlFor }: LabelProps) {
return (
);
}
================================================
FILE: src/components/OpenButton.tsx
================================================
import Button, { ButtonProps } from "./Button";
export default function OpenButton(props: ButtonProps) {
return (
);
}
================================================
FILE: src/components/Select.tsx
================================================
import clsx from "clsx";
import Label from "./Label";
interface SelectProps extends React.ComponentPropsWithoutRef<"select"> {
/** Set to true to highlight the label so that it is visually marked as changed from the default. */
changed?: boolean;
/** Input label */
label: string;
/** Specify select's width */
width?: "full" | "default";
}
export default function Select(props: SelectProps) {
const {
id,
onChange,
width = "default",
changed = false,
label,
value,
...rest
} = props;
return (
<>
>
);
}
================================================
FILE: src/http.types.ts
================================================
import { RequestHandler } from "msw";
import { StartOptions } from "msw/browser";
export interface CustomResponse {
/** Response handler name */
handler: string;
/** Delay the response by a specified number of milliseconds. */
delay?: number;
/** HTTP status code to return for this call */
status?: number;
/** Optional response. */
response?: string;
}
export interface MswSettings {
/** A function that accepts custom settings and returns an array of Mock Service Worker request handlers */
requestHandlers: () => RequestHandler[];
/** Optional Mock Service worker start options */
startOptions?: StartOptions;
/** Global delay in milliseconds */
delay?: number;
/** Array of custom responses */
customResponses: CustomResponse[];
}
================================================
FILE: src/index.ts
================================================
import { useSwitchboardState } from "./useSwitchboardState";
import { useSwitchboard } from "./useSwitchboard";
import { Switchboard } from "./Switchboard";
import { Http } from "./Http";
import { useHttp } from "./useHttp";
import { CustomResponse, MswSettings } from "./http.types";
import {
Position,
switchboardPositions,
SwitchboardDefaults,
SwitchboardConfig,
} from "./switchboard.types";
import { customResponseDefaults } from "./components/HttpCustomResponseForm";
export {
useSwitchboard,
useSwitchboardState,
Http,
useHttp,
CustomResponse,
Position,
switchboardPositions,
SwitchboardDefaults,
SwitchboardConfig,
MswSettings,
customResponseDefaults,
};
export default Switchboard;
================================================
FILE: src/input.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
================================================
FILE: src/localStorage.utils.ts
================================================
// Get list of localStorage items that start with "sb-"
export function getLocalStorageSwitchboardKeys() {
return Object.keys(localStorage).filter((key) => key.startsWith("sb-"));
}
================================================
FILE: src/switchboard.types.ts
================================================
import { CustomResponse } from "./http.types";
export const switchboardPositions = [
"top-left",
"top-right",
"bottom-left",
"bottom-right",
] as const;
/** Union of Switchboard window positions */
export type Position = (typeof switchboardPositions)[number];
/** Setting defaults */
export interface SwitchboardDefaults {
/** Set to true to enable closing Switchboard by clicking outside the window by default */
closeViaOutsideClick: boolean;
/** When true, close Switchboard when the escape key is pressed */
closeViaEscapeKey?: boolean;
/** The default delay for mock HTTP requests */
delay: number;
/** The default window position */
position: Position;
/** Set to true to open Switchboard by default */
openByDefault: boolean;
}
export interface SwitchboardConfig {
/** Set to true to open the DevTools window by default */
openByDefault: boolean;
/** Switchboard window position */
position: Position;
/** Global HTTP delay */
delay: number;
/** Array of custom responses */
customResponses: CustomResponse[];
}
================================================
FILE: src/types/react-use-keypress.d.ts
================================================
// TODO: Remove this when types are provided. Pull from https://github.com/jacobbuck/react-use-keypress/issues/6#issue-1319821201
declare module "react-use-keypress" {
export default function useKeyPress(
key: KeyboardEvent["key"] | KeyboardEvent["key"][],
callback?: (e: KeyboardEvent) => void
);
}
================================================
FILE: src/useHttp.ts
================================================
import { useEffect } from "react";
import { setupWorker } from "msw/browser";
import { SwitchboardMswSettings } from "./Switchboard";
/** Configure msw */
export function useHttp(
setIsReady: () => void,
mswSettings?: SwitchboardMswSettings
) {
useEffect(() => {
if (!mswSettings) {
setIsReady();
return;
}
const setup = async () => {
const worker = setupWorker(...mswSettings.requestHandlers());
await worker.start(mswSettings.startOptions);
setIsReady();
};
setup();
}, []);
return {
requestHandlers: mswSettings?.requestHandlers(),
};
}
================================================
FILE: src/useOutsideClick.ts
================================================
import React, { useEffect } from "react";
/**
* Call a function when the user clicks outside the ref passed.
* @param ref Clicks outside this element will trigger the function provided to onOutsideClick
* @param onOutsideClick Function called when the user clicks outside the element specified in the ref argument
* @returns void
*/
export default function useOutsideClick(
ref: React.RefObject,
onOutsideClick: (event: globalThis.MouseEvent) => void
) {
useEffect(() => {
function handleClickOutside(event: globalThis.MouseEvent) {
if (
ref.current &&
event.target instanceof Node &&
!ref.current.contains(event.target)
) {
onOutsideClick(event);
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
});
}
================================================
FILE: src/useSwitchboard.ts
================================================
import React, { useState, useRef } from "react";
import useKeypress from "react-use-keypress";
import useOutsideClick from "./useOutsideClick";
import { Position, SwitchboardDefaults } from "./switchboard.types";
import { writeToClipboard } from "./clipboardUtils";
import { useSwitchboardState } from "./useSwitchboardState";
import { getLocalStorageSwitchboardKeys } from "./localStorage.utils";
import { CustomResponse } from "./http.types";
const maxUrlLength = 2000;
export const httpDefaults = {
delay: 0,
status: 200,
response: undefined,
};
interface KeyboardShortcut {
key: string | string[];
alt?: boolean;
ctrl?: boolean;
}
export interface GeneralSettings {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
position: Position;
setPosition: (position: Position) => void;
openByDefault: boolean;
setOpenByDefault: (openByDefault: boolean) => void;
closeViaOutsideClick: boolean;
setCloseViaOutsideClick: (closeViaOutsideClick: boolean) => void;
closeViaEscapeKey: boolean;
setCloseViaEscapeKey: (closeViaEscapeKey: boolean) => void;
}
export interface HttpSettings {
delay: number;
setDelay: (delay: number) => void;
delayChanged: boolean;
customResponses: CustomResponse[];
setCustomResponses: React.Dispatch>;
}
interface UseSwitchboardArgs {
/** Override the built in setting defaults */
overriddenDefaults?: Partial;
/** Specify a keyboard shortcut that toggles the window open/closed */
openKeyboardShortcut?: KeyboardShortcut;
}
/** This component is useful to display custom devtools settings for your project */
export function useSwitchboard({
openKeyboardShortcut,
overriddenDefaults,
}: UseSwitchboardArgs | undefined = {}) {
// These settings use the useSwitchboardState hook so that the settings persist in localStorage and are optionally initialized via the URL
const [openByDefault, setOpenByDefault] = useSwitchboardState(
"sb-openByDefault",
overriddenDefaults?.openByDefault ?? true
);
const [isOpen, setIsOpen] = useState(openByDefault);
const [closeViaOutsideClick, setCloseViaOutsideClick] = useSwitchboardState(
"sb-closeViaOutsideClick",
overriddenDefaults?.closeViaOutsideClick ?? false
);
const [closeViaEscapeKey, setCloseViaEscapeKey] = useSwitchboardState(
"sb-closeViaEscapeKey",
overriddenDefaults?.closeViaEscapeKey ?? true
);
const [position, setPosition] = useSwitchboardState(
"sb-position",
overriddenDefaults?.position ?? "top-left"
);
const [delay, setDelay, delayChanged] = useSwitchboardState(
"sb-delay",
httpDefaults.delay
);
const [customResponses, setCustomResponses] = useSwitchboardState<
CustomResponse[]
>("sb-customResponses", []);
const switchboardWindowRef = useRef(null);
useKeypress("Escape", () => {
if (closeViaEscapeKey) setIsOpen(false);
});
useKeypress(openKeyboardShortcut ? openKeyboardShortcut.key : [], (e) => {
if (openKeyboardShortcut?.alt && !e.altKey) return;
if (openKeyboardShortcut?.ctrl && !e.ctrlKey) return;
setIsOpen((current) => !current);
});
useOutsideClick(switchboardWindowRef, () => {
if (closeViaOutsideClick) setIsOpen(false);
});
// Convert the settings to URL search params
function getSettingsAsQueryParams() {
const switchboardKeys = getLocalStorageSwitchboardKeys();
// Encode the settings into search params
const params = new URLSearchParams();
switchboardKeys.forEach((key) => {
params.set(key, localStorage.getItem(key)!);
});
return "?" + params.toString();
}
async function copySettingsUrlToClipboard() {
const url = window.location.href + getSettingsAsQueryParams();
try {
await writeToClipboard(url);
if (url.length > maxUrlLength) {
alert(
`Warning: The URL copied to your clipboard may not work in all browsers because it's over ${maxUrlLength} characters. To reduce the length, consider redesigning your settings state to store identifiers (such as recordId=1) instead of specifying raw data.`
);
}
} catch (err) {
() => alert("Failed to copy settings URL to clipboard");
}
}
const generalSettings: GeneralSettings = {
isOpen,
setIsOpen,
position,
setPosition,
openByDefault,
setOpenByDefault,
closeViaOutsideClick,
setCloseViaOutsideClick,
closeViaEscapeKey,
setCloseViaEscapeKey,
};
const httpSettings: HttpSettings = {
delay,
setDelay,
delayChanged,
customResponses,
setCustomResponses,
};
return {
generalSettings,
httpSettings,
copySettingsUrlToClipboard,
switchboardWindowRef,
};
}
================================================
FILE: src/useSwitchboardState.ts
================================================
import { useCallback, useState } from "react";
/** Returns a string that contains the current URL with the specified key and value in the querystring */
function getUrlWithUpdatedQuery(url: URL, key: string, value: unknown = null) {
const urlWithoutQuerystring = url.href.split("?")[0];
const params = new URLSearchParams(url.search);
// Remove existing querystring if it exists. Here's why:
// 1. This assures the newly generated URL doesn't contain the param twice.
// 2. We only add the param if a value is provided, so removing it cleans up the URL if no value has been provided for the key.
params.delete(key);
if (value) params.append(key, JSON.stringify(value));
return urlWithoutQuerystring + "?" + params.toString();
}
interface SwitchboardStateOptions {
/** Set to true to show values that match the default value in the URL.
* By default, if the selected value matches the default value, it's omitted from the URL.
* This keeps the URL as short as possible. */
// TODO: Finish refactor to union
urlBehavior?: "initialization-only" | "initialize-and-display-always";
/** Set to true to store values that match the default value in localStorage.
* By default, if the selected value matches the default value, it's omitted from localStorage.
* This keeps localStorage as minimal as possible.
*/
storeDefaultValuesInLocalStorage?: boolean;
}
type SwitchboardKey = TKey extends string
? `${TPrefix}${TKey}`
: never;
/**
* This hook makes it easy to declare state for devtools.
* It's a fork of https://usehooks.com/useLocalStorage/,
* but enhanced to read the URL as a way to override the specified default.
* Since DevTools often benefit from being initialized via the URL,
* it reads optional default values from the URL. And since it's handy
* for the DevTools to "remember" settings between hard refreshes,
* it writes settings to localStorage onChange.
*
* Finally, if neither the URL or localStorage is set, it falls back
* to the provided default.
* In summary, it sets the default value in the following order:
* 1. URL
* 2. localStorage
* 3. Specified default
*
* So, in other words, if the URL isn't provided, it falls back to localStorage.
* If localStorage isn't set, it falls back to the specified default.
*
* This hook writes each state change to 2 spots:
* 1. localStorage (so settings persist after the tab is closed)
* 2. local state variable (so React renders when the state changes)
*
*
* @param key The URL param to check for the default, as well as the key used to write the value to localStorage
* @param defaultValue The default value to use if the URL and localStorage don't have a matching value for the provided key.
* */
export function useSwitchboardState(
/** Prefix each key with "sb-" to "namespace" all Switchboard settings. This avoids naming collisions and supports easily removing only the Switchboard settings from localStorage when necessary. */
key: SwitchboardKey,
defaultValue: T,
options?: SwitchboardStateOptions
) {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
if (typeof window === "undefined") {
return defaultValue;
}
// First, check the URL for a value and use it for the default if found.
const params = new URLSearchParams(window.location.search);
const urlValue = params.get(key);
if (urlValue) {
// TODO: Validate the object
const parsedObject = JSON.parse(urlValue);
// Update localStorage with URL value too
// TODO: Use localforage instead.
window.localStorage.setItem(key, JSON.stringify(parsedObject));
// Clear out the URL now that we read the value and stored it in localStorage. This keeps the URL clean.
// TODO: Make this an option
const newUrl = getUrlWithUpdatedQuery(new URL(window.location.href), key);
window.history.pushState("", "DevTools state update", newUrl);
return parsedObject;
}
// If URL doesn't contain the key, then fall back to checking localStorage for a default value
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
// TODO: Use Zod to validate the querystring
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
// If error also return initialValue
console.error(error);
return defaultValue;
}
});
// Return a wrapped version of useState's setter function that persists the new value to localStorage.
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Step 1: Save state, so React re-renders
setStoredValue(valueToStore);
// Step 2: Save to localStorage, so the settings persist after the window is closed
if (typeof window !== "undefined") {
// If the value is the initial value, then we can omit it from localStorage.
// But, go ahead and put it in localStorage anyway if storeDefaultValuesInLocalStorage is true.
if (
valueToStore == defaultValue &&
!options?.storeDefaultValuesInLocalStorage
) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
}
} catch (error) {
// TODO: Improve error handling
console.error(error);
}
},
[defaultValue, key, options?.storeDefaultValuesInLocalStorage, storedValue]
);
const isChanged = storedValue !== defaultValue;
return [storedValue, setValue, isChanged] as const;
}
================================================
FILE: tailwind.config.cjs
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
prefix: "sb-",
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
corePlugins: {
preflight: false,
},
};
================================================
FILE: tsconfig.json
================================================
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": false,
"lib": ["ESNext", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"jsx": "react-jsx"
},
"exclude": ["dist", "node_modules"]
}
================================================
FILE: tsup.config.ts
================================================
import { defineConfig } from "tsup";
export default defineConfig((options) => ({
entry: ["src/index.ts"],
clean: true,
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
external: ["react", "msw"],
...options,
}));