├── postcss.config.js
├── src
├── pages
│ ├── root.tsx
│ ├── errors
│ │ └── error-page.tsx
│ └── button-tester.tsx
├── App.tsx
├── global.css
├── main.tsx
├── routes
│ └── router.tsx
├── components
│ ├── ui
│ │ ├── progress.tsx
│ │ ├── button.tsx
│ │ └── progress-button.tsx
│ └── themes
│ │ ├── theme-toggle.tsx
│ │ └── theme-provider.tsx
├── machines
│ └── button-machine.ts
├── lib
│ └── utils.tsx
└── images
│ └── vite.svg
├── tsconfig.node.json
├── .gitignore
├── vite.config.ts
├── components.json
├── index.html
├── .github
└── workflows
│ └── greetings.yml
├── tsconfig.json
├── tailwind.config.js
├── package.json
├── tailwind-bg-classes.js
├── scripts
└── generateTailwindColorNames.cjs
└── README.md
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/src/pages/root.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 |
3 | export default function Root() {
4 | return (
5 | <>
6 |
7 | >
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { RouterProvider } from "react-router-dom";
2 | import { router } from "./routes/router";
3 |
4 | export default function App() {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/src/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body {
7 | height: 100%;
8 | margin: 0;
9 | }
10 |
11 | #root {
12 | @apply h-full;
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | !**/glob-import/dir/node_modules
2 | .DS_Store
3 | .idea
4 | *.cpuprofile
5 | *.local
6 | *.log
7 | /.vscode/
8 | /docs/.vitepress/cache
9 | dist
10 | dist-ssr
11 | explorations
12 | node_modules
13 | node_modules/
14 | playground-temp
15 | temp
16 | TODOs.md
17 | .eslintcache
18 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import react from "@vitejs/plugin-react";
3 | import { defineConfig } from "vite";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@/src": path.resolve(__dirname, "./src"),
11 | "@/convex": path.resolve(__dirname, "./convex"),
12 | },
13 | },
14 | });
15 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/global.css",
9 | "baseColor": "zinc",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/src/components",
14 | "utils": "@/src/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 | import "./global.css";
5 | import { ThemeProvider } from "./components/themes/theme-provider";
6 |
7 | ReactDOM.createRoot(document.getElementById("root")!).render(
8 |
9 |
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Progress Button - shadcn/ui & tailwind
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.github/workflows/greetings.yml:
--------------------------------------------------------------------------------
1 | name: Greetings
2 |
3 | on: [pull_request_target, issues]
4 |
5 | jobs:
6 | greeting:
7 | runs-on: ubuntu-latest
8 | permissions:
9 | issues: write
10 | pull-requests: write
11 | steps:
12 | - uses: actions/first-interaction@v1
13 | with:
14 | repo-token: ${{ secrets.GITHUB_TOKEN }}
15 | issue-message: "Message that will be displayed on users' first issue"
16 | pr-message: "Message that will be displayed on users' first pull request"
17 |
--------------------------------------------------------------------------------
/src/pages/errors/error-page.tsx:
--------------------------------------------------------------------------------
1 | import { isRouteErrorResponse, useRouteError } from "react-router-dom";
2 |
3 | export default function ErrorPage() {
4 | const error = useRouteError();
5 |
6 | return (
7 |
8 |
Oops!
9 |
Sorry, an unexpected error has occurred.
10 |
11 |
12 | {isRouteErrorResponse(error)
13 | ? error.status || error.statusText
14 | : "Unknown error"}
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/routes/router.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter } from "react-router-dom";
2 | import Root from "@/src/pages/root";
3 | import ErrorPage from "@/src/pages/errors/error-page";
4 | import ButtonTester from "@/src/pages/button-tester";
5 |
6 | export const router = createBrowserRouter([
7 | {
8 | path: "/",
9 | element: ,
10 | errorElement: ,
11 | children: [
12 | {
13 | path: "/",
14 | element: ,
15 | },
16 | {
17 | path: "/test",
18 | element: ,
19 | },
20 | ],
21 | },
22 | ]);
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ESNext.Array"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "strictNullChecks": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true,
23 |
24 | "types": ["vite/client"],
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/src/*": ["./src/*"]
28 | }
29 | },
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ProgressPrimitive from "@radix-ui/react-progress"
3 |
4 | import { cn } from "@/src/lib/utils"
5 |
6 | const Progress = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, value, ...props }, ref) => (
10 |
18 |
22 |
23 | ))
24 | Progress.displayName = ProgressPrimitive.Root.displayName
25 |
26 | export { Progress }
27 |
--------------------------------------------------------------------------------
/src/pages/button-tester.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import ProgressButton from "../components/ui/progress-button";
3 |
4 | export default function ButtonTester() {
5 | const [manualProgress, setManualProgress] = useState(0);
6 |
7 | useEffect(() => {
8 | const intervalId = setInterval(() => {
9 | setManualProgress((prev) => {
10 | if (prev < 100) {
11 | return prev + 5;
12 | }
13 | return prev;
14 | });
15 | }, 200);
16 | return () => {
17 | clearInterval(intervalId);
18 | };
19 | }, []);
20 |
21 | return (
22 |
23 |
{
28 | console.log("clicked");
29 | setManualProgress(0);
30 | }}
31 | onComplete={() => console.log("completed")}
32 | onError={(error) => console.error(error)}
33 | />
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | "./tailwind-bg-classes.js",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | fontSize: {
22 | xxs: "0.6rem",
23 | },
24 | keyframes: {
25 | "accordion-down": {
26 | from: { height: "0" },
27 | to: { height: "var(--radix-accordion-content-height)" },
28 | },
29 | "accordion-up": {
30 | from: { height: "var(--radix-accordion-content-height)" },
31 | to: { height: "0" },
32 | },
33 | },
34 | animation: {
35 | "accordion-down": "accordion-down 0.2s ease-out",
36 | "accordion-up": "accordion-up 0.2s ease-out",
37 | },
38 | },
39 | },
40 | plugins: [require("tailwindcss-animate")],
41 | };
42 |
--------------------------------------------------------------------------------
/src/machines/button-machine.ts:
--------------------------------------------------------------------------------
1 | import { assign, setup } from "xstate";
2 |
3 | const progressButtonMachine = setup({
4 | types: {
5 | context: {} as {
6 | progress: number;
7 | },
8 | events: {} as
9 | | { type: "click" }
10 | | { type: "complete" }
11 | | { type: "setProgress"; progress: number },
12 | },
13 | }).createMachine({
14 | context: {
15 | progress: 0,
16 | },
17 | id: "progressButton",
18 | initial: "idle",
19 |
20 | states: {
21 | idle: {
22 | on: { click: "inProgress" },
23 | },
24 | inProgress: {
25 | on: {
26 | setProgress: {
27 | actions: assign(({ event }) => {
28 | return {
29 | progress: event.progress,
30 | };
31 | }),
32 | },
33 | complete: "success",
34 | },
35 | },
36 | success: {
37 | after: {
38 | 1500: "successFadeOut", // Transition to 'successFadeOut' after x ms
39 | },
40 | },
41 | successFadeOut: {
42 | after: {
43 | 10: "idle", // Transition to 'idle' after x ms
44 | },
45 | },
46 | },
47 | });
48 |
49 | export { progressButtonMachine };
50 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "progress-button-shadcn-tailwind",
3 | "private": true,
4 | "version": "0.0.1",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite --open",
8 | "build": "tsc && vite build",
9 | "lint": "tsc && eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@radix-ui/react-progress": "^1.0.3",
14 | "@radix-ui/react-slot": "^1.0.2",
15 | "@types/react-router-dom": "^5.3.3",
16 | "@xstate/react": "^4.1.1",
17 | "class-variance-authority": "^0.7.0",
18 | "clsx": "^2.1.1",
19 | "lucide-react": "^0.379.0",
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0",
22 | "react-router-dom": "^6.23.1",
23 | "tailwind-merge": "^1.14.0",
24 | "tailwindcss-animate": "^1.0.7",
25 | "xstate": "^5.13.1"
26 | },
27 | "devDependencies": {
28 | "@types/node": "^20.7.0",
29 | "@types/react": "^18.2.21",
30 | "@types/react-dom": "^18.2.7",
31 | "@vitejs/plugin-react": "^4.0.4",
32 | "autoprefixer": "^10.4.19",
33 | "eslint": "^8.49.0",
34 | "postcss": "^8.4.38",
35 | "tailwindcss": "^3.4.4",
36 | "typescript": "^5.2.2",
37 | "vite": "^4.4.9"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/themes/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react";
2 |
3 | import { Button } from "@/src/components/ui/button";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "@/src/components/ui/dropdown-menu";
10 | import { useTheme } from "@/src/components/themes/theme-provider";
11 |
12 | export function ModeToggle() {
13 | const { setTheme } = useTheme();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | Toggle theme
22 |
23 |
24 |
25 | setTheme("light")}>
26 | Light
27 |
28 | setTheme("dark")}>
29 | Dark
30 |
31 | setTheme("system")}>
32 | System
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/themes/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react";
2 |
3 | type Theme = "dark" | "light" | "system";
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "light",
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "light",
26 | storageKey = "invoicer-ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme
31 | );
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement;
35 |
36 | root.classList.remove("light", "dark");
37 |
38 | if (theme === "system") {
39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
40 | .matches
41 | ? "dark"
42 | : "light";
43 |
44 | root.classList.add(systemTheme);
45 | return;
46 | }
47 |
48 | root.classList.add(theme);
49 | }, [theme]);
50 |
51 | const value = {
52 | theme,
53 | setTheme: (theme: Theme) => {
54 | localStorage.setItem(storageKey, theme);
55 | setTheme(theme);
56 | },
57 | };
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | }
65 |
66 | export const useTheme = () => {
67 | const context = useContext(ThemeProviderContext);
68 |
69 | if (context === undefined)
70 | throw new Error("useTheme must be used within a ThemeProvider");
71 |
72 | return context;
73 | };
74 |
--------------------------------------------------------------------------------
/src/lib/utils.tsx:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { ForwardRefRenderFunction, forwardRef } from "react";
3 | import { twMerge } from "tailwind-merge";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export const sleep = (ms: number) =>
10 | new Promise((resolve) => setTimeout(resolve, ms));
11 |
12 | // forward refs
13 | export function fr>(
14 | component: ForwardRefRenderFunction
15 | ) {
16 | const wrapped = forwardRef(component);
17 | wrapped.displayName = component.name;
18 | return wrapped;
19 | }
20 |
21 | // styled element
22 | export function se<
23 | T = HTMLElement,
24 | P extends React.HTMLAttributes = React.HTMLAttributes,
25 | >(Tag: keyof React.ReactHTML, ...classNames: ClassValue[]) {
26 | const component = fr(({ className, ...props }, ref) => (
27 | // @ts-expect-error Too complicated for TypeScript
28 |
29 | ));
30 | component.displayName = Tag[0].toUpperCase() + Tag.slice(1);
31 | return component;
32 | }
33 |
34 | // Tailwind color class or HEX color
35 | export type TailwindColorClassOrHexColor = "tailwind" | "hex" | "unknown";
36 | export function isTailwindColorClassOrHexColor(
37 | str: string
38 | ): TailwindColorClassOrHexColor {
39 | const tailwindColorClassPattern = /^[a-z]+-[0-9]{3}$/;
40 | const hexColorPattern = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/;
41 |
42 | if (tailwindColorClassPattern.test(str)) {
43 | return "tailwind";
44 | } else if (hexColorPattern.test(str)) {
45 | return "hex";
46 | } else {
47 | return "unknown";
48 | }
49 | }
50 |
51 | export function generateProgressBarBackgroundColorClass(colorString: string) {
52 | const color = isTailwindColorClassOrHexColor(colorString);
53 | switch (color) {
54 | case "tailwind":
55 | return `[&>*>*]:bg-${colorString}`; // e.g. bg-red-500
56 | case "hex":
57 | return `[&>*>*]:bg-[${colorString}]`; // e.g. bg-[#ff0000]
58 | case "unknown":
59 | return "[&>*>*]:bg-primany-900"; // shrug emoji
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/src/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90",
14 | destructive:
15 | "bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-50 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
18 | secondary:
19 | "bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
20 | ghost:
21 | "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
22 | link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50",
23 | },
24 | size: {
25 | default: "h-10 px-4 py-2",
26 | sm: "h-9 rounded-md px-3",
27 | lg: "h-11 rounded-md px-8",
28 | icon: "h-10 w-10",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | );
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
53 | );
54 | }
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/tailwind-bg-classes.js:
--------------------------------------------------------------------------------
1 | module.exports = [
2 | "[&>*>*]:bg-black",
3 | "[&>*>*]:bg-white",
4 | "[&>*>*]:bg-gray-50",
5 | "[&>*>*]:bg-gray-100",
6 | "[&>*>*]:bg-gray-200",
7 | "[&>*>*]:bg-gray-300",
8 | "[&>*>*]:bg-gray-400",
9 | "[&>*>*]:bg-gray-500",
10 | "[&>*>*]:bg-gray-600",
11 | "[&>*>*]:bg-gray-700",
12 | "[&>*>*]:bg-gray-800",
13 | "[&>*>*]:bg-gray-900",
14 | "[&>*>*]:bg-coolGray-50",
15 | "[&>*>*]:bg-coolGray-100",
16 | "[&>*>*]:bg-coolGray-200",
17 | "[&>*>*]:bg-coolGray-300",
18 | "[&>*>*]:bg-coolGray-400",
19 | "[&>*>*]:bg-coolGray-500",
20 | "[&>*>*]:bg-coolGray-600",
21 | "[&>*>*]:bg-coolGray-700",
22 | "[&>*>*]:bg-coolGray-800",
23 | "[&>*>*]:bg-coolGray-900",
24 | "[&>*>*]:bg-warmGray-50",
25 | "[&>*>*]:bg-warmGray-100",
26 | "[&>*>*]:bg-warmGray-200",
27 | "[&>*>*]:bg-warmGray-300",
28 | "[&>*>*]:bg-warmGray-400",
29 | "[&>*>*]:bg-warmGray-500",
30 | "[&>*>*]:bg-warmGray-600",
31 | "[&>*>*]:bg-warmGray-700",
32 | "[&>*>*]:bg-warmGray-800",
33 | "[&>*>*]:bg-warmGray-900",
34 | "[&>*>*]:bg-trueGray-50",
35 | "[&>*>*]:bg-trueGray-100",
36 | "[&>*>*]:bg-trueGray-200",
37 | "[&>*>*]:bg-trueGray-300",
38 | "[&>*>*]:bg-trueGray-400",
39 | "[&>*>*]:bg-trueGray-500",
40 | "[&>*>*]:bg-trueGray-600",
41 | "[&>*>*]:bg-trueGray-700",
42 | "[&>*>*]:bg-trueGray-800",
43 | "[&>*>*]:bg-trueGray-900",
44 | "[&>*>*]:bg-red-50",
45 | "[&>*>*]:bg-red-100",
46 | "[&>*>*]:bg-red-200",
47 | "[&>*>*]:bg-red-300",
48 | "[&>*>*]:bg-red-400",
49 | "[&>*>*]:bg-red-500",
50 | "[&>*>*]:bg-red-600",
51 | "[&>*>*]:bg-red-700",
52 | "[&>*>*]:bg-red-800",
53 | "[&>*>*]:bg-red-900",
54 | "[&>*>*]:bg-yellow-50",
55 | "[&>*>*]:bg-yellow-100",
56 | "[&>*>*]:bg-yellow-200",
57 | "[&>*>*]:bg-yellow-300",
58 | "[&>*>*]:bg-yellow-400",
59 | "[&>*>*]:bg-yellow-500",
60 | "[&>*>*]:bg-yellow-600",
61 | "[&>*>*]:bg-yellow-700",
62 | "[&>*>*]:bg-yellow-800",
63 | "[&>*>*]:bg-yellow-900",
64 | "[&>*>*]:bg-green-50",
65 | "[&>*>*]:bg-green-100",
66 | "[&>*>*]:bg-green-200",
67 | "[&>*>*]:bg-green-300",
68 | "[&>*>*]:bg-green-400",
69 | "[&>*>*]:bg-green-500",
70 | "[&>*>*]:bg-green-600",
71 | "[&>*>*]:bg-green-700",
72 | "[&>*>*]:bg-green-800",
73 | "[&>*>*]:bg-green-900",
74 | "[&>*>*]:bg-blue-50",
75 | "[&>*>*]:bg-blue-100",
76 | "[&>*>*]:bg-blue-200",
77 | "[&>*>*]:bg-blue-300",
78 | "[&>*>*]:bg-blue-400",
79 | "[&>*>*]:bg-blue-500",
80 | "[&>*>*]:bg-blue-600",
81 | "[&>*>*]:bg-blue-700",
82 | "[&>*>*]:bg-blue-800",
83 | "[&>*>*]:bg-blue-900",
84 | "[&>*>*]:bg-indigo-50",
85 | "[&>*>*]:bg-indigo-100",
86 | "[&>*>*]:bg-indigo-200",
87 | "[&>*>*]:bg-indigo-300",
88 | "[&>*>*]:bg-indigo-400",
89 | "[&>*>*]:bg-indigo-500",
90 | "[&>*>*]:bg-indigo-600",
91 | "[&>*>*]:bg-indigo-700",
92 | "[&>*>*]:bg-indigo-800",
93 | "[&>*>*]:bg-indigo-900",
94 | "[&>*>*]:bg-purple-50",
95 | "[&>*>*]:bg-purple-100",
96 | "[&>*>*]:bg-purple-200",
97 | "[&>*>*]:bg-purple-300",
98 | "[&>*>*]:bg-purple-400",
99 | "[&>*>*]:bg-purple-500",
100 | "[&>*>*]:bg-purple-600",
101 | "[&>*>*]:bg-purple-700",
102 | "[&>*>*]:bg-purple-800",
103 | "[&>*>*]:bg-purple-900",
104 | "[&>*>*]:bg-pink-50",
105 | "[&>*>*]:bg-pink-100",
106 | "[&>*>*]:bg-pink-200",
107 | "[&>*>*]:bg-pink-300",
108 | "[&>*>*]:bg-pink-400",
109 | "[&>*>*]:bg-pink-500",
110 | "[&>*>*]:bg-pink-600",
111 | "[&>*>*]:bg-pink-700",
112 | "[&>*>*]:bg-pink-800",
113 | "[&>*>*]:bg-pink-900",
114 | "[&>*>*]:bg-cyan-50",
115 | "[&>*>*]:bg-cyan-100",
116 | "[&>*>*]:bg-cyan-200",
117 | "[&>*>*]:bg-cyan-300",
118 | "[&>*>*]:bg-cyan-400",
119 | "[&>*>*]:bg-cyan-500",
120 | "[&>*>*]:bg-cyan-600",
121 | "[&>*>*]:bg-cyan-700",
122 | "[&>*>*]:bg-cyan-800",
123 | "[&>*>*]:bg-cyan-900",
124 | "[&>*>*]:bg-teal-50",
125 | "[&>*>*]:bg-teal-100",
126 | "[&>*>*]:bg-teal-200",
127 | "[&>*>*]:bg-teal-300",
128 | "[&>*>*]:bg-teal-400",
129 | "[&>*>*]:bg-teal-500",
130 | "[&>*>*]:bg-teal-600",
131 | "[&>*>*]:bg-teal-700",
132 | "[&>*>*]:bg-teal-800",
133 | "[&>*>*]:bg-teal-900",
134 | "[&>*>*]:bg-emerald-50",
135 | "[&>*>*]:bg-emerald-100",
136 | "[&>*>*]:bg-emerald-200",
137 | "[&>*>*]:bg-emerald-300",
138 | "[&>*>*]:bg-emerald-400",
139 | "[&>*>*]:bg-emerald-500",
140 | "[&>*>*]:bg-emerald-600",
141 | "[&>*>*]:bg-emerald-700",
142 | "[&>*>*]:bg-emerald-800",
143 | "[&>*>*]:bg-emerald-900",
144 | "[&>*>*]:bg-lime-50",
145 | "[&>*>*]:bg-lime-100",
146 | "[&>*>*]:bg-lime-200",
147 | "[&>*>*]:bg-lime-300",
148 | "[&>*>*]:bg-lime-400",
149 | "[&>*>*]:bg-lime-500",
150 | "[&>*>*]:bg-lime-600",
151 | "[&>*>*]:bg-lime-700",
152 | "[&>*>*]:bg-lime-800",
153 | "[&>*>*]:bg-lime-900",
154 | "[&>*>*]:bg-amber-50",
155 | "[&>*>*]:bg-amber-100",
156 | "[&>*>*]:bg-amber-200",
157 | "[&>*>*]:bg-amber-300",
158 | "[&>*>*]:bg-amber-400",
159 | "[&>*>*]:bg-amber-500",
160 | "[&>*>*]:bg-amber-600",
161 | "[&>*>*]:bg-amber-700",
162 | "[&>*>*]:bg-amber-800",
163 | "[&>*>*]:bg-amber-900",
164 | "[&>*>*]:bg-orange-50",
165 | "[&>*>*]:bg-orange-100",
166 | "[&>*>*]:bg-orange-200",
167 | "[&>*>*]:bg-orange-300",
168 | "[&>*>*]:bg-orange-400",
169 | "[&>*>*]:bg-orange-500",
170 | "[&>*>*]:bg-orange-600",
171 | "[&>*>*]:bg-orange-700",
172 | "[&>*>*]:bg-orange-800",
173 | "[&>*>*]:bg-orange-900",
174 | "[&>*>*]:bg-rose-50",
175 | "[&>*>*]:bg-rose-100",
176 | "[&>*>*]:bg-rose-200",
177 | "[&>*>*]:bg-rose-300",
178 | "[&>*>*]:bg-rose-400",
179 | "[&>*>*]:bg-rose-500",
180 | "[&>*>*]:bg-rose-600",
181 | "[&>*>*]:bg-rose-700",
182 | "[&>*>*]:bg-rose-800",
183 | "[&>*>*]:bg-rose-900",
184 | ];
185 |
--------------------------------------------------------------------------------
/scripts/generateTailwindColorNames.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | const fs = require("fs");
3 |
4 | // Tailwind CSS color palette
5 | const colors = {
6 | // Grayscale
7 | black: "black",
8 | white: "white",
9 | gray: {
10 | 50: "gray-50",
11 | 100: "gray-100",
12 | 200: "gray-200",
13 | 300: "gray-300",
14 | 400: "gray-400",
15 | 500: "gray-500",
16 | 600: "gray-600",
17 | 700: "gray-700",
18 | 800: "gray-800",
19 | 900: "gray-900",
20 | },
21 | coolGray: {
22 | 50: "coolGray-50",
23 | 100: "coolGray-100",
24 | 200: "coolGray-200",
25 | 300: "coolGray-300",
26 | 400: "coolGray-400",
27 | 500: "coolGray-500",
28 | 600: "coolGray-600",
29 | 700: "coolGray-700",
30 | 800: "coolGray-800",
31 | 900: "coolGray-900",
32 | },
33 | warmGray: {
34 | 50: "warmGray-50",
35 | 100: "warmGray-100",
36 | 200: "warmGray-200",
37 | 300: "warmGray-300",
38 | 400: "warmGray-400",
39 | 500: "warmGray-500",
40 | 600: "warmGray-600",
41 | 700: "warmGray-700",
42 | 800: "warmGray-800",
43 | 900: "warmGray-900",
44 | },
45 | trueGray: {
46 | 50: "trueGray-50",
47 | 100: "trueGray-100",
48 | 200: "trueGray-200",
49 | 300: "trueGray-300",
50 | 400: "trueGray-400",
51 | 500: "trueGray-500",
52 | 600: "trueGray-600",
53 | 700: "trueGray-700",
54 | 800: "trueGray-800",
55 | 900: "trueGray-900",
56 | },
57 | // Primary Colors
58 | red: {
59 | 50: "red-50",
60 | 100: "red-100",
61 | 200: "red-200",
62 | 300: "red-300",
63 | 400: "red-400",
64 | 500: "red-500",
65 | 600: "red-600",
66 | 700: "red-700",
67 | 800: "red-800",
68 | 900: "red-900",
69 | },
70 | yellow: {
71 | 50: "yellow-50",
72 | 100: "yellow-100",
73 | 200: "yellow-200",
74 | 300: "yellow-300",
75 | 400: "yellow-400",
76 | 500: "yellow-500",
77 | 600: "yellow-600",
78 | 700: "yellow-700",
79 | 800: "yellow-800",
80 | 900: "yellow-900",
81 | },
82 | green: {
83 | 50: "green-50",
84 | 100: "green-100",
85 | 200: "green-200",
86 | 300: "green-300",
87 | 400: "green-400",
88 | 500: "green-500",
89 | 600: "green-600",
90 | 700: "green-700",
91 | 800: "green-800",
92 | 900: "green-900",
93 | },
94 | blue: {
95 | 50: "blue-50",
96 | 100: "blue-100",
97 | 200: "blue-200",
98 | 300: "blue-300",
99 | 400: "blue-400",
100 | 500: "blue-500",
101 | 600: "blue-600",
102 | 700: "blue-700",
103 | 800: "blue-800",
104 | 900: "blue-900",
105 | },
106 | indigo: {
107 | 50: "indigo-50",
108 | 100: "indigo-100",
109 | 200: "indigo-200",
110 | 300: "indigo-300",
111 | 400: "indigo-400",
112 | 500: "indigo-500",
113 | 600: "indigo-600",
114 | 700: "indigo-700",
115 | 800: "indigo-800",
116 | 900: "indigo-900",
117 | },
118 | purple: {
119 | 50: "purple-50",
120 | 100: "purple-100",
121 | 200: "purple-200",
122 | 300: "purple-300",
123 | 400: "purple-400",
124 | 500: "purple-500",
125 | 600: "purple-600",
126 | 700: "purple-700",
127 | 800: "purple-800",
128 | 900: "purple-900",
129 | },
130 | pink: {
131 | 50: "pink-50",
132 | 100: "pink-100",
133 | 200: "pink-200",
134 | 300: "pink-300",
135 | 400: "pink-400",
136 | 500: "pink-500",
137 | 600: "pink-600",
138 | 700: "pink-700",
139 | 800: "pink-800",
140 | 900: "pink-900",
141 | },
142 | cyan: {
143 | 50: "cyan-50",
144 | 100: "cyan-100",
145 | 200: "cyan-200",
146 | 300: "cyan-300",
147 | 400: "cyan-400",
148 | 500: "cyan-500",
149 | 600: "cyan-600",
150 | 700: "cyan-700",
151 | 800: "cyan-800",
152 | 900: "cyan-900",
153 | },
154 | teal: {
155 | 50: "teal-50",
156 | 100: "teal-100",
157 | 200: "teal-200",
158 | 300: "teal-300",
159 | 400: "teal-400",
160 | 500: "teal-500",
161 | 600: "teal-600",
162 | 700: "teal-700",
163 | 800: "teal-800",
164 | 900: "teal-900",
165 | },
166 | emerald: {
167 | 50: "emerald-50",
168 | 100: "emerald-100",
169 | 200: "emerald-200",
170 | 300: "emerald-300",
171 | 400: "emerald-400",
172 | 500: "emerald-500",
173 | 600: "emerald-600",
174 | 700: "emerald-700",
175 | 800: "emerald-800",
176 | 900: "emerald-900",
177 | },
178 | lime: {
179 | 50: "lime-50",
180 | 100: "lime-100",
181 | 200: "lime-200",
182 | 300: "lime-300",
183 | 400: "lime-400",
184 | 500: "lime-500",
185 | 600: "lime-600",
186 | 700: "lime-700",
187 | 800: "lime-800",
188 | 900: "lime-900",
189 | },
190 | amber: {
191 | 50: "amber-50",
192 | 100: "amber-100",
193 | 200: "amber-200",
194 | 300: "amber-300",
195 | 400: "amber-400",
196 | 500: "amber-500",
197 | 600: "amber-600",
198 | 700: "amber-700",
199 | 800: "amber-800",
200 | 900: "amber-900",
201 | },
202 | orange: {
203 | 50: "orange-50",
204 | 100: "orange-100",
205 | 200: "orange-200",
206 | 300: "orange-300",
207 | 400: "orange-400",
208 | 500: "orange-500",
209 | 600: "orange-600",
210 | 700: "orange-700",
211 | 800: "orange-800",
212 | 900: "orange-900",
213 | },
214 | rose: {
215 | 50: "rose-50",
216 | 100: "rose-100",
217 | 200: "rose-200",
218 | 300: "rose-300",
219 | 400: "rose-400",
220 | 500: "rose-500",
221 | 600: "rose-600",
222 | 700: "rose-700",
223 | 800: "rose-800",
224 | 900: "rose-900",
225 | },
226 | };
227 |
228 | // Function to generate class names
229 | const generateBgClasses = (colors) => {
230 | const classes = [];
231 |
232 | for (const color in colors) {
233 | if (typeof colors[color] === "string") {
234 | classes.push(`[&>*>*]:bg-${colors[color]}`);
235 | } else {
236 | for (const shade in colors[color]) {
237 | classes.push(`[&>*>*]:bg-${colors[color][shade]}`);
238 | }
239 | }
240 | }
241 |
242 | return classes;
243 | };
244 |
245 | const bgClasses = generateBgClasses(colors);
246 |
247 | // Write to a file
248 | fs.writeFileSync(
249 | "tailwind-bg-classes.js",
250 | `module.exports = ${JSON.stringify(bgClasses, null, 2)};`,
251 | "utf8"
252 | );
253 |
254 | console.log("Tailwind background color classes have been generated.");
255 |
--------------------------------------------------------------------------------
/src/components/ui/progress-button.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useMachine } from "@xstate/react";
3 | import { progressButtonMachine } from "@/src/machines/button-machine";
4 | import { Check, File } from "lucide-react";
5 | import { Button } from "@/src/components/ui/button";
6 | import { Progress } from "@/src/components/ui/progress";
7 |
8 | type ProgressButtonBaseProps = {
9 | successColorClass?: string;
10 | onClick?: () => void;
11 | onComplete?: () => void;
12 | onError?: (error: Error) => void;
13 | };
14 |
15 | type ManualProgressButtonProps = ProgressButtonBaseProps & {
16 | progressType?: "manual";
17 | progress: number;
18 | duration?: never;
19 | totalDuration?: never;
20 | numberOfProgressSteps?: never;
21 | };
22 |
23 | type AutomaticProgressButtonProps = ProgressButtonBaseProps & {
24 | progressType?: "automatic";
25 | progress?: never;
26 | totalDuration?: number;
27 | numberOfProgressSteps?: number;
28 | };
29 |
30 | type ProgressButtonProps =
31 | | ManualProgressButtonProps
32 | | AutomaticProgressButtonProps;
33 |
34 | const ProgressButton = (props: ProgressButtonProps) => {
35 | const {
36 | progressType = "automatic",
37 | totalDuration = 5000,
38 | numberOfProgressSteps = 5,
39 | successColorClass,
40 | onClick,
41 | onComplete,
42 | onError,
43 | progress,
44 | } = props;
45 |
46 | const [state, send] = useMachine(progressButtonMachine);
47 |
48 | useEffect(() => {
49 | if (progress) {
50 | send({ type: "setProgress", progress });
51 | if (progress >= 100) {
52 | setTimeout(() => {
53 | try {
54 | send({ type: "setProgress", progress: 0 });
55 | send({ type: "complete" });
56 | handleComplete();
57 | } catch (e: any) {
58 | handleError(e);
59 | }
60 | }, 1000);
61 | }
62 | }
63 | }, [progress, send]);
64 |
65 | const scheduleProgressUpdates = (totalDuration: number, steps: number) => {
66 | // Generate random durations
67 | const randomDurations = Array.from({ length: steps }, () => Math.random());
68 | const totalRandom = randomDurations.reduce((sum, value) => sum + value, 0);
69 | const normalizedDurations = randomDurations.map(
70 | (value) => (value / totalRandom) * totalDuration
71 | );
72 |
73 | // Generate random progress increments
74 | const randomProgressIncrements = Array.from({ length: steps }, () =>
75 | Math.random()
76 | );
77 | const totalRandomProgress = randomProgressIncrements.reduce(
78 | (sum, value) => sum + value,
79 | 0
80 | );
81 | const normalizedProgresses = randomProgressIncrements.map((value) =>
82 | Math.round((value / totalRandomProgress) * 100)
83 | );
84 |
85 | let accumulatedTime = 0;
86 | let accumulatedProgress = 0;
87 |
88 | for (let i = 0; i < steps; i++) {
89 | accumulatedTime += normalizedDurations[i];
90 | accumulatedProgress += normalizedProgresses[i];
91 |
92 | let progress = accumulatedProgress > 95 ? 100 : accumulatedProgress;
93 |
94 | setTimeout(() => {
95 | send({ type: "setProgress", progress });
96 |
97 | if (progress === 100) {
98 | setTimeout(() => {
99 | try {
100 | send({ type: "setProgress", progress: 0 });
101 | send({ type: "complete" });
102 | handleComplete();
103 | } catch (e: any) {
104 | handleError(e);
105 | }
106 | }, 1000);
107 | }
108 | }, accumulatedTime);
109 | }
110 | };
111 |
112 | const isManualComplete = () =>
113 | progressType === "manual" && state.context.progress >= 100;
114 | const shouldStartAutomaticProgress = () =>
115 | progressType === "automatic" && state.matches("inProgress");
116 |
117 | useEffect(() => {
118 | if (isManualComplete()) {
119 | handleComplete();
120 | } else if (shouldStartAutomaticProgress()) {
121 | scheduleProgressUpdates(totalDuration, numberOfProgressSteps);
122 | }
123 | }, [progressType, state.value, totalDuration, numberOfProgressSteps]);
124 |
125 | const handleClick = () => {
126 | send({ type: "click" });
127 | onClick?.();
128 | };
129 |
130 | const handleComplete = () => {
131 | send({ type: "complete" });
132 | onComplete?.();
133 | };
134 |
135 | const handleError = (error: Error) => {
136 | onError?.(error);
137 | };
138 |
139 | const prefixIcon = ;
140 | const buttonLabel = (
141 | Export
142 | );
143 | const progressBar = (
144 |
145 | );
146 | const successIcon = ;
147 |
148 | /*
149 | * TODO: Get custom hex colors working
150 | * The challenge is that Tailwind requires the full string
151 | * to exist somewhere at build time, so we can't just
152 | * interpolate the color class into the string.
153 | const bgColor =
154 | isTailwindColorClassOrHexColor(successColorClass || "") === "tailwind"
155 | ? `[&>*>*]:bg-${successColorClass}`
156 | : `[&>*>*]:bg-[${successColorClass}]`;
157 | */
158 | const bgColor = `[&>*>*]:bg-${successColorClass}`;
159 |
160 | const bgColorClass = () => {
161 | if (!successColorClass || state.context.progress < 100)
162 | return "[&>*>*]:bg-primary-900";
163 | if (state.context.progress >= 100) {
164 | return bgColor;
165 | }
166 | };
167 |
168 | return (
169 | <>
170 |
171 |
179 | {state.matches("idle") && (
180 |
181 |
182 | {prefixIcon}
183 |
184 | {buttonLabel}
185 |
186 | )}
187 | {state.matches("inProgress") && (
188 |
191 | {progressBar}
192 |
193 | )}
194 | {state.matches("success") && (
195 |
196 | {successIcon}
197 |
198 | )}
199 | {state.matches("successFadeOut") && (
200 | {successIcon}
201 | )}
202 |
203 |
204 |
205 | Type: {" "}
206 | {progressType}
207 |
208 |
209 | State: {" "}
210 | {state.value}
211 |
212 |
213 | Progress: {" "}
214 | {state.context.progress}
215 |
216 |
217 |
218 | >
219 | );
220 | };
221 |
222 | export default ProgressButton;
223 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ProgressButton Component
2 | 
3 | 
4 |
5 |
6 | Created by [@redman](https://x.com/redman)
7 |
8 | If you like this, you'll love [Convex](https://github.com/get-convex)!
9 |
10 | ## Overview
11 |
12 | The `ProgressButton` component is a versatile and customizable button that visually indicates progress through either manual or automatic updates. It's built with React and leverages the XState library to manage its state machine.
13 |
14 | This component builds upon the button provided by [shadcn/ui](https://ui.shadcn.com/docs/components/button), making it perfect for use cases where you need to show progress, such as file uploads, form submissions, or any asynchronous operations.
15 |
16 | https://github.com/tomredman/ProgressButton/assets/4225378/c3f7a3cd-e2c7-401e-8e51-8ce9985a232c
17 |
18 | ## Features
19 |
20 | - **Manual and Automatic Progress**: Supports both manual progress updates and automatic progress simulation.
21 | - **State Management**: Utilizes XState for robust and scalable state management.
22 | - **Customizable**: Offers customization options for success color, progress type, and duration.
23 | - **User Feedback**: Provides visual feedback for different states: idle, in-progress, success, and error.
24 |
25 | ## State Machine
26 |
27 | The `ProgressButton` component uses an XState state machine to manage its different states and transitions. Here is a description of the state machine:
28 |
29 | ### State Machine Definition
30 |
31 | ```typescript
32 | import { assign, setup } from "xstate";
33 |
34 | const progressButtonMachine = setup({
35 | types: {
36 | context: {} as {
37 | progress: number;
38 | },
39 | events: {} as
40 | | { type: "click" }
41 | | { type: "complete" }
42 | | { type: "setProgress"; progress: number },
43 | },
44 | }).createMachine({
45 | context: {
46 | progress: 0,
47 | },
48 | id: "progressButton",
49 | initial: "idle",
50 |
51 | states: {
52 | idle: {
53 | on: { click: "inProgress" },
54 | },
55 | inProgress: {
56 | on: {
57 | setProgress: {
58 | actions: assign(({ event }) => {
59 | return {
60 | progress: event.progress,
61 | };
62 | }),
63 | },
64 | complete: "success",
65 | },
66 | },
67 | success: {
68 | after: {
69 | 1500: "successFadeOut", // Transition to 'successFadeOut' after 1500 ms
70 | },
71 | },
72 | successFadeOut: {
73 | after: {
74 | 10: "idle", // Transition to 'idle' after 10 ms
75 | },
76 | },
77 | },
78 | });
79 |
80 | export { progressButtonMachine };
81 | ```
82 |
83 | ### States and Transitions
84 |
85 | - **idle**: Initial state. The button is waiting for a click.
86 | - `on: { click: "inProgress" }`: Transitions to the `inProgress` state on a click event.
87 | - **inProgress**: The button is actively showing progress.
88 | - `on: { setProgress: { actions: assign(({ event }) => { return { progress: event.progress }; }) }}`: Updates the progress based on the `setProgress` event.
89 | - `on: { complete: "success" }`: Transitions to the `success` state when the progress completes.
90 | - **success**: The button has successfully completed its progress.
91 |
92 | - `after: { 1500: "successFadeOut" }`: Automatically transitions to the `successFadeOut` state after 1500 milliseconds.
93 |
94 | - **successFadeOut**: The success state is fading out.
95 | - `after: { 10: "idle" }`: Automatically transitions back to the `idle` state after 10 milliseconds, ready for a new interaction.
96 |
97 | ## Installation
98 |
99 | To install the `ProgressButton` component, you need to have React, XState, Tailwind and a few other packages set up in your project. If you haven't installed these dependencies, you can do so with the following commands:
100 |
101 | ```bash
102 | npm install && npm run dev
103 | ```
104 |
105 | ## Usage
106 |
107 | Here's a basic example of how to use the `ProgressButton` component:
108 |
109 | ```jsx
110 | import React from "react";
111 | import ProgressButton from "./ProgressButton";
112 |
113 | const MyComponent = () => {
114 | const handleButtonClick = () => {
115 | console.log("Button clicked");
116 | };
117 |
118 | const handleComplete = () => {
119 | console.log("Progress complete");
120 | };
121 |
122 | const handleError = (error) => {
123 | console.error("An error occurred:", error);
124 | };
125 |
126 | return (
127 |
128 |
Progress Button Example
129 |
138 |
139 | );
140 | };
141 |
142 | export default MyComponent;
143 | ```
144 |
145 | ## Props
146 |
147 | The `ProgressButton` component accepts the following props:
148 |
149 | ### Base Props
150 |
151 | | Prop | Type | Default | Description |
152 | | ------------------- | ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
153 | | `successColorClass` | `string` | `undefined` | Tailwind color class for the button's success color. Must be in the format `red-500` with no prefix (e.g., NOT `bg-red-500`) |
154 | | `onClick` | `function` | `undefined` | Callback function triggered when the button is clicked. |
155 | | `onComplete` | `function` | `undefined` | Callback function triggered when progress completes. |
156 | | `onError` | `function` | `undefined` | Callback function triggered when an error occurs. |
157 |
158 | ### Manual Progress Props
159 |
160 | | Prop | Type | Default | Description |
161 | | -------------- | -------- | -------- | ------------------------------------ |
162 | | `progressType` | `string` | `manual` | Set to "manual" for manual progress. |
163 | | `progress` | `number` | `0` | Current progress value (0 to 100). |
164 |
165 | ### Automatic Progress Props
166 |
167 | | Prop | Type | Default | Description |
168 | | ----------------------- | -------- | ----------- | ----------------------------------------------------------------------- |
169 | | `progressType` | `string` | `automatic` | Set to "automatic" for automatic progress simulation. |
170 | | `totalDuration` | `number` | `5000` | Total duration for the automatic progress in milliseconds. |
171 | | `numberOfProgressSteps` | `number` | `5` | Number of steps to divide the total duration into for progress updates. |
172 |
173 | ## Customization
174 |
175 | ### Success Color
176 |
177 | The `successColorClass` prop allows you to customize the success color of the button. You can use any Tailwind CSS color in the format `cyan-500`.
178 |
179 | Tailwind only includes colors used in the code in the final build, therefore every color class must exist as a complete string or be listed in the Tailwind config's [safelist](https://tailwindcss.com/docs/content-configuration#safelisting-classes) to be available.
180 |
181 | To get around this and let you use any default Tailwind class, there is a script that generates a file that lists every available option. It's found in `scripts/generateTailwindColorNames.cjs` and can be run with `node generateTailwindColorNames.cjs`. This will generate a file `tailwind-bg-classes.js` that is then included in Tailwind config `content` which lists all the classes we might need.
182 |
183 | This file is populated with classes like `[&>*>*]:bg-blue-700`: **the magical selector `[&>*>*]` is specific to shadcn/ui progress bar component!** It lets us select the bar itself and color it.
184 |
185 | I have not found a way to let you choose arbitrary values like `bg-[#ff0000]` as this string literal – not interpolated – needs to be present in the source somewhere. Listing all possible hex colors would require 16,777,216 string literals. There's got to be a better way, but I haven't found it yet.
186 |
187 | ```
188 | // tailwind.config.js
189 | /** @type {import('tailwindcss').Config} */
190 | module.exports = {
191 | ...
192 | content: [
193 | ...
194 | "./tailwind-bg-classes.js",
195 | ],
196 | ...
197 | ```
198 |
199 | ```jsx
200 | // THIS IS GOOD
201 |
202 |
203 | // THIS IS BAD
204 |
205 | ```
206 |
207 | ### Manual Progress
208 |
209 | For manual progress updates, use the `progress` prop to set the current progress.
210 |
211 | **When the progress gets to or exceeds 100, the button will transition to the success state, and then ultimately to the completed/idle state.**
212 |
213 | ```jsx
214 |
215 | ```
216 |
217 | ### Automatic Progress
218 |
219 | For automatic progress simulation, you can configure the total duration and the number of progress steps.
220 |
221 | The steps and the duration between them are randomized for better UX, but will always add up to the values set here.
222 |
223 | ```jsx
224 |
229 | ```
230 |
231 | ## States
232 |
233 | The button goes through various states managed by XState:
234 |
235 | - `idle`: Initial state, waiting for user interaction.
236 | - `inProgress`: Progress is ongoing.
237 | - `success`: Progress completed successfully.
238 | - `successFadeOut`: Success state fading out.
239 | - `error`: An error occurred during progress.
240 |
241 | ## Contributing
242 |
243 | If you have suggestions for improving the `ProgressButton` component or find any bugs, please open an issue or submit a pull request. We welcome contributions from the community!
244 |
245 | ## License
246 |
247 | This project is licensed under the MIT License.
248 |
249 | ---
250 |
251 | By following the above guidelines, you can effectively utilize the `ProgressButton` component in your project to provide a visual representation of progress, enhancing the user experience.
252 |
--------------------------------------------------------------------------------
/src/images/vite.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | File:Vitejs-logo.svg - Wikimedia Commons
6 |
14 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
File : Vitejs-logo.svg
67 |
68 |
From Wikimedia Commons, the free media repository
69 |
70 |
71 |
72 |
73 |
Jump to navigation
74 |
Jump to search
75 |
80 |
Original file (SVG file, nominally 410 × 404 pixels, file size: 1 KB)
81 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
Navigation menu
203 |
204 |
205 |
222 |
223 |
224 |
225 |
242 |
243 |
244 |
269 |
270 |
271 |
272 |
273 |
290 |
291 |
292 |
317 |
318 |
319 |
336 |
337 |
338 |
339 |
340 |
421 |
422 |
423 |
424 |
449 |
450 |
451 |
452 |
453 |
--------------------------------------------------------------------------------