JSPM

  • Created
  • Published
  • Downloads 594
  • Score
    100M100P100Q93294F
  • License MIT

Set of UI components for common interaction elements in a SaaS app

Package Exports

  • hazo_ui
  • hazo_ui/package.json
  • hazo_ui/styles.css
  • hazo_ui/tailwind-preset
  • hazo_ui/test-harness

Readme

hazo_ui

A set of UI components for common interaction elements in a SaaS app.

Installation

npm install hazo_ui

Quick Setup (Required)

hazo_ui uses Tailwind CSS and CSS variables for styling. Follow these two steps:

Step 1: Import the CSS variables

Add to your app's entry point (e.g., layout.tsx, _app.tsx, or main.tsx):

import 'hazo_ui/styles.css';

Step 2: Configure Tailwind

Update your tailwind.config.ts:

import hazoUiPreset from 'hazo_ui/tailwind-preset';

export default {
  presets: [hazoUiPreset],
  content: [
    './src/**/*.{js,ts,jsx,tsx}',
    './node_modules/hazo_ui/dist/**/*.js',  // Required: scan hazo_ui components
  ],
  // ... your other config
};

That's it! The components will now render correctly with proper styling.

Note: If you already have shadcn/ui configured with CSS variables, you may skip Step 1 as the variables are compatible.

Important: Tailwind v4 Compatibility

If you're using Tailwind CSS v4, you must add the @source directive to ensure hazo_ui's Tailwind classes are compiled:

In your globals.css or main CSS file:

@import "tailwindcss";
@import "tw-animate-css";

/* REQUIRED for Tailwind v4: Enable scanning of hazo_ui components */
@source "../node_modules/hazo_ui/dist";

PostCSS (Tailwind v4):

// postcss.config.js
module.exports = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};

Why is this needed?

Tailwind v4 uses JIT compilation and only generates CSS for classes found in scanned files. By default, it doesn't scan node_modules/. Without the @source directive:

  • Hover states won't work
  • Colors will be missing
  • Layouts may break

The tw-animate-css import replaces the legacy tailwindcss-animate plugin (used by hazo_ui's accordion/collapse animations) for Tailwind v4 consumers.

Note: Tailwind v3 users do NOT need this directive — the content configuration plus tailwindcss-animate@^1.0.7 is sufficient.


Color Configuration

hazo_ui provides a centralized configuration system for customizing button and header colors across all components. You can set colors globally using set_hazo_ui_config() or override them per-component using props.

Global Configuration

Set default colors for all components in your app:

import { set_hazo_ui_config } from 'hazo_ui';

// Set custom colors globally (typically in your app's entry point)
set_hazo_ui_config({
  // Dialog headers
  header_background_color: '#1e3a8a',
  header_text_color: '#ffffff',

  // Submit/Apply buttons
  submit_button_background_color: '#10b981',
  submit_button_text_color: '#ffffff',

  // Cancel buttons
  cancel_button_border_color: '#6b7280',
  cancel_button_text_color: '#374151',

  // Clear/Delete buttons
  clear_button_border_color: '#ef4444',
  clear_button_text_color: '#ef4444',
});

Per-Component Override

Override colors for individual component instances using props:

<HazoUiMultiFilterDialog
  availableFields={fields}
  onFilterChange={handleFilterChange}
  // Color overrides (these take precedence over global config)
  headerBackgroundColor="#7c3aed"
  headerTextColor="#ffffff"
  submitButtonBackgroundColor="#f59e0b"
  submitButtonTextColor="#ffffff"
  cancelButtonBorderColor="#ec4899"
  cancelButtonTextColor="#ec4899"
/>

Available Color Properties

HazoUiConfig Interface:

interface HazoUiConfig {
  // Dialog Header Colors
  header_background_color?: string;
  header_text_color?: string;

  // Submit/Action Button Colors
  submit_button_background_color?: string;
  submit_button_text_color?: string;
  submit_button_hover_color?: string;

  // Cancel Button Colors
  cancel_button_background_color?: string;
  cancel_button_text_color?: string;
  cancel_button_border_color?: string;
  cancel_button_hover_color?: string;

  // Clear/Delete Button Colors
  clear_button_background_color?: string;
  clear_button_text_color?: string;
  clear_button_border_color?: string;
  clear_button_hover_color?: string;
}

Configuration Functions

set_hazo_ui_config(config: Partial<HazoUiConfig>)

  • Merges provided config with existing global config
  • Accepts partial config (you can set only the colors you want to customize)

get_hazo_ui_config(): Readonly<HazoUiConfig>

  • Returns the current global configuration (read-only)

reset_hazo_ui_config()

  • Resets all colors to default (undefined, using theme defaults)

Components Supporting Color Configuration

The following components support both global config and prop-level color overrides:

  • HazoUiDialog - via headerBackgroundColor, headerTextColor, accentColor props
  • HazoUiConfirmDialog - via accentColor, confirmButtonColor props
  • HazoUiMultiFilterDialog - via header/submit/cancel/clear color props
  • HazoUiMultiSortDialog - via header/submit/cancel/clear color props

Components

Component Overview

  • HazoUiMultiFilterDialog - A powerful dialog component for multi-field filtering with support for text, number, combobox, boolean, and date fields. Includes operator support, validation, and visual feedback.

  • HazoUiMultiSortDialog - A flexible dialog component for multi-field sorting with drag-and-drop reordering. Allows users to set sort priority and direction (ascending/descending) for multiple fields.

  • HazoUiFlexRadio - A flexible radio button/icon selection component with support for single and multi-selection modes, customizable layouts, and react-icons integration. Perfect for settings panels, preference selection, and option groups.

  • HazoUiPillRadio - Pill-shaped radio selection buttons with optional icons, customizable accent colors, size variants, and equal-width support. Ideal for action choices, status selection, and compact option groups.

  • HazoUiFlexInput - An enhanced input component with type validation, character restrictions, and error messaging. Supports numeric, alpha, email, and mixed input types with built-in validation and formatting guides.

  • HazoUiRte - A comprehensive rich text editor for email template generation with variable insertion support, file attachments, and full formatting controls. Built on Tiptap with support for tables, lists, images, and multiple view modes (HTML, Plain Text, Raw HTML).

  • HazoUiTextbox - A single-line input component with command pill support. Allows users to insert prefix-triggered commands (e.g., @mentions, /commands, #tags) that appear as interactive pills. Includes click-to-edit functionality for modifying or removing inserted commands.

  • HazoUiTextarea - A multi-line textarea component with command pill support. Similar to HazoUiTextbox but supports multi-line input with Shift+Enter for new lines and Cmd/Ctrl+Enter to submit. Features the same interactive pill editing capabilities.

  • HazoUiDialog - A standardized dialog component with customizable animations, sizes, and theming. Features header/body/footer layout, color customization props, multiple size variants, and distinct animation presets (zoom, slide, fade).

  • HazoUiConfirmDialog - A compact, opinionated confirmation dialog with accent top border, variant system (destructive, warning, info, success), async loading support, and configurable buttons. Perfect for delete confirmations, unsaved changes warnings, and simple acknowledgments.

  • HazoUiTable - A column-config-driven data table built on a shadcn Table primitive family. Sortable headers, debounced search, multi-column filter / sort dialogs, pagination, row click (mouse + keyboard), loading / empty / no-results states, and a card-per-row mobile fallback. Optional server-side onLoad.

  • Drawer - A vaul-backed bottom sheet primitive for mobile UIs. Pair with useMediaQuery to swap between Dialog and Drawer based on viewport width.

  • Chart Primitives (v2.17.0) - Pure-SVG chart components for KPI cards and trend dashboards: Sparkline, InverseSparkline, LineChart, MultiLineChart, StackedBars, and DateRangeSelector. Zero third-party chart deps. Gap-aware paths, hover tooltips, and per-series endpoint markers built in.

State Primitives (v2.10.0)

Lightweight, opinionated components for the four ubiquitous async states: loading, empty, error, and success.

  • Skeleton family (Skeleton, SkeletonCircle, SkeletonBar, SkeletonRect, SkeletonGroup) - Shimmer placeholders. Respects prefers-reduced-motion.
  • EmptyState - Icon + title + description + CTA for empty lists, search misses, no-data screens.
  • ErrorBanner - Inline warning or error strip with optional title, action button, and dismiss.
  • ErrorPage - Full-page error fallback with title, description, error code tag, correlation id, and CTAs.
  • LoadingTimeout - Wraps a loading region and escalates messaging at 5s / 15s / 30s thresholds before showing a retry banner.
  • ProgressiveImage - Three-stage image render: grey placeholder → blurred LQIP → sharp final image.
  • HazoUiToaster + toast helpers - sonner-backed toaster with successToast() and errorToast() imperative helpers.
  • useLoadingState hook - { isLoading, setLoading, withLoading } with an async wrapper.
  • useErrorDisplay hook - Passive { error, setError, clearError } that coerces Error instances to strings.

shadcn/ui Primitive Re-exports

All shadcn/ui base components are re-exported from hazo_ui, so sibling hazo_* packages (and consumers) can import UI primitives from a single source without installing shadcn/ui separately:

import { Button, Input, Label, Checkbox, Switch, Tabs, TabsList, TabsTrigger, TabsContent } from 'hazo_ui';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from 'hazo_ui';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from 'hazo_ui';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from 'hazo_ui';
import { HoverCard, HoverCardTrigger, HoverCardContent } from 'hazo_ui';
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from 'hazo_ui';
import { Popover, PopoverTrigger, PopoverContent } from 'hazo_ui';
import { RadioGroup, RadioGroupItem } from 'hazo_ui';
import { Calendar, Textarea } from 'hazo_ui';

Animation Utilities

Shared animation presets and resolver function are exported for use in custom dialog implementations:

import { ANIMATION_PRESETS, resolve_animation_classes } from 'hazo_ui';
import type { AnimationPreset } from 'hazo_ui';

HazoUiMultiFilterDialog

A powerful, flexible dialog component for multi-field filtering with support for various input types. Perfect for table headers, grid views, or any interface where users need to apply multiple filters simultaneously.

HazoUiMultiFilterDialog - Filter Button with Active Filters Tooltip

HazoUiMultiFilterDialog - Dialog with Multiple Filters

HazoUiMultiFilterDialog - Calendar Date Picker

HazoUiMultiFilterDialog - Filter Output Example

Features

  • Multiple Field Types: Supports text, number, combobox (select), boolean, and date fields
  • Operator Support: Number and date fields support comparison operators (equals, greater than, less than, etc.)
  • Dynamic Field Addition: Users can add and remove filter fields dynamically
  • Field Validation: Built-in validation for text length, number ranges, and decimal precision
  • Visual Feedback: Tooltip shows active filters when hovering over the filter button
  • Clear All Button: Quickly clear all filters at once
  • Responsive Design: Works seamlessly on mobile and desktop devices
  • TypeScript Support: Fully typed with TypeScript interfaces
  • Accessible: Built with accessibility in mind using Radix UI primitives

Field Types

  1. Text Fields

    • Configurable min/max length
    • Real-time validation
  2. Number Fields

    • Min/max value constraints
    • Optional decimal support with precision control
    • Comparison operators: equals, not equals, greater than, less than, greater or equal, less or equal
  3. Combobox Fields

    • Dropdown selection from predefined options
    • Searchable field selection
  4. Boolean Fields

    • Custom true/false labels
    • Simple toggle selection
  5. Date Fields

    • Calendar picker interface
    • Comparison operators for date ranges
    • Formatted display (e.g., "Nov 6, 2025")

Usage

import { HazoUiMultiFilterDialog, type FilterField, type FilterConfig } from 'hazo_ui';
import { useState } from 'react';

function DataTable() {
  const [filters, setFilters] = useState<FilterConfig[]>([]);

  // Define available filter fields
  const availableFields: FilterField[] = [
    {
      value: "name",
      label: "Name",
      type: "text",
      textConfig: {
        minLength: 2,
        maxLength: 50,
      },
    },
    {
      value: "age",
      label: "Age",
      type: "number",
      numberConfig: {
        min: 0,
        max: 120,
        allowDecimal: false,
      },
    },
    {
      value: "price",
      label: "Price",
      type: "number",
      numberConfig: {
        min: 0,
        max: 10000,
        allowDecimal: true,
        decimalLength: 2,
      },
    },
    {
      value: "status",
      label: "Status",
      type: "combobox",
      comboboxOptions: [
        { label: "Active", value: "active" },
        { label: "Inactive", value: "inactive" },
        { label: "Pending", value: "pending" },
        { label: "Completed", value: "completed" },
      ],
    },
    {
      value: "is_verified",
      label: "Verified",
      type: "boolean",
      booleanLabels: {
        trueLabel: "Yes",
        falseLabel: "No",
      },
    },
    {
      value: "created_date",
      label: "Created Date",
      type: "date",
    },
  ];

  // Handle filter changes
  const handleFilterChange = (filterConfig: FilterConfig[]) => {
    setFilters(filterConfig);
    // Apply filters to your data
    console.log('Applied filters:', filterConfig);
  };

  return (
    <div>
      <HazoUiMultiFilterDialog
        availableFields={availableFields}
        onFilterChange={handleFilterChange}
        initialFilters={filters}
        title="Filter Products"           // Optional: customize dialog title (default: "Filter")
        description="Filter by product attributes"  // Optional: customize description
      />
      {/* Your table/grid component */}
    </div>
  );
}

Props

Prop Type Required Default Description
availableFields FilterField[] Yes - Array of field definitions for filtering
onFilterChange (filters: FilterConfig[]) => void Yes - Callback when filters are applied
initialFilters FilterConfig[] No [] Initial filter configuration
title string No "Filter" Dialog title text
description string No "Add multiple fields to filter by..." Dialog description text

Example Input

// Available fields configuration
const availableFields: FilterField[] = [
  {
    value: "name",
    label: "Name",
    type: "text",
    textConfig: {
      minLength: 2,
      maxLength: 50,
    },
  },
  {
    value: "age",
    label: "Age",
    type: "number",
    numberConfig: {
      min: 0,
      max: 120,
      allowDecimal: false,
    },
  },
  {
    value: "status",
    label: "Status",
    type: "combobox",
    comboboxOptions: [
      { label: "Active", value: "active" },
      { label: "Inactive", value: "inactive" },
    ],
  },
];

// Initial filters (optional)
const initialFilters: FilterConfig[] = [
  {
    field: "name",
    value: "John",
  },
  {
    field: "age",
    operator: "greater_than",
    value: 25,
  },
];

Expected Output

When users apply filters, the onFilterChange callback receives an array of FilterConfig objects:

// Example output when user applies filters:
[
  {
    field: "name",
    value: "John"
  },
  {
    field: "age",
    operator: "greater_than",
    value: 25
  },
  {
    field: "status",
    value: "active"
  },
  {
    field: "is_verified",
    value: true
  },
  {
    field: "created_date",
    operator: "greater_equal",
    value: Date // JavaScript Date object
  }
]

HazoUiMultiSortDialog

A powerful dialog component for multi-field sorting with drag-and-drop reordering. Allows users to select multiple fields for sorting, reorder them by priority, and set ascending/descending direction for each field.

HazoUiMultiSortDialog - Sort Button with Active Sorts Tooltip

HazoUiMultiSortDialog - Dialog with Multiple Sort Fields

HazoUiMultiSortDialog - Drag and Drop Reordering

HazoUiMultiSortDialog - Sort Output Example

Features

  • Drag-and-Drop Reordering: Intuitively reorder sort fields by dragging them
  • Multiple Sort Fields: Add multiple fields to sort by with priority ordering
  • Direction Toggle: Switch between ascending and descending for each field
  • Visual Feedback: Drag handle with grip icon, opacity changes during drag
  • Clear All Button: Quickly clear all sort fields at once
  • Tooltip Display: Shows active sort configuration when hovering over the sort button
  • Keyboard Accessible: Full keyboard navigation support for drag and drop
  • Responsive Design: Works seamlessly on mobile and desktop devices
  • TypeScript Support: Fully typed with TypeScript interfaces
  • Accessible: Built with accessibility in mind using Radix UI primitives

How It Works

  1. Adding Sort Fields: Click the "Add field" button to select from available fields
  2. Reordering: Drag fields using the grip icon (⋮⋮) to change their priority
  3. Changing Direction: Toggle the switch next to each field to change between ascending (A→Z, 0→9) and descending (Z→A, 9→0)
  4. Removing Fields: Click the trash icon to remove a field from sorting
  5. Applying Sorts: Click "Apply" to save the sort configuration
  6. Clearing All: Click "Clear All" to remove all sort fields at once

The component returns an array of sort configurations in priority order, where the first item is the primary sort field, second is secondary, and so on.

Usage

import { HazoUiMultiSortDialog, type SortField, type SortConfig } from 'hazo_ui';
import { useState } from 'react';

function DataTable() {
  const [sorts, setSorts] = useState<SortConfig[]>([]);

  // Define available sort fields
  const availableFields: SortField[] = [
    {
      value: "name",
      label: "Name",
    },
    {
      value: "age",
      label: "Age",
    },
    {
      value: "price",
      label: "Price",
    },
    {
      value: "status",
      label: "Status",
    },
    {
      value: "created_date",
      label: "Created Date",
    },
  ];

  // Handle sort changes
  const handleSortChange = (sortConfig: SortConfig[]) => {
    setSorts(sortConfig);
    // Apply sorts to your data
    console.log('Applied sorts:', sortConfig);
    // Sort your data based on the configuration
    // First item is primary sort, second is secondary, etc.
  };

  return (
    <div>
      <HazoUiMultiSortDialog
        availableFields={availableFields}
        onSortChange={handleSortChange}
        initialSortFields={sorts}
        title="Sort Products"           // Optional: customize dialog title (default: "Sort")
        description="Drag to reorder sort priority"  // Optional: customize description
      />
      {/* Your table/grid component */}
    </div>
  );
}

Props

Prop Type Required Default Description
availableFields SortField[] Yes - Array of field definitions for sorting
onSortChange (sorts: SortConfig[]) => void Yes - Callback when sorts are applied
initialSortFields SortConfig[] No [] Initial sort configuration
title string No "Sort" Dialog title text
description string No "Add multiple fields to sort by..." Dialog description text

Example Input

// Available sort fields configuration
const availableFields: SortField[] = [
  {
    value: "name",
    label: "Name",
  },
  {
    value: "price",
    label: "Price",
  },
  {
    value: "created_date",
    label: "Created Date",
  },
];

// Initial sort fields (optional)
const initialSortFields: SortConfig[] = [
  {
    field: "name",
    direction: "asc",
  },
  {
    field: "price",
    direction: "desc",
  },
];

Expected Output

When users apply sorts, the onSortChange callback receives an array of SortConfig objects in priority order:

// Example output when user applies sorts:
[
  {
    field: "name",
    direction: "asc"      // Primary sort: Name ascending
  },
  {
    field: "price",
    direction: "desc"     // Secondary sort: Price descending
  },
  {
    field: "created_date",
    direction: "desc"     // Tertiary sort: Created Date descending
  }
]

Important: The order of the array matters! The first item is the primary sort field, the second is secondary, and so on. This allows for multi-level sorting (e.g., sort by name first, then by price for items with the same name).

TypeScript Interfaces

interface SortField {
  value: string;          // Unique identifier for the field
  label: string;          // Display label
}

interface SortConfig {
  field: string;          // Field identifier
  direction: 'asc' | 'desc';  // Sort direction: 'asc' for ascending, 'desc' for descending
}

Implementing the Sort Logic

Here's an example of how to apply the sort configuration to your data:

function applySorts(data: any[], sortConfigs: SortConfig[]): any[] {
  if (sortConfigs.length === 0) return data;

  return [...data].sort((a, b) => {
    for (const sortConfig of sortConfigs) {
      const aValue = a[sortConfig.field];
      const bValue = b[sortConfig.field];
      
      let comparison = 0;
      
      // Handle different value types
      if (typeof aValue === 'string' && typeof bValue === 'string') {
        comparison = aValue.localeCompare(bValue);
      } else if (aValue instanceof Date && bValue instanceof Date) {
        comparison = aValue.getTime() - bValue.getTime();
      } else {
        comparison = (aValue ?? 0) - (bValue ?? 0);
      }
      
      // Apply direction
      if (sortConfig.direction === 'desc') {
        comparison = -comparison;
      }
      
      // If values are different, return the comparison
      // Otherwise, continue to next sort field
      if (comparison !== 0) {
        return comparison;
      }
    }
    
    return 0; // All sort fields are equal
  });
}

// Usage
const sortedData = applySorts(originalData, sorts);

HazoUiFlexRadio

A flexible radio button/icon selection component with support for single and multi-selection modes, customizable layouts, and react-icons integration. Perfect for settings panels, preference selection, and option groups.

Features

  • Single & Multi-Selection: Support for both single selection (radio) and multi-selection (checkbox) modes
  • Layout Options: Horizontal (default) or vertical arrangement of options
  • Style Variants: Radio button style or icon-only button style
  • Icon Support: Integration with react-icons library (supports fa, md, hi, bi, ai, bs, fi, io, ri, tb icon sets)
  • Label Control: Option to show or hide labels
  • Tooltips: Hover tooltips with 1-second delay showing option labels
  • Responsive Design: Adaptive spacing and sizing for different screen sizes
  • Controlled Component: Fully controlled component with value/onChange pattern
  • TypeScript Support: Fully typed with TypeScript interfaces
  • Accessible: Built with accessibility in mind using Radix UI primitives

Props

interface HazoUiFlexRadioItem {
  label: string;                    // Display label for the option
  value: string;                    // Unique value identifier
  icon_selected?: string;           // Icon name when selected (e.g., "FaHome")
  icon_unselected?: string;         // Icon name when unselected (e.g., "FaRegHome")
}

interface HazoUiFlexRadioProps {
  layout?: 'horizontal' | 'vertical';  // Layout direction (default: 'horizontal')
  style?: 'radio' | 'icons';           // Display style (default: 'radio')
  display_label?: boolean;              // Show/hide labels (default: true)
  icon_set?: string;                    // Icon set package name (e.g., 'fa', 'md')
  data: HazoUiFlexRadioItem[];          // Array of options
  selection: 'single' | 'multi';       // Selection mode
  value: string | string[];             // Controlled value (string for single, array for multi)
  onChange: (value: string | string[]) => void;  // Change handler
  className?: string;                   // Additional CSS classes
}

Usage

Basic Single Selection (Radio Style)

import { HazoUiFlexRadio, type HazoUiFlexRadioItem } from 'hazo_ui';
import { useState } from 'react';

function SettingsPanel() {
  const [selectedOption, setSelectedOption] = useState<string>('option1');

  const options: HazoUiFlexRadioItem[] = [
    { label: 'Option 1', value: 'option1' },
    { label: 'Option 2', value: 'option2' },
    { label: 'Option 3', value: 'option3' },
    { label: 'Option 4', value: 'option4' },
  ];

  return (
    <HazoUiFlexRadio
      data={options}
      value={selectedOption}
      onChange={setSelectedOption}
      selection="single"
      layout="horizontal"
      style="radio"
      display_label={true}
    />
  );
}

Icon Style with React Icons

import { HazoUiFlexRadio, type HazoUiFlexRadioItem } from 'hazo_ui';
import { useState } from 'react';

function IconSelector() {
  const [selectedIcon, setSelectedIcon] = useState<string>('home');

  const iconOptions: HazoUiFlexRadioItem[] = [
    {
      label: 'Home',
      value: 'home',
      icon_selected: 'FaHome',
      icon_unselected: 'FaRegHome',
    },
    {
      label: 'User',
      value: 'user',
      icon_selected: 'FaUser',
      icon_unselected: 'FaRegUser',
    },
    {
      label: 'Settings',
      value: 'settings',
      icon_selected: 'FaCog',
      icon_unselected: 'FaRegCog',
    },
  ];

  return (
    <HazoUiFlexRadio
      data={iconOptions}
      value={selectedIcon}
      onChange={setSelectedIcon}
      selection="single"
      layout="horizontal"
      style="icons"
      display_label={true}
      icon_set="fa"  // FontAwesome icons
    />
  );
}

Multi-Selection Mode

import { HazoUiFlexRadio, type HazoUiFlexRadioItem } from 'hazo_ui';
import { useState } from 'react';

function MultiSelectExample() {
  const [selectedOptions, setSelectedOptions] = useState<string[]>(['option1', 'option3']);

  const options: HazoUiFlexRadioItem[] = [
    { label: 'Option 1', value: 'option1' },
    { label: 'Option 2', value: 'option2' },
    { label: 'Option 3', value: 'option3' },
    { label: 'Option 4', value: 'option4' },
  ];

  return (
    <HazoUiFlexRadio
      data={options}
      value={selectedOptions}
      onChange={setSelectedOptions}
      selection="multi"
      layout="horizontal"
      style="radio"
      display_label={true}
    />
  );
}

Vertical Layout with Icons Only (No Labels)

import { HazoUiFlexRadio, type HazoUiFlexRadioItem } from 'hazo_ui';
import { useState } from 'react';

function VerticalIconSelector() {
  const [selected, setSelected] = useState<string>('favorite');

  const options: HazoUiFlexRadioItem[] = [
    {
      label: 'Favorite',
      value: 'favorite',
      icon_selected: 'MdFavorite',
      icon_unselected: 'MdFavoriteBorder',
    },
    {
      label: 'Star',
      value: 'star',
      icon_selected: 'MdStar',
      icon_unselected: 'MdStarBorder',
    },
  ];

  return (
    <HazoUiFlexRadio
      data={options}
      value={selected}
      onChange={setSelected}
      selection="single"
      layout="vertical"
      style="icons"
      display_label={false}  // Hide labels, show only icons
      icon_set="md"  // Material Design icons
    />
  );
}

Supported Icon Sets

The component supports the following react-icons packages:

  • fa - FontAwesome (react-icons/fa)
  • md - Material Design (react-icons/md)
  • hi - Heroicons (react-icons/hi)
  • bi - Bootstrap Icons (react-icons/bi)
  • ai - Ant Design Icons (react-icons/ai)
  • bs - Bootstrap Icons (react-icons/bs)
  • fi - Feather Icons (react-icons/fi)
  • io / io5 - Ionicons (react-icons/io5)
  • ri - Remix Icon (react-icons/ri)
  • tb - Tabler Icons (react-icons/tb)

Expected Output

Single Selection Mode:

// onChange receives a string value
'onChange' => (value: string) => {
  // value will be the selected option's value, e.g., "option1"
}

Multi-Selection Mode:

// onChange receives an array of string values
'onChange' => (value: string[]) => {
  // value will be an array of selected values, e.g., ["option1", "option3"]
}

HazoUiPillRadio

Pill-shaped radio selection buttons with optional icons, customizable accent colors per pill, size variants, and equal-width support. Ideal for action choices, status selection, and compact option groups.

Features

  • Pill Design: Rounded-full pill buttons with border, selected state shows accent color tint
  • Icon Support: Integration with react-icons library (same icon sets as HazoUiFlexRadio)
  • Custom Colors: Each pill can have its own accent color for the selected state
  • Size Variants: Three sizes — sm, md (default), lg
  • Layout Options: Horizontal (default) or vertical arrangement
  • Equal Width: Optional equal_width prop makes all pills the same width
  • Auto-fit Vertical: Vertical layout auto-fits to the widest pill instead of stretching full-width
  • Tooltips: Hover tooltips with 1-second delay
  • Accessible: Uses role="radiogroup" and role="radio" with aria-checked

Props

interface HazoUiPillRadioItem {
  label: string;       // Text displayed in the pill
  value: string;       // Unique value identifier
  icon?: string;       // react-icons icon name (e.g., "FaFolder")
  color?: string;      // Accent color when selected (default: "#3b82f6" blue)
}

interface HazoUiPillRadioProps {
  layout?: 'horizontal' | 'vertical';  // Layout direction (default: 'horizontal')
  icon_set?: string;                    // Icon set package name (default: 'fa')
  data: HazoUiPillRadioItem[];          // Array of pill options
  value: string;                        // Controlled selected value
  onChange: (value: string) => void;     // Change handler
  className?: string;                   // Additional CSS classes
  pill_size?: 'sm' | 'md' | 'lg';      // Pill size variant (default: 'md')
  equal_width?: boolean;                // Make all pills equal width (default: false)
}

Usage

Basic Horizontal Pills

import { HazoUiPillRadio, type HazoUiPillRadioItem } from 'hazo_ui';
import { useState } from 'react';

function ResponseSelector() {
  const [selected, setSelected] = useState<string>('');

  const options: HazoUiPillRadioItem[] = [
    { value: 'upload', label: 'Upload corrected document', icon: 'FaFolder' },
    { value: 'comment', label: 'Provide comment', icon: 'FaComment' },
  ];

  return (
    <HazoUiPillRadio
      data={options}
      value={selected}
      onChange={setSelected}
    />
  );
}

Vertical Layout

<HazoUiPillRadio
  data={options}
  value={selected}
  onChange={setSelected}
  layout="vertical"
/>

Custom Colors per Pill

const priority_options: HazoUiPillRadioItem[] = [
  { value: 'low', label: 'Low', icon: 'FaArrowDown', color: '#22c55e' },
  { value: 'medium', label: 'Medium', icon: 'FaMinus', color: '#f59e0b' },
  { value: 'high', label: 'High', icon: 'FaArrowUp', color: '#ef4444' },
];

<HazoUiPillRadio
  data={priority_options}
  value={selected}
  onChange={setSelected}
/>

Equal Width Pills

<HazoUiPillRadio
  data={options}
  value={selected}
  onChange={setSelected}
  equal_width
/>

Different Icon Set and Size

<HazoUiPillRadio
  data={options}
  value={selected}
  onChange={setSelected}
  icon_set="md"
  pill_size="lg"
/>

HazoUiFlexInput

An enhanced input component with type validation, character restrictions, and error messaging. Extends shadcn Input component with additional validation props for numeric, alpha, email, and mixed input types.

Features

  • Multiple Input Types: Supports mixed (text), numeric, alpha (letters only), and email input types
  • Real-time Character Filtering: Automatically prevents invalid characters from being entered (e.g., numbers in alpha fields)
  • Validation on Blur: Validates input when the field loses focus and displays error messages
  • Numeric Constraints: Min/max value validation and decimal precision control
  • Length Constraints: Configurable minimum and maximum character lengths
  • Regex Validation: Custom regex pattern support for complex validation rules
  • Format Guide: Optional helper text displayed below the input
  • Error Messaging: Clear error messages displayed when validation fails
  • Controlled & Uncontrolled: Supports both controlled and uncontrolled usage patterns
  • TypeScript Support: Fully typed with TypeScript interfaces
  • Accessible: Built with accessibility in mind using shadcn/ui components

Input Types

  1. Mixed (text)

    • Allows any characters
    • Supports length constraints (min/max)
    • Supports regex validation
  2. Numeric

    • Only allows numbers, decimal point, and minus sign
    • Supports min/max value constraints
    • Configurable decimal precision (including integers with 0 decimals)
    • Automatically filters out non-numeric characters
  3. Alpha

    • Only allows letters and spaces
    • Automatically filters out numbers and special characters
    • Supports length constraints
  4. Email

    • Validates email format on blur
    • Uses standard email regex pattern

Props

interface HazoUiFlexInputProps extends Omit<InputProps, "type"> {
  input_type?: "mixed" | "numeric" | "email" | "alpha";  // Input type (default: "mixed")
  text_len_min?: number;                                  // Minimum character length
  text_len_max?: number;                                  // Maximum character length
  num_min?: number;                                       // Minimum numeric value
  num_max?: number;                                       // Maximum numeric value
  regex?: string | RegExp;                                // Custom regex pattern
  num_decimals?: number;                                  // Number of decimal places allowed
  format_guide?: string;                                  // Helper text displayed below input
  format_guide_info?: boolean;                            // Show format guide (default: false)
}

Usage

Basic Mixed Input

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function BasicForm() {
  const [value, setValue] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="mixed"
      placeholder="Enter text..."
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

Numeric Input with Constraints

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function PriceInput() {
  const [price, setPrice] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="numeric"
      placeholder="Enter price (0-100)..."
      num_min={0}
      num_max={100}
      num_decimals={2}
      format_guide="Enter a number between 0 and 100 with up to 2 decimal places"
      format_guide_info={true}
      value={price}
      onChange={(e) => setPrice(e.target.value)}
    />
  );
}

Integer Input (No Decimals)

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function AgeInput() {
  const [age, setAge] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="numeric"
      placeholder="Enter age (1-120)..."
      num_min={1}
      num_max={120}
      num_decimals={0}
      format_guide="Enter a whole number between 1 and 120"
      format_guide_info={true}
      value={age}
      onChange={(e) => setAge(e.target.value)}
    />
  );
}

Alpha Input (Letters Only)

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function NameInput() {
  const [name, setName] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="alpha"
      placeholder="Enter name (letters only)..."
      format_guide="Only letters and spaces are allowed"
      format_guide_info={true}
      value={name}
      onChange={(e) => setName(e.target.value)}
    />
  );
}

Email Input with Validation

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function EmailForm() {
  const [email, setEmail] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="email"
      placeholder="Enter email address..."
      format_guide="Enter a valid email address (e.g., user@example.com)"
      format_guide_info={true}
      value={email}
      onChange={(e) => setEmail(e.target.value)}
    />
  );
}

Mixed Input with Length Constraints

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function UsernameInput() {
  const [username, setUsername] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="mixed"
      placeholder="Enter username (5-20 characters)..."
      text_len_min={5}
      text_len_max={20}
      format_guide="Must be between 5 and 20 characters"
      format_guide_info={true}
      value={username}
      onChange={(e) => setUsername(e.target.value)}
    />
  );
}

Input with Regex Validation

import { HazoUiFlexInput } from 'hazo_ui';
import { useState } from 'react';

function PhoneInput() {
  const [phone, setPhone] = useState<string>("");

  return (
    <HazoUiFlexInput
      input_type="mixed"
      placeholder="Enter phone number (XXX-XXX-XXXX)..."
      regex={/^\d{3}-\d{3}-\d{4}$/}
      format_guide="Format: XXX-XXX-XXXX (e.g., 123-456-7890)"
      format_guide_info={true}
      value={phone}
      onChange={(e) => setPhone(e.target.value)}
    />
  );
}

Validation Behavior

  • Character Filtering: For numeric and alpha types, invalid characters are automatically filtered out as the user types
  • Validation Timing: Validation occurs when the input loses focus (onBlur event)
  • Error Display: Error messages appear below the input in red text when validation fails
  • Format Guide: Optional helper text can be displayed below the input (set format_guide_info={true})
  • Error Priority: If both an error message and format guide are present, only the error message is shown

Expected Output

The component behaves like a standard input element:

// onChange receives a standard React.ChangeEvent<HTMLInputElement>
onChange={(e) => {
  const value = e.target.value;  // Current input value as string
  // Handle value change
}}

Error Messages

The component provides default error messages for common validation failures:

  • Numeric: "Must be a valid number", "Must be at least X", "Must be at most X", "Maximum X decimal places allowed"
  • Alpha: "Only letters are allowed"
  • Email: "Must be a valid email address"
  • Mixed: "Must be at least X characters", "Must be at most X characters"
  • Regex: "Invalid format" (or custom message via format_guide)

HazoUiRte

A comprehensive rich text editor component designed for email template generation. Features variable insertion for dynamic content, file attachments, and a full-featured formatting toolbar. Built on Tiptap, a headless editor framework.

Features

  • Rich Text Formatting: Bold, italic, underline, strikethrough, subscript, superscript
  • Block Types: Paragraphs, headings (H1-H3), bullet lists, numbered lists, checklists, blockquotes, code blocks
  • Font Controls: Font family selection, font size adjustment (8-72px)
  • Text Alignment: Left, center, right, justify
  • Colors: Text color and highlight/background color with color picker
  • Links: Insert and edit hyperlinks
  • Images: Insert images (supports base64 and URLs)
  • Tables: Insert tables with custom size, add/remove rows and columns
  • Horizontal Rules: Insert dividers
  • Variable Insertion: Insert template variables (e.g., {{first_name}}) for email personalization
  • File Attachments: Attach files stored as base64-encoded data
  • View Modes: Switch between HTML editor, Plain Text view, and Raw HTML view
  • Undo/Redo: Full history support
  • Indent/Outdent: Control list indentation

Note: HazoUiRte includes Tiptap and ~17 extension packages. This may impact your bundle size if you're not already using Tiptap in your project.

Type Definitions

interface HazoUiRteProps {
  // Initial HTML content
  html?: string;

  // Initial plain text (typically not used directly)
  plain_text?: string;

  // Initial file attachments
  attachments?: RteAttachment[];

  // Template variables available for insertion
  variables?: RteVariable[];

  // Callback fired when content changes (debounced 300ms)
  on_change?: (output: RteOutput) => void;

  // Placeholder text when editor is empty
  placeholder?: string;  // default: "Start typing..."

  // Editor height constraints
  min_height?: string;   // default: "200px"
  max_height?: string;   // default: "400px"

  // Disable editing
  disabled?: boolean;    // default: false

  // Additional CSS classes
  className?: string;

  // Show view mode tabs (HTML, Plain Text, Raw HTML)
  show_output_viewer?: boolean;  // default: false
}

interface RteAttachment {
  filename: string;      // e.g., "document.pdf"
  mime_type: string;     // e.g., "application/pdf"
  data: string;          // base64 encoded content
}

interface RteVariable {
  name: string;          // Variable name (e.g., "first_name")
  description: string;   // Description shown in dropdown
}

interface RteOutput {
  html: string;          // HTML content
  plain_text: string;    // Plain text content (tags stripped)
  attachments: RteAttachment[];  // Current attachments
}

Basic Usage

import { HazoUiRte, type RteOutput } from 'hazo_ui';
import { useState } from 'react';

function BasicEditor() {
  const [content, setContent] = useState<RteOutput | null>(null);

  return (
    <HazoUiRte
      placeholder="Start typing your content..."
      min_height="200px"
      max_height="400px"
      on_change={(output) => setContent(output)}
    />
  );
}

Email Template with Variables

import { HazoUiRte, type RteOutput, type RteVariable } from 'hazo_ui';
import { useState } from 'react';

function EmailTemplateEditor() {
  const [content, setContent] = useState<RteOutput | null>(null);

  // Define available template variables
  const variables: RteVariable[] = [
    { name: "first_name", description: "Recipient's first name" },
    { name: "last_name", description: "Recipient's last name" },
    { name: "email", description: "Recipient's email address" },
    { name: "company_name", description: "Company name" },
    { name: "order_id", description: "Order ID number" },
    { name: "order_date", description: "Date of order" },
    { name: "total_amount", description: "Total order amount" },
  ];

  // Initial template HTML
  const initialHtml = `
    <h2>Order Confirmation</h2>
    <p>Dear <span data-variable="first_name">{{first_name}}</span>,</p>
    <p>Thank you for your order!</p>
    <p>Order ID: <span data-variable="order_id">{{order_id}}</span></p>
    <p>Total: <span data-variable="total_amount">{{total_amount}}</span></p>
  `;

  return (
    <HazoUiRte
      html={initialHtml}
      variables={variables}
      placeholder="Compose your email template..."
      show_output_viewer={true}
      on_change={(output) => {
        setContent(output);
        console.log('HTML:', output.html);
        console.log('Plain text:', output.plain_text);
      }}
    />
  );
}

With File Attachments

import { HazoUiRte, type RteOutput, type RteAttachment } from 'hazo_ui';
import { useState } from 'react';

function EditorWithAttachments() {
  const [content, setContent] = useState<RteOutput | null>(null);

  // Pre-loaded attachments (if any)
  const initialAttachments: RteAttachment[] = [
    {
      filename: "terms.pdf",
      mime_type: "application/pdf",
      data: "JVBERi0xLjQK...", // base64 encoded PDF
    },
  ];

  return (
    <HazoUiRte
      html="<p>Please see the attached document.</p>"
      attachments={initialAttachments}
      on_change={(output) => {
        setContent(output);
        // Access attachments from output
        console.log('Attachments:', output.attachments);
      }}
    />
  );
}

With Output Viewer Tabs

The show_output_viewer prop enables tabs to switch between different views:

  • HTML: The rich text editor (default, editable)
  • Plain Text: Plain text version with HTML tags stripped (view-only)
  • Raw HTML: Formatted HTML source code (view-only)
import { HazoUiRte } from 'hazo_ui';

function EditorWithViewer() {
  return (
    <HazoUiRte
      html="<p>Hello <strong>World</strong>!</p>"
      show_output_viewer={true}
      min_height="300px"
    />
  );
}

When viewing Plain Text or Raw HTML tabs, the toolbar is disabled (grayed out) since those are view-only modes.

Disabled State

import { HazoUiRte } from 'hazo_ui';

function ReadOnlyEditor() {
  return (
    <HazoUiRte
      html="<p>This content cannot be edited.</p>"
      disabled={true}
    />
  );
}

Props Reference

Prop Type Default Description
html string "" Initial HTML content
plain_text string - Initial plain text (rarely used)
attachments RteAttachment[] [] Initial file attachments
variables RteVariable[] [] Template variables for insertion
on_change (output: RteOutput) => void - Callback when content changes (debounced 300ms)
placeholder string "Start typing..." Placeholder text
min_height string "200px" Minimum editor height
max_height string "400px" Maximum editor height
disabled boolean false Disable editing
className string - Additional CSS classes
show_output_viewer boolean false Show HTML/Plain Text/Raw HTML tabs

Toolbar Controls

The toolbar includes the following controls (left to right):

  1. Undo/Redo - History navigation
  2. Block Type - Paragraph, Headings, Lists, Code, Quote
  3. Font Family - Arial, Verdana, Times New Roman, Georgia, Courier New, Trebuchet MS
  4. Font Size - Decrease/Increase (8-72px)
  5. Text Formatting - Bold, Italic, Underline, Strikethrough, Subscript, Superscript
  6. Link - Insert/edit hyperlinks
  7. Clear Formatting - Remove all formatting
  8. Text Color - Color picker for text
  9. Highlight Color - Color picker for background
  10. Text Alignment - Left, Center, Right, Justify
  11. Lists - Bullet list, Numbered list, Checklist
  12. Indent/Outdent - Adjust list indentation
  13. Horizontal Rule - Insert divider
  14. Image - Insert image
  15. Table - Insert table with size picker, add/remove rows/columns
  16. Variables - Insert template variables (if variables prop provided)
  17. Attachment - Attach files

HazoUiTextbox

A single-line text input component with prefix-triggered command pill support. Perfect for mention systems, command inputs, tag fields, and any input that needs to convert text patterns into interactive elements.

Features

  • Command Pills: Convert prefix-triggered text (e.g., @mention, /command, #tag) into interactive pills
  • Click to Edit: Click any pill to open an edit popover with options to change or remove the command
  • Keyboard Navigation: Navigate edit options with Arrow Up/Down, select with Enter, close with Escape
  • Multiple Prefixes: Support multiple prefix types simultaneously (e.g., @ for users, # for tags, / for commands)
  • Visual Variants: Three pill styles - default (blue), outline (transparent), subtle (muted)
  • Auto-complete Dropdown: Searchable dropdown appears when typing a prefix character
  • Controlled & Uncontrolled: Supports both controlled and uncontrolled usage patterns
  • Single-line Input: Press Enter to submit (triggers on_submit callback)
  • TypeScript Support: Fully typed with comprehensive interfaces

Type Definitions

interface HazoUiTextboxProps {
  // Controlled value (plain text with prefix+action format, e.g., "Hello @john_doe!")
  value?: string;

  // Uncontrolled default value
  default_value?: string;

  // Prefix configurations (required)
  prefixes: PrefixConfig[];

  // Input properties
  placeholder?: string;  // default: "Type here..."
  disabled?: boolean;    // default: false
  className?: string;

  // Pill styling
  pill_variant?: "default" | "outline" | "subtle";  // default: "default"

  // Unique instance ID to prevent TipTap plugin key conflicts
  // Required when multiple instances coexist (e.g., in lists)
  // If not provided, auto-generated via React.useId()
  instance_id?: string;

  // Callbacks
  on_change?: (output: CommandTextOutput) => void;
  on_submit?: (output: CommandTextOutput) => void;  // Triggered on Enter key
  on_command_insert?: (command: CommandItem, prefix: string) => void;
  on_command_change?: (old_command: InsertedCommand, new_command: CommandItem) => void;
  on_command_remove?: (command: InsertedCommand) => void;
}

interface PrefixConfig {
  char: string;          // Prefix character (e.g., "@", "/", "#")
  commands: CommandItem[];  // Available commands for this prefix
}

interface CommandItem {
  action: string;           // Unique action identifier (e.g., "john_doe")
  action_label: string;     // Display label (e.g., "John Doe")
  action_description?: string;  // Optional description shown in dropdown
  icon?: React.ReactNode;   // Optional icon
}

interface CommandTextOutput {
  plain_text: string;       // Plain text with prefix+action (e.g., "Hello @john_doe!")
  display_text: string;     // Display text with labels (e.g., "Hello @John Doe!")
  commands: InsertedCommand[];  // Array of inserted commands with positions
}

interface InsertedCommand {
  id: string;               // Unique ID for this instance
  prefix: string;           // The prefix character
  action: string;           // The action identifier
  action_label: string;     // The display label
  position: number;         // Character position in plain_text
  length: number;           // Length of the command in plain_text
}

Basic Usage

import { HazoUiTextbox, type PrefixConfig, type CommandTextOutput } from 'hazo_ui';
import { useState } from 'react';

function MentionInput() {
  const [value, setValue] = useState<string>("");

  // Define available mentions
  const prefixes: PrefixConfig[] = [
    {
      char: "@",
      commands: [
        { action: "john_doe", action_label: "John Doe" },
        { action: "jane_smith", action_label: "Jane Smith" },
        { action: "bob_wilson", action_label: "Bob Wilson" },
      ],
    },
  ];

  const handleChange = (output: CommandTextOutput) => {
    setValue(output.plain_text);
    console.log('Plain text:', output.plain_text);
    console.log('Display text:', output.display_text);
    console.log('Commands:', output.commands);
  };

  return (
    <HazoUiTextbox
      value={value}
      prefixes={prefixes}
      placeholder="Type @ to mention someone..."
      on_change={handleChange}
    />
  );
}

Multiple Prefix Types

import { HazoUiTextbox, type PrefixConfig } from 'hazo_ui';

function MultiPrefixInput() {
  const prefixes: PrefixConfig[] = [
    {
      char: "@",
      commands: [
        { action: "john", action_label: "John Doe", action_description: "Software Engineer" },
        { action: "jane", action_label: "Jane Smith", action_description: "Product Manager" },
      ],
    },
    {
      char: "#",
      commands: [
        { action: "bug", action_label: "Bug", action_description: "Something is broken" },
        { action: "feature", action_label: "Feature", action_description: "New functionality" },
        { action: "docs", action_label: "Documentation", action_description: "Documentation update" },
      ],
    },
    {
      char: "/",
      commands: [
        { action: "assign", action_label: "Assign", action_description: "Assign to user" },
        { action: "close", action_label: "Close", action_description: "Close this issue" },
        { action: "archive", action_label: "Archive", action_description: "Archive this item" },
      ],
    },
  ];

  return (
    <HazoUiTextbox
      prefixes={prefixes}
      placeholder="Type @, #, or / for commands..."
      on_change={(output) => console.log(output)}
    />
  );
}

With Command Callbacks

import { HazoUiTextbox, type CommandItem, type InsertedCommand } from 'hazo_ui';

function CommandInput() {
  const prefixes = [
    {
      char: "@",
      commands: [
        { action: "alice", action_label: "Alice Johnson" },
        { action: "bob", action_label: "Bob Smith" },
      ],
    },
  ];

  const handleInsert = (command: CommandItem, prefix: string) => {
    console.log(`Inserted ${prefix}${command.action_label}`);
    // Track analytics, send notifications, etc.
  };

  const handleChange = (old_command: InsertedCommand, new_command: CommandItem) => {
    console.log(`Changed from ${old_command.action} to ${new_command.action}`);
    // Update references, notify backend, etc.
  };

  const handleRemove = (command: InsertedCommand) => {
    console.log(`Removed ${command.prefix}${command.action_label}`);
    // Clean up references, update state, etc.
  };

  return (
    <HazoUiTextbox
      prefixes={prefixes}
      placeholder="Mention someone..."
      on_command_insert={handleInsert}
      on_command_change={handleChange}
      on_command_remove={handleRemove}
      on_submit={(output) => console.log('Submitted:', output)}
    />
  );
}

Pill Variants

import { HazoUiTextbox } from 'hazo_ui';

function VariantExample() {
  const prefixes = [
    {
      char: "@",
      commands: [{ action: "user", action_label: "Username" }],
    },
  ];

  return (
    <div className="space-y-4">
      {/* Default: Blue background */}
      <HazoUiTextbox
        prefixes={prefixes}
        pill_variant="default"
        placeholder="Default variant (blue)..."
      />

      {/* Outline: Transparent with border */}
      <HazoUiTextbox
        prefixes={prefixes}
        pill_variant="outline"
        placeholder="Outline variant (transparent)..."
      />

      {/* Subtle: Muted background */}
      <HazoUiTextbox
        prefixes={prefixes}
        pill_variant="subtle"
        placeholder="Subtle variant (muted)..."
      />
    </div>
  );
}

Edit Popover Behavior

When a user clicks on an inserted command pill:

  1. Edit Popover Opens: A dropdown appears below the clicked pill
  2. Command Options: Shows all available commands for that prefix
  3. Current Selection: The current command is highlighted with "current" label
  4. Change Command: Click any command to change to that option (triggers on_command_change)
  5. Remove Command: Click "Remove" at the bottom to delete the pill (triggers on_command_remove)
  6. Keyboard Navigation:
    • Arrow Up/Down: Navigate options
    • Enter: Select highlighted option
    • Escape: Close popover without changes
  7. Click Outside: Click anywhere outside the popover to close without changes

Expected Output

// Example input: "Hello @john_doe and #feature request"
// With prefixes: @ for users, # for tags

const output: CommandTextOutput = {
  plain_text: "Hello @john_doe and #feature request",
  display_text: "Hello @John Doe and #Feature request",
  commands: [
    {
      id: "cmd_abc123",
      prefix: "@",
      action: "john_doe",
      action_label: "John Doe",
      position: 6,   // Character position of "@" in plain_text
      length: 9,     // Length of "@john_doe"
    },
    {
      id: "cmd_def456",
      prefix: "#",
      action: "feature",
      action_label: "Feature",
      position: 20,  // Character position of "#" in plain_text
      length: 8,     // Length of "#feature"
    },
  ],
};

HazoUiTextarea

A multi-line text input component with prefix-triggered command pill support. Similar to HazoUiTextbox but supports multiple paragraphs and line breaks.

Features

  • All HazoUiTextbox Features: Inherits all command pill functionality
  • Multi-line Support: Supports multiple paragraphs with line breaks
  • Shift+Enter: Create new lines within the textarea
  • Cmd/Ctrl+Enter to Submit: Submit with keyboard shortcut (triggers on_submit callback)
  • Configurable Height: Set min/max height or use rows prop
  • Click to Edit Pills: Same interactive pill editing as HazoUiTextbox
  • Auto-scrolling: Scrollable content when exceeding max height

Type Definitions

interface HazoUiTextareaProps {
  // Same as HazoUiTextbox plus:
  value?: string;
  default_value?: string;
  prefixes: PrefixConfig[];
  placeholder?: string;
  disabled?: boolean;
  className?: string;
  pill_variant?: "default" | "outline" | "subtle";

  // Unique instance ID to prevent TipTap plugin key conflicts
  // Required when multiple instances coexist (e.g., in lists)
  // If not provided, auto-generated via React.useId()
  instance_id?: string;

  on_change?: (output: CommandTextOutput) => void;
  on_submit?: (output: CommandTextOutput) => void;  // Triggered on Cmd/Ctrl+Enter
  on_command_insert?: (command: CommandItem, prefix: string) => void;
  on_command_change?: (old_command: InsertedCommand, new_command: CommandItem) => void;
  on_command_remove?: (command: InsertedCommand) => void;

  // Textarea-specific properties
  min_height?: string;   // default: "80px"
  max_height?: string;   // default: "200px"
  rows?: number;         // Alternative to min_height (calculates as rows * 1.5em)
}

Basic Usage

import { HazoUiTextarea, type PrefixConfig } from 'hazo_ui';
import { useState } from 'react';

function CommentBox() {
  const [value, setValue] = useState<string>("");

  const prefixes: PrefixConfig[] = [
    {
      char: "@",
      commands: [
        { action: "alice", action_label: "Alice Johnson" },
        { action: "bob", action_label: "Bob Smith" },
      ],
    },
    {
      char: "#",
      commands: [
        { action: "bug", action_label: "Bug" },
        { action: "feature", action_label: "Feature Request" },
      ],
    },
  ];

  return (
    <HazoUiTextarea
      value={value}
      prefixes={prefixes}
      placeholder="Write a comment... (Cmd+Enter to submit)"
      min_height="100px"
      max_height="300px"
      on_change={(output) => setValue(output.plain_text)}
      on_submit={(output) => {
        console.log('Submitting:', output);
        // Submit comment to backend
        setValue(""); // Clear after submit
      }}
    />
  );
}

With Custom Height

import { HazoUiTextarea } from 'hazo_ui';

function CustomHeightExample() {
  const prefixes = [
    { char: "@", commands: [{ action: "user", action_label: "User" }] },
  ];

  return (
    <div className="space-y-4">
      {/* Using rows */}
      <HazoUiTextarea
        prefixes={prefixes}
        rows={3}
        placeholder="3 rows tall..."
      />

      {/* Using min/max height */}
      <HazoUiTextarea
        prefixes={prefixes}
        min_height="120px"
        max_height="400px"
        placeholder="Custom height..."
      />
    </div>
  );
}

Keyboard Shortcuts

  • Enter: Insert a new line (Shift+Enter also works)
  • Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): Submit (triggers on_submit)
  • Arrow Up/Down: Navigate command suggestions or edit options
  • Escape: Close popover/dropdown
  • Typing @, #, / (or any configured prefix): Open command dropdown

Props Reference

Prop Type Default Description
value string - Controlled value (plain text with commands)
default_value string - Uncontrolled default value
prefixes PrefixConfig[] Required Prefix configurations
placeholder string "Type here..." Placeholder text
disabled boolean false Disable input
className string - Additional CSS classes
pill_variant "default" | "outline" | "subtle" "default" Pill styling variant
instance_id string auto-generated Unique ID to prevent TipTap plugin conflicts when multiple instances coexist
min_height string "80px" Minimum textarea height
max_height string "200px" Maximum textarea height
rows number - Number of rows (overrides min_height)
on_change (output: CommandTextOutput) => void - Called when content changes
on_submit (output: CommandTextOutput) => void - Called on Cmd/Ctrl+Enter
on_command_insert (command, prefix) => void - Called when command is inserted
on_command_change (old, new) => void - Called when command is changed via edit popover
on_command_remove (command) => void - Called when command is removed via edit popover

HazoUiConfirmDialog

A compact, opinionated confirmation dialog for destructive actions, warnings, and simple acknowledgments. Features an accent top border colored by variant, configurable buttons, and automatic async loading state management.

Basic Usage

import { HazoUiConfirmDialog } from 'hazo_ui';

// Destructive confirmation with two buttons
<HazoUiConfirmDialog
  open={showClear}
  onOpenChange={setShowClear}
  variant="destructive"
  title="Clear Form Data"
  description="This will permanently delete all uploaded files and reset the status. This action cannot be undone."
  confirmLabel="Yes, clear everything"
  onConfirm={handleClear}
/>

// OK-only acknowledgment
<HazoUiConfirmDialog
  open={showSuccess}
  onOpenChange={setShowSuccess}
  variant="info"
  title="Upload Complete"
  description="Your files have been uploaded successfully."
  showCancelButton={false}
  onConfirm={() => setShowSuccess(false)}
/>

// Async with auto-loading spinner
<HazoUiConfirmDialog
  open={showDelete}
  onOpenChange={setShowDelete}
  variant="destructive"
  title="Delete Account"
  description="This will permanently delete your account."
  confirmLabel="Delete My Account"
  onConfirm={async () => {
    await deleteAccount();
    // dialog auto-closes on resolve
  }}
/>

// Custom children content
<HazoUiConfirmDialog
  open={showConfirm}
  onOpenChange={setShowConfirm}
  title="Confirm Submission"
  onConfirm={handleSubmit}
>
  <div>
    <p>You are about to submit:</p>
    <ul>
      <li>3 documents</li>
      <li>2 images</li>
    </ul>
  </div>
</HazoUiConfirmDialog>

Variants

Variant Top Border Button Color Use Case
default Slate Primary General confirmations
destructive Red Red Delete, clear, remove
warning Amber Amber Unsaved changes, caution
info Blue Blue Notices, acknowledgments
success Green Green Completions, confirmations

Props

Prop Type Default Description
open boolean - Required. Controls dialog visibility
onOpenChange (open: boolean) => void - Required. Called when dialog open state changes
title string - Required. Dialog title text
description string - Simple text description (alternative to children)
children ReactNode - Custom content (overrides description if both provided)
variant ConfirmDialogVariant 'default' Color variant preset
confirmLabel string 'Confirm' / 'OK' Confirm button text. Defaults to "OK" when showCancelButton={false}
cancelLabel string 'Cancel' Cancel button text
showCancelButton boolean true Whether to show the cancel button
onConfirm () => void | Promise<void> - Required. Called on confirm. If returns Promise, auto-shows loading spinner
onCancel () => void - Called on cancel (before dialog closes)
loading boolean - External loading state (overrides async auto-detection)
disabled boolean false Disable confirm button
accentColor string - Override top border color
confirmButtonColor string - Override confirm button background color
openAnimation AnimationPreset | string 'zoom' Dialog open animation
closeAnimation AnimationPreset | string 'zoom' Dialog close animation

Async Loading

When onConfirm returns a Promise, the component automatically:

  1. Shows a spinner on the confirm button
  2. Disables both buttons
  3. Closes dialog on resolve
  4. Restores buttons on reject (dialog stays open)

Use the loading prop to override this behavior with external control.

Custom Colors

<HazoUiConfirmDialog
  accentColor="#7c3aed"
  confirmButtonColor="#7c3aed"
  title="Custom Styled"
  description="Purple accent and button colors."
  onConfirm={handleConfirm}
  open={open}
  onOpenChange={setOpen}
/>

Accessibility

  • Focus trap within dialog (Radix)
  • ESC closes dialog
  • Cancel button receives initial focus for destructive/warning variants (prevents accidental confirmation)
  • Proper aria-labelledby and aria-describedby via Radix primitives

HazoUiDialog

A flexible, standardized dialog component with customizable animations, sizes, and theming. Built on Radix UI Dialog primitives with a consistent header/body/footer layout.

Two Usage Patterns

HazoUiDialog offers two approaches depending on your needs:

  1. Props-based API (HazoUiDialog) - For simple confirmations, alerts, and standard forms with predefined header/body/footer layout
  2. Compositional API (Primitives) - For complex layouts with custom headers, tabs, avatars, and full layout control

When to Use Each Approach

Use HazoUiDialog (props-based) when:

  • You need a standard confirmation dialog
  • Your content fits a simple header/body/footer layout
  • You want quick implementation with minimal code
  • Examples: confirmations, alerts, simple forms

Use Compositional API (primitives) when:

  • You need custom headers with avatars, icons, or complex layouts
  • Your dialog has tabs or multi-section content
  • You want full control over the dialog structure
  • Examples: client details with tabs, complex forms, custom workflows

Features

  • Flexible Sizing: 5 size presets from small (400px) to full-width (98vw), plus custom sizing
  • 9 Animation Presets: Zoom, slide (bottom/top/left/right), fade, bounce, scale-up, and flip animations
  • Header Bar Option: Full-width colored header bar for modern modal designs
  • Color Customization: Override header, body, footer, border, and accent colors via props
  • Themed Variants: Pre-built themes for success, warning, destructive, and info states
  • Responsive Design: Viewport-relative sizing with maximum constraints
  • Controlled Component: Fully controlled open/close state
  • Callback Support: Separate callbacks for confirm and cancel actions
  • TypeScript Support: Fully typed with comprehensive interfaces
  • Accessible: Built with accessibility in mind using Radix UI primitives

Type Definitions

interface HazoUiDialogProps {
  // Dialog State Control
  open?: boolean;
  onOpenChange?: (open: boolean) => void;

  // Content & Callbacks
  children: React.ReactNode;
  onConfirm?: () => void;
  onCancel?: () => void;

  // Header Configuration
  title?: string;                      // default: "Action required"
  description?: string;

  // Button Configuration
  actionButtonText?: string;           // default: "Confirm"
  actionButtonVariant?: ButtonVariant; // default: "default"
  cancelButtonText?: string;           // default: "Cancel"
  showCancelButton?: boolean;          // default: true

  // Action Button Enhancement
  actionButtonLoading?: boolean;       // default: false - Shows spinner, disables button
  actionButtonDisabled?: boolean;      // default: false - Disables action button
  actionButtonIcon?: React.ReactNode;  // Icon before button text (replaced by spinner when loading)

  // Custom Footer
  footerContent?: React.ReactNode;     // Custom footer content (replaces default buttons)

  // Size Configuration
  sizeWidth?: string;                  // default: "min(90vw, 600px)"
  sizeHeight?: string;                 // default: "min(80vh, 800px)"
  fixedSize?: boolean;                 // default: false - When true, sizeHeight becomes a fixed height instead of maxHeight

  // Animation Configuration
  openAnimation?: AnimationPreset | string;  // default: "zoom"
  closeAnimation?: AnimationPreset | string; // default: "zoom"

  // Variant (preset color theme)
  variant?: DialogVariant;               // default: "default"
  splitHeader?: boolean;                 // default: true - When true, title/description get separate bg rows; false = single bg, italic description

  // Color Customization (overrides variant preset if both provided)
  headerBackgroundColor?: string;
  descriptionBackgroundColor?: string;
  headerTextColor?: string;
  bodyBackgroundColor?: string;
  footerBackgroundColor?: string;
  borderColor?: string;
  accentColor?: string;

  // Header Bar (full-width colored bar)
  headerBar?: boolean;                 // default: false - Enable full-width colored header bar
  headerBarColor?: string;             // default: "#1e293b" - Color for the header bar

  // Styling Customization
  className?: string;
  contentClassName?: string;
  overlayClassName?: string;
  headerClassName?: string;
  footerClassName?: string;
  showCloseButton?: boolean;           // default: true
}

type DialogVariant = 'default' | 'info' | 'success' | 'warning' | 'destructive';
type AnimationPreset = 'zoom' | 'slide' | 'fade' | 'bounce' | 'scale-up' | 'flip' | 'slide-left' | 'slide-right' | 'slide-top' | 'none';
type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";

Pattern 1: Basic Usage (Props-based API)

For simple dialogs with standard layout:

import { HazoUiDialog } from 'hazo_ui';
import { useState } from 'react';

function ConfirmDialog() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>
        Open Dialog
      </button>

      <HazoUiDialog
        open={isOpen}
        onOpenChange={setIsOpen}
        title="Confirm Action"
        description="Are you sure you want to proceed?"
        onConfirm={() => {
          console.log('Confirmed');
          setIsOpen(false);
        }}
        onCancel={() => {
          console.log('Cancelled');
        }}
      >
        <p>This action cannot be undone.</p>
      </HazoUiDialog>
    </>
  );
}

Pattern 2: Compositional API (Complex Layouts)

For dialogs with custom headers, tabs, or complex layouts:

import {
  HazoUiDialogRoot,
  HazoUiDialogContent,
  HazoUiDialogHeader,
  HazoUiDialogTitle,
  HazoUiDialogDescription,
  HazoUiDialogFooter,
} from 'hazo_ui';
import { useState } from 'react';
import { User } from 'lucide-react';

function ClientDetailsDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const [activeTab, setActiveTab] = useState('personal');

  return (
    <>
      <button onClick={() => setIsOpen(true)}>
        Open Client Details
      </button>

      <HazoUiDialogRoot open={isOpen} onOpenChange={setIsOpen}>
        <HazoUiDialogContent className="sm:max-w-2xl w-[90vw] h-[80vh] flex flex-col p-0">
          {/* Custom Header with Avatar */}
          <HazoUiDialogHeader className="bg-navbar text-navbar-foreground p-6 rounded-t-lg">
            <div className="flex items-center gap-3">
              <div className="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
                <User className="h-6 w-6 text-primary-foreground" />
              </div>
              <div>
                <HazoUiDialogTitle className="text-white text-left">
                  John Doe
                </HazoUiDialogTitle>
                <HazoUiDialogDescription className="text-white/80 text-left">
                  john.doe@example.com
                </HazoUiDialogDescription>
              </div>
            </div>
          </HazoUiDialogHeader>

          {/* Tabs Navigation */}
          <div className="border-b px-6">
            <div className="flex gap-4">
              <button
                onClick={() => setActiveTab('personal')}
                className={`py-3 px-1 text-sm font-medium border-b-2 ${
                  activeTab === 'personal'
                    ? 'border-primary text-primary'
                    : 'border-transparent text-muted-foreground'
                }`}
              >
                Personal
              </button>
              <button
                onClick={() => setActiveTab('address')}
                className={`py-3 px-1 text-sm font-medium border-b-2 ${
                  activeTab === 'address'
                    ? 'border-primary text-primary'
                    : 'border-transparent text-muted-foreground'
                }`}
              >
                Address
              </button>
              <button
                onClick={() => setActiveTab('contact')}
                className={`py-3 px-1 text-sm font-medium border-b-2 ${
                  activeTab === 'contact'
                    ? 'border-primary text-primary'
                    : 'border-transparent text-muted-foreground'
                }`}
              >
                Contact
              </button>
            </div>
          </div>

          {/* Scrollable Tab Content */}
          <div className="flex-1 overflow-y-auto p-6">
            {activeTab === 'personal' && (
              <div className="space-y-4">
                <div>
                  <label className="text-sm font-medium">Full Name</label>
                  <input
                    type="text"
                    className="w-full px-3 py-2 border rounded-md mt-1"
                  />
                </div>
                {/* More personal fields */}
              </div>
            )}
            {activeTab === 'address' && (
              <div className="space-y-4">
                {/* Address fields */}
              </div>
            )}
            {activeTab === 'contact' && (
              <div className="space-y-4">
                {/* Contact fields */}
              </div>
            )}
          </div>

          {/* Footer */}
          <HazoUiDialogFooter className="p-6 border-t">
            <button
              onClick={() => setIsOpen(false)}
              className="px-4 py-2 border rounded-md hover:bg-accent"
            >
              Cancel
            </button>
            <button
              onClick={() => setIsOpen(false)}
              className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
            >
              Save Changes
            </button>
          </HazoUiDialogFooter>
        </HazoUiDialogContent>
      </HazoUiDialogRoot>
    </>
  );
}

Available Compositional Components:

import {
  HazoUiDialogRoot,        // Root dialog container (replaces Dialog)
  HazoUiDialogTrigger,     // Trigger button (optional)
  HazoUiDialogContent,     // Dialog content wrapper
  HazoUiDialogHeader,      // Header section
  HazoUiDialogTitle,       // Title text
  HazoUiDialogDescription, // Description text
  HazoUiDialogFooter,      // Footer section
  HazoUiDialogPortal,      // Portal wrapper
  HazoUiDialogOverlay,     // Backdrop overlay
  HazoUiDialogClose,       // Close button
} from 'hazo_ui';

Key Differences:

Feature Props-based API Compositional API
Layout Predefined header/body/footer Build your own structure
Configuration Via props Via component composition
Complexity Simple, quick Flexible, powerful
Best for Confirmations, alerts Tabs, avatars, custom layouts
Code Minimal More verbose

Compositional API Tips:

  • Use flex flex-col on HazoUiDialogContent for layouts with headers/footers
  • Set p-0 on HazoUiDialogContent and add padding to individual sections
  • Use overflow-y-auto on the scrollable content area
  • Apply bg-navbar text-navbar-foreground for dark headers
  • Use border-t on footer for visual separation

Action Button States and Custom Footers

The dialog supports enhanced action buttons with loading states, icons, and fully custom footers for complex UX scenarios.

1. Form Dialog with Loading State
import { HazoUiDialog } from 'hazo_ui';
import { useState } from 'react';

function SaveFormDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const [isSaving, setIsSaving] = useState(false);

  const handleSave = async () => {
    setIsSaving(true);
    try {
      await saveFormData();
      setIsOpen(false);
    } catch (error) {
      console.error('Save failed:', error);
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <HazoUiDialog
      open={isOpen}
      onOpenChange={setIsOpen}
      title="Save Changes"
      description="Your changes will be saved to the server."
      actionButtonText={isSaving ? "Saving..." : "Save"}
      actionButtonLoading={isSaving}
      onConfirm={handleSave}
    >
      <p>Click Save to see the loading spinner and disabled button.</p>
    </HazoUiDialog>
  );
}
2. Confirmation Dialog with Icon
import { HazoUiDialog } from 'hazo_ui';
import { Send } from 'lucide-react';
import { useState } from 'react';

function SendEmailDialog() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <HazoUiDialog
      open={isOpen}
      onOpenChange={setIsOpen}
      title="Send Email"
      description="This will send the email to all recipients."
      actionButtonText="Send Email"
      actionButtonIcon={<Send className="h-4 w-4" />}
      onConfirm={() => {
        sendEmail();
        setIsOpen(false);
      }}
    >
      <p>The Send icon appears before the button text.</p>
    </HazoUiDialog>
  );
}
3. Destructive Action with Loading
import { HazoUiDialog } from 'hazo_ui';
import { Lock } from 'lucide-react';
import { useState } from 'react';

function CloseAccountDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const [isClosing, setIsClosing] = useState(false);

  const handleClose = async () => {
    setIsClosing(true);
    try {
      await closeAccount();
      setIsOpen(false);
    } catch (error) {
      console.error('Close failed:', error);
    } finally {
      setIsClosing(false);
    }
  };

  return (
    <HazoUiDialog
      open={isOpen}
      onOpenChange={setIsOpen}
      title="Close Account"
      description="This will permanently close your account."
      actionButtonText={isClosing ? "Closing..." : "Close"}
      actionButtonIcon={<Lock className="h-4 w-4" />}
      actionButtonLoading={isClosing}
      onConfirm={handleClose}
    >
      <p>Lock icon is replaced by spinner when loading.</p>
    </HazoUiDialog>
  );
}
import { HazoUiDialog } from 'hazo_ui';
import { useState } from 'react';

function ReviewItemsDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const [stats, setStats] = useState({ keep: 0, accept: 0, skip: 0 });

  return (
    <HazoUiDialog
      open={isOpen}
      onOpenChange={setIsOpen}
      title="Review Items"
      description="Review and process the items below."
      footerContent={
        <div className="flex items-center justify-between w-full">
          <div className="text-sm text-muted-foreground">
            Keep: {stats.keep} | Accept: {stats.accept} | Skip: {stats.skip}
          </div>
          <div className="flex gap-2">
            <button
              onClick={() => setStats({ ...stats, skip: stats.skip + 1 })}
              className="px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
            >
              Skip
            </button>
            <button
              onClick={() => setStats({ ...stats, keep: stats.keep + 1 })}
              className="px-3 py-1.5 text-sm bg-blue-500 text-white rounded-md"
            >
              Keep
            </button>
            <button
              onClick={() => {
                setStats({ ...stats, accept: stats.accept + 1 });
                setIsOpen(false);
              }}
              className="px-3 py-1.5 text-sm bg-green-500 text-white rounded-md"
            >
              Accept
            </button>
          </div>
        </div>
      }
    >
      <p>Custom footer shows stats and multiple action buttons.</p>
    </HazoUiDialog>
  );
}
import { HazoUiDialog } from 'hazo_ui';
import { useState, useEffect } from 'react';

function ProgressDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    if (isOpen) {
      const interval = setInterval(() => {
        setProgress((prev) => {
          const next = prev + 10;
          if (next >= 100) {
            clearInterval(interval);
            setTimeout(() => setIsOpen(false), 500);
            return 100;
          }
          return next;
        });
      }, 300);
      return () => clearInterval(interval);
    }
  }, [isOpen]);

  return (
    <HazoUiDialog
      open={isOpen}
      onOpenChange={setIsOpen}
      title="Processing..."
      description="Please wait while we process your request."
      showCloseButton={false}
      footerContent={<div />}
    >
      <div className="space-y-4">
        <div className="w-full bg-muted rounded-full h-2">
          <div
            className="bg-primary h-2 rounded-full transition-all"
            style={{ width: `${progress}%` }}
          />
        </div>
        <p className="text-sm text-center">{progress}% complete</p>
      </div>
    </HazoUiDialog>
  );
}

Size Variants

// Small - 400px
<HazoUiDialog
  sizeWidth="min(90vw, 400px)"
  sizeHeight="min(70vh, 300px)"
  {...props}
/>

// Medium (default) - 600px
<HazoUiDialog
  sizeWidth="min(90vw, 600px)"
  sizeHeight="min(80vh, 600px)"
  {...props}
/>

// Large - 1000px
<HazoUiDialog
  sizeWidth="min(95vw, 1000px)"
  sizeHeight="min(85vh, 800px)"
  {...props}
/>

// Extra-Large - 1400px
<HazoUiDialog
  sizeWidth="min(95vw, 1400px)"
  sizeHeight="min(90vh, 900px)"
  {...props}
/>

// Full Width - 98vw
<HazoUiDialog
  sizeWidth="98vw"
  sizeHeight="min(90vh, 1000px)"
  {...props}
/>

// Fixed Size - dialog maintains exact height regardless of content
<HazoUiDialog
  sizeHeight="500px"
  fixedSize={true}
  {...props}
/>

Animation Variants

// Zoom - Scales from 50% size with dramatic effect
<HazoUiDialog
  openAnimation="zoom"
  closeAnimation="zoom"
  {...props}
/>

// Slide Bottom - Slides from bottom of screen
<HazoUiDialog
  openAnimation="slide"
  closeAnimation="slide"
  {...props}
/>

// Slide Top - Slides from top of screen
<HazoUiDialog
  openAnimation="slide-top"
  closeAnimation="slide-top"
  {...props}
/>

// Slide Left - Slides from left side
<HazoUiDialog
  openAnimation="slide-left"
  closeAnimation="slide-left"
  {...props}
/>

// Slide Right - Slides from right side
<HazoUiDialog
  openAnimation="slide-right"
  closeAnimation="slide-right"
  {...props}
/>

// Fade - Pure opacity fade with no movement
<HazoUiDialog
  openAnimation="fade"
  closeAnimation="fade"
  {...props}
/>

// Bounce - Gentle bounce/spring animation
<HazoUiDialog
  openAnimation="bounce"
  closeAnimation="bounce"
  {...props}
/>

// Scale Up - Scales from 0% to full size
<HazoUiDialog
  openAnimation="scale-up"
  closeAnimation="scale-up"
  {...props}
/>

// Flip - Flip/rotate animation effect
<HazoUiDialog
  openAnimation="flip"
  closeAnimation="flip"
  {...props}
/>

// None - No animation
<HazoUiDialog
  openAnimation="none"
  closeAnimation="none"
  {...props}
/>

Themed Dialogs (Variant Prop)

Use the variant prop to apply preset color themes with a single prop instead of specifying individual colors. Available variants: info, success, warning, destructive.

Each variant auto-applies: header background tint, header text color, border color, accent color, tinted overlay, and (for destructive) destructive button variant.

Override chain: Individual prop > Variant preset > Global config. You can use a variant and still override specific colors via props.

// Success variant
<HazoUiDialog variant="success" title="✓ Success" actionButtonText="Done" showCancelButton={false} {...props}>
  <p>Your changes have been saved.</p>
</HazoUiDialog>

// Warning variant
<HazoUiDialog variant="warning" title="⚠ Warning" actionButtonText="I Understand" {...props}>
  <p>You have unsaved changes that will be lost.</p>
</HazoUiDialog>

// Danger variant (auto-sets destructive button)
<HazoUiDialog variant="destructive" title="⛔ Delete" actionButtonText="Delete Permanently" {...props}>
  <p>All data will be permanently deleted.</p>
</HazoUiDialog>

// Info variant
<HazoUiDialog variant="info" title="ℹ Information" actionButtonText="Got It" showCancelButton={false} {...props}>
  <p>New features are now available.</p>
</HazoUiDialog>
Split Header vs Merged Header

By default (splitHeader={true}), variant dialogs render the title and description as two separate rows with different background intensities. Set splitHeader={false} for a single header background with the description as italic subtext.

// Split header (default) - two background rows
<HazoUiDialog variant="info" splitHeader={true} title="Title" description="Description" {...props}>
  <p>Title row has stronger blue, description row has lighter blue.</p>
</HazoUiDialog>

// Merged header - single background, italic description
<HazoUiDialog variant="destructive" splitHeader={false} title="Delete" description="This is permanent." {...props}>
  <p>One red header background with italic description text.</p>
</HazoUiDialog>
Overriding Variant Colors
// Use info variant but override header to purple
<HazoUiDialog
  variant="info"
  headerBackgroundColor="rgb(243, 232, 255)"
  headerTextColor="rgb(88, 28, 135)"
  title="Custom Header"
  {...props}
>
  <p>Blue border/accent from variant, purple header from props.</p>
</HazoUiDialog>

// Danger variant with non-destructive button override
<HazoUiDialog variant="destructive" actionButtonVariant="default" title="Acknowledge" {...props}>
  <p>Red colors from variant, default button from prop override.</p>
</HazoUiDialog>

Header Bar Style

The header bar feature creates a full-width colored bar at the top of the dialog, similar to common modal designs in modern applications. When enabled, the header text automatically becomes white for better contrast.

// Dark Header Bar (slate)
<HazoUiDialog
  title="Invite Team Members"
  description="Add people to your workspace"
  headerBar={true}
  headerBarColor="#1e293b"
  actionButtonText="Send Invites"
  {...props}
>
  <div className="space-y-4">
    <input
      type="text"
      placeholder="email@example.com"
      className="w-full px-3 py-2 border rounded-md"
    />
    <select className="w-full px-3 py-2 border rounded-md">
      <option>Admin</option>
      <option>Editor</option>
      <option>Viewer</option>
    </select>
  </div>
</HazoUiDialog>

// Blue Header Bar
<HazoUiDialog
  title="Create New Project"
  description="Set up a new project workspace"
  headerBar={true}
  headerBarColor="#2563eb"
  actionButtonText="Create"
  {...props}
>
  {/* Form content */}
</HazoUiDialog>

// Purple Header Bar
<HazoUiDialog
  title="Upload Document"
  description="Upload a file to process"
  headerBar={true}
  headerBarColor="#9333ea"
  actionButtonText="Upload"
  {...props}
>
  {/* Upload UI */}
</HazoUiDialog>

Key Features:

  • Full-width colored bar extends to dialog edges
  • Header text automatically becomes white for contrast
  • Description text becomes semi-transparent white (80% opacity)
  • Close button color adapts to white for visibility
  • Compatible with all animation presets and size variants

Complex Form Dialog

import { HazoUiDialog } from 'hazo_ui';
import { useState } from 'react';

function FormDialog() {
  const [isOpen, setIsOpen] = useState(false);
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    role: '',
  });

  const handleSubmit = () => {
    console.log('Form data:', formData);
    setIsOpen(false);
  };

  return (
    <HazoUiDialog
      open={isOpen}
      onOpenChange={setIsOpen}
      title="New Employee Registration"
      description="Fill out the required fields"
      actionButtonText="Register"
      onConfirm={handleSubmit}
      sizeWidth="min(90vw, 700px)"
      sizeHeight="min(85vh, 900px)"
    >
      <div className="space-y-4">
        <div>
          <label className="text-sm font-medium">Full Name *</label>
          <input
            type="text"
            value={formData.name}
            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            className="w-full px-3 py-2 border rounded-md"
          />
        </div>

        <div>
          <label className="text-sm font-medium">Email *</label>
          <input
            type="email"
            value={formData.email}
            onChange={(e) => setFormData({ ...formData, email: e.target.value })}
            className="w-full px-3 py-2 border rounded-md"
          />
        </div>

        <div>
          <label className="text-sm font-medium">Role *</label>
          <select
            value={formData.role}
            onChange={(e) => setFormData({ ...formData, role: e.target.value })}
            className="w-full px-3 py-2 border rounded-md"
          >
            <option value="">Select a role</option>
            <option value="engineer">Software Engineer</option>
            <option value="designer">UI/UX Designer</option>
            <option value="manager">Product Manager</option>
          </select>
        </div>
      </div>
    </HazoUiDialog>
  );
}

Props Reference

Prop Type Default Description
open boolean - Controlled open state
onOpenChange (open: boolean) => void - Called when open state changes
children React.ReactNode Required Dialog body content
onConfirm () => void - Called when action button clicked
onCancel () => void - Called when cancel button clicked
title string "Action required" Dialog title
description string - Dialog description
actionButtonText string "Confirm" Action button label
actionButtonVariant ButtonVariant "default" Action button style
cancelButtonText string "Cancel" Cancel button label
showCancelButton boolean true Show cancel button
actionButtonLoading boolean false Shows loading spinner and disables action button
actionButtonDisabled boolean false Disables the action button
actionButtonIcon React.ReactNode - Icon element rendered before action button text
footerContent React.ReactNode - Custom footer content that replaces default buttons
sizeWidth string "min(90vw, 600px)" Dialog width
sizeHeight string "min(80vh, 800px)" Dialog max height (or fixed height when fixedSize is true)
fixedSize boolean false When true, sizeHeight becomes a fixed height instead of max height
openAnimation AnimationPreset | string "zoom" Open animation
closeAnimation AnimationPreset | string "zoom" Close animation
headerBackgroundColor string - Header background color
headerTextColor string - Header text color
bodyBackgroundColor string - Body background color
footerBackgroundColor string - Footer background color
borderColor string - Dialog border color
accentColor string - Action button background color
className string - Additional CSS classes
contentClassName string - Body CSS classes
overlayClassName string - Overlay CSS classes
headerClassName string - Header CSS classes
footerClassName string - Footer CSS classes
showCloseButton boolean true Show X close button

Drawer

A vaul-backed bottom sheet primitive. Intended for mobile UIs; on desktop, prefer Dialog.

import {
  Drawer,
  DrawerTrigger,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerDescription,
  DrawerFooter,
  DrawerClose,
  useMediaQuery,
} from "hazo_ui";

function Example() {
  const is_mobile = useMediaQuery("(max-width: 640px)");

  if (!is_mobile) {
    // Render a Dialog instead on desktop
    return null;
  }

  return (
    <Drawer>
      <DrawerTrigger asChild>
        <button>Open</button>
      </DrawerTrigger>
      <DrawerContent>
        <DrawerHeader>
          <DrawerTitle>Title</DrawerTitle>
          <DrawerDescription>Optional description</DrawerDescription>
        </DrawerHeader>
        <DrawerFooter>
          <DrawerClose asChild>
            <button>Close</button>
          </DrawerClose>
        </DrawerFooter>
      </DrawerContent>
    </Drawer>
  );
}

Behavior:

  • Drag the grab handle downward to dismiss
  • ESC closes
  • Click overlay to dismiss
  • max-h-[96dvh] keeps the sheet usable on iOS Safari when the address bar toggles
  • Focus trap, aria-labelledby, and background aria-hidden are wired automatically by vaul

Not yet supported (deferred): snap points, side-anchored drawers, nested drawers.


State Primitives

Components and hooks for the four ubiquitous async states. All are exported flat (unprefixed) from the package root — import { Skeleton, EmptyState, ErrorBanner, ErrorPage, LoadingTimeout, ProgressiveImage, HazoUiToaster, successToast, errorToast, useLoadingState, useErrorDisplay } from "hazo_ui".

Skeleton

Shimmer placeholders for loading content. The shimmer animation respects prefers-reduced-motion (renders as a static grey block instead).

import { Skeleton, SkeletonCircle, SkeletonBar, SkeletonRect, SkeletonGroup } from "hazo_ui";

<SkeletonGroup label="Loading user profile">
  <div className="flex items-center gap-3">
    <SkeletonCircle size={40} />
    <div className="flex-1 space-y-2">
      <SkeletonBar width="60%" height={14} />
      <SkeletonBar width="40%" height={10} />
    </div>
  </div>
  <SkeletonRect height={120} radius={8} />
</SkeletonGroup>
Variant Props
Skeleton All standard <div> props. Base shimmer block.
SkeletonCircle size?: number (default 40), className?: string
SkeletonBar width?: number | string (default "100%"), height?: number (default 12), className?: string
SkeletonRect width?, height?, radius?: number | string (default 6), className?: string
SkeletonGroup label?: string (default "Loading content"), children: ReactNode — wraps a region with role="status" + aria-busy + visually-hidden label.

EmptyState

Standardized empty-list / no-data / no-results display.

import { EmptyState, Button } from "hazo_ui";
import { InboxIcon } from "lucide-react";

<EmptyState
  icon={<InboxIcon />}
  title="No messages yet"
  description="When you receive a message, it'll show up here."
  action={<Button onClick={onCompose}>Send your first message</Button>}
  size="md"
/>
Prop Type Default Description
title string required Main heading
icon ReactNode Icon element (recommended 48×48)
description ReactNode Secondary description
action ReactNode CTA region (typically a <Button>)
size "sm" | "md" | "lg" "md" Visual size for inline cards (sm) vs full pages (lg)
className string Additional classes

ErrorBanner

Inline error or warning strip. role="alert", with aria-live="assertive" for errors / "polite" for warnings.

import { ErrorBanner, Button } from "hazo_ui";

<ErrorBanner
  severity="error"
  title="Couldn't save your changes"
  message="We hit a network error. Your draft is safe locally."
  action={<Button size="sm" onClick={onRetry}>Retry</Button>}
  onDismiss={() => setBannerVisible(false)}
/>

<ErrorBanner severity="warning" message="Your session expires in 2 minutes." />
Prop Type Default Description
message ReactNode required Body text
severity "warning" | "error" "error" Drives colour & icon
title string Bold heading above the message
icon ReactNode auto Override the auto-selected AlertTriangle / OctagonAlert
action ReactNode CTA region (typically a <Button>)
onDismiss () => void When provided, renders a dismiss X button
className string Additional classes

ErrorPage

Full-page error fallback, ideal for route-level error boundaries.

import { ErrorPage, Button } from "hazo_ui";

<ErrorPage
  title="Something went wrong"
  description="We couldn't load this page. The issue has been reported."
  errorCode="500"
  correlationId="req_a8f3c12e4d"
  actions={
    <>
      <Button onClick={onRetry}>Try again</Button>
      <Button variant="outline" onClick={onGoHome}>Go home</Button>
    </>
  }
/>
Prop Type Default Description
title string "Something went wrong" Main heading
description ReactNode Explanation paragraph(s)
errorCode string Short symbolic code rendered as a tag ("500", "NOT_FOUND")
correlationId string Correlation id (typically from hazo_logs) — rendered in a copyable mono block
actions ReactNode CTA region
illustration ReactNode <OctagonAlert /> Override the default icon
className string Additional classes

LoadingTimeout

Wraps a loading region and escalates messaging if the load takes too long. Four phases:

Phase When What renders
silent 0 – 5s The provided skeleton (no message)
gentle 5s – 15s Skeleton + "Loading {label}…"
firm 15s – 30s Skeleton + "Still working on it — almost there."
expired 30s+ <ErrorBanner severity="error"> with a "Try again" button
import { LoadingTimeout, SkeletonGroup, SkeletonBar } from "hazo_ui";

<LoadingTimeout
  active={isLoading}
  label="dashboard"
  onRetry={refetch}
  skeleton={
    <SkeletonGroup>
      <SkeletonBar width="80%" />
      <SkeletonBar width="60%" />
    </SkeletonGroup>
  }
>
  <Dashboard data={data} />
</LoadingTimeout>
Prop Type Default Description
active boolean required When true, runs the timeout escalation; when false, renders children
children ReactNode Content shown when active is false
skeleton ReactNode Placeholder rendered during the silent/gentle/firm phases
onRetry () => void Called when the user clicks Retry in the expired phase
thresholds { gentle?, firm?, expired? } {5000, 15000, 30000} Override timeout thresholds (ms)
label string "content" Used in gentle/firm/expired messages
className string Additional classes

ProgressiveImage

Three-stage image render: grey placeholder → blurred low-quality image (lqip) → sharp final image. Sets a stable container box so layout doesn't shift.

import { ProgressiveImage } from "hazo_ui";

<ProgressiveImage
  src="/photos/large.jpg"
  lqip="data:image/jpeg;base64,/9j/4AAQ…"
  alt="Sunset over the lake"
  width={400}
  height={300}
  fit="cover"
  loading="lazy"
/>
Prop Type Default Description
src string required Final image src
alt string required Alt text (empty string allowed for decorative)
width number | string required Container width (px or any CSS length)
height number | string required Container height
lqip string Low-quality placeholder (data URL or tiny image URL)
loading "eager" | "lazy" "lazy" Native loading attribute
fit "cover" | "contain" | "fill" | "none" | "scale-down" "cover" Object-fit for the final image
onLoad () => void Called when the final image loads
onError () => void Called if the final image errors
className string Additional classes

Toasts (HazoUiToaster + successToast / errorToast)

A sonner-backed toaster plus two opinionated imperative helpers.

Mount the toaster once near the root of your app:

import { HazoUiToaster } from "hazo_ui";

export default function RootLayout({ children }) {
  return (
    <>
      {children}
      <HazoUiToaster position="bottom-right" />
    </>
  );
}

Fire toasts imperatively from anywhere:

import { successToast, errorToast } from "hazo_ui";

await save();
successToast({ title: "Saved", description: "Your changes have been published." });

try { await sync(); } catch (e) {
  errorToast({
    title: "Sync failed",
    description: "Check your connection and try again.",
    action: { label: "Retry", onClick: () => sync() },
  });
}

HazoUiToaster props:

Prop Type Default
position "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center" "bottom-right"
closeButton boolean true
visibleToasts number 5

ToastOptions (for successToast / errorToast):

Prop Type Default
title string required
description string
duration number (ms) 3000 success / 5000 error
action { label: string; onClick: () => void }

Also exports rawToast (re-export of sonner's toast) for advanced use cases.

useLoadingState

Hook that returns a controlled loading flag plus an async wrapper.

import { useLoadingState } from "hazo_ui";

const { isLoading, setLoading, withLoading } = useLoadingState(false);

async function onSubmit() {
  await withLoading(async () => {
    await api.save(data);
  });
}

return <Button disabled={isLoading} onClick={onSubmit}>{isLoading ? "Saving…" : "Save"}</Button>;

Returns { isLoading: boolean; setLoading: (v: boolean) => void; withLoading: <T>(fn: () => Promise<T>) => Promise<T> }. withLoading sets the flag to true before the call and clears it in a finally block — even on errors.

useErrorDisplay

Passive error state. Coerces Error instances to their .message, strings pass through, anything else is String()-cast.

import { useErrorDisplay, ErrorBanner } from "hazo_ui";

const { error, setError, clearError } = useErrorDisplay();

try { await save(); } catch (e) { setError(e); }

return error ? <ErrorBanner message={error} onDismiss={clearError} /> : null;

Returns { error: string | null; setError: (v: unknown) => void; clearError: () => void }. Pass null (or any nullish value) to setError to clear.


HazoUiKanban — Drag-Drop Kanban (v2.13.0+)

Drag-drop board with mobile tab-strip / desktop column-grid layouts, optimistic updates with revert, theme-able priority borders, and keyboard-driven DnD. Wraps @dnd-kit internally.

Minimal usage

import {
  HazoUiKanban,
  HazoUiKanbanFilter,
  applyKanbanFilter,
  type KanbanColumn,
  type KanbanFilterValue,
} from 'hazo_ui';

const COLUMNS: KanbanColumn[] = [
  { key: 'todo',        title: 'To Do' },
  { key: 'in_progress', title: 'In Progress' },
  { key: 'blocked',     title: 'Blocked' },
  { key: 'done',        title: 'Done' },
];

function Actions({ initial }) {
  const [items, setItems] = useState(initial);
  const [filter, setFilter] = useState<KanbanFilterValue>({
    search: '', categories: [], priority: null,
  });
  const visible = useMemo(() => applyKanbanFilter(items, filter), [items, filter]);

  return (
    <>
      <HazoUiKanbanFilter
        categories={['On-page SEO', 'Technical SEO', 'Content']}
        priorities={['P0', 'P1', 'P2', 'P3']}
        value={filter}
        onChange={setFilter}
      />
      <HazoUiKanban
        columns={COLUMNS}
        items={visible}
        renderCard={(action) => <ActionCard action={action} />}
        itemLabel={(action) => `action ${action.id}`}
        onMove={async (event) => {
          try {
            const res = await fetch(`/api/v1/actions/${event.itemId}`, {
              method: 'PATCH',
              body: JSON.stringify({ status: event.toColumn }),
            });
            if (!res.ok) throw new Error('PATCH failed');
            setItems(prev => prev.map(it =>
              it.id === event.itemId ? { ...it, columnKey: event.toColumn } : it,
            ));
          } catch {
            event.revert();
          }
        }}
      />
    </>
  );
}

Optimistic update + revert

onMove fires after the library has already moved the card visually. The event carries revert() — call it when your API request fails and the overlay snaps back to whatever items says.

Theming (CSS custom properties)

Variable Default Purpose
--hazo-kanban-priority-p0 0 84% 60% (red) Left border for P0 cards
--hazo-kanban-priority-p1 45 93% 55% (yellow) Left border for P1 cards
--hazo-kanban-priority-p2 217 91% 60% (blue) Left border for P2 cards
--hazo-kanban-priority-p3 220 9% 64% (grey) Left border for P3 cards
--hazo-kanban-card-bg var(--card) Card background
--hazo-kanban-card-border var(--border) Card border
--hazo-kanban-column-gap 0.75rem Gap between columns

Values are HSL channels (no hsl() wrapper) to match the shadcn theme convention. Custom priorities like 'critical' work — define --hazo-kanban-priority-critical and pass priority: 'critical'.

Visual reference

Run npm run dev:test-app and visit /board for five demo scenarios (default kanban, controlled / uncontrolled filter, keyboard-only flow, and optimistic + revert).

Out-of-the-box card editor

Each card carries a pencil icon (top-right, opacity-0 idle, opacity-100 on hover/focus). Clicking it opens a HazoUiDialog editor:

<HazoUiKanban
  columns={COLUMNS}
  items={items}
  renderCard={(action) => <ActionCard action={action} />}
  // Declarative field config — library renders one row per declaration.
  editorFields={[
    { key: 'title',    type: 'textarea', required: true },
    { key: 'category', type: 'select',   options: CATEGORIES },
    { key: 'priority', type: 'priority' },
  ]}
  onCardSave={async (event) => {
    const res = await fetch(`/api/v1/actions/${event.itemId}`, {
      method: 'PATCH',
      body: JSON.stringify(event.next),
    });
    if (!res.ok) throw new Error('PATCH failed');
    setItems(prev => prev.map(it =>
      it.id === event.itemId ? event.next : it,
    ));
  }}
/>

Field types: text, textarea, select, number, checkbox, priority (a select pre-populated with editorPriorities — defaults to ["P0","P1","P2","P3"]).

If you don't pass editorFields, the library auto-detects all string-valued fields on the item (except id, columnKey, priority) and renders each as a text input with a humanized label.

Custom form via renderCardEditor

When the declarative config isn't enough, replace the dialog body with your own form:

<HazoUiKanban
  // ...
  renderCardEditor={(item, ctx) => (
    <MyComplexForm
      value={ctx.draft}
      onChange={(next) => ctx.setDraft(next)}
      saving={ctx.saving}
      error={ctx.error}
    />
  )}
  onCardSave={async (event) => { /* ... */ }}
/>

ctx provides draft, setDraft, save(), close(), saving, error, and isDirty. The library still renders the dialog shell, title, and the default Save/Cancel footer. To render your own buttons too, pass hideEditorFooter={true} and call ctx.save()/ctx.close() from within your form.

Save lifecycle

onCardSave returns void | Promise<void>. A returned Promise gates the dialog close and shows a Saving… spinner; a rejected Promise keeps the dialog open with ctx.error populated. Consumer is responsible for updating items on success — symmetric with onMove.

If onCardSave is not provided, the pencil is hidden entirely (read-only kanban). To explicitly hide editing without removing onCardSave, pass disableEdit={true}.


HazoUiTable — Column-config-driven Data Table (v2.14.0+, v2.15 additions)

A higher-level data table that composes the shadcn Table primitive family with the existing HazoUiMultiSortDialog / HazoUiMultiFilterDialog and an in-memory filter / sort / paginate pipeline.

import { HazoUiTable, type TableColumn } from 'hazo_ui';

interface Run {
  id: string;
  date: Date;
  status: 'ok' | 'fail';
  count: number;
}

const columns: TableColumn<Run>[] = [
  { key: 'date',   label: 'Date',   sortable: true, formatter: 'date', filterType: 'date' },
  {
    key: 'status',
    label: 'Status',
    sortable: true,
    filterType: 'combobox',
    filterConfig: {
      comboboxOptions: [
        { label: 'OK',   value: 'ok'   },
        { label: 'Fail', value: 'fail' },
      ],
    },
  },
  { key: 'count', label: 'Count', sortable: true, formatter: 'number', align: 'right' },
  // v2.15+ — column-level currency override (defaults to USD)
  { key: 'revenue', label: 'Revenue', sortable: true, formatter: 'currency', currency: 'EUR', align: 'right' },
];

<HazoUiTable<Run>
  columns={columns}
  rows={runs}
  getRowKey={(r) => r.id}
  enableSearch
  enableSortDialog
  enableFilterDialog
  pagination={{ pageSize: 25 }}
  onRowClick={(r) => router.push(`/runs/${r.id}`)}
/>

Features:

  • Header click cycles asc → desc → none. Shift-click a second header to append it as a secondary sort (v2.15). Multi-column sort also available via the optional sort dialog.
  • Free-text search across string-typed columns (debounced 200 ms).
  • Per-column filters via the filter dialog — declarable per column.
  • Loading state via SkeletonBar, empty / no-results via EmptyState.
  • Pagination footer with Prev / Next.
  • Row click with mouse and keyboard (Enter / Space) when onRowClick is set.
  • Mobile card-per-row fallback below mobileBreakpoint (default 768 px). Opt out with mobileCardFallback={false}.
  • Optional onLoad({ page, sort, filter }) for server-driven pagination — latest-request-wins.
  • Structured error UX (v2.15): pass error={node | (err) => node} to render a custom failure state when onLoad rejects; otherwise the table falls back to an EmptyState showing the thrown message.
  • Per-column currency (v2.15) for ISO-4217 codes other than USD.

The package also re-exports the bare shadcn primitives — Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption — for consumers that prefer raw markup.


Chart Primitives (v2.17.0)

A set of dependency-free, pure-SVG chart components for KPI cards, trend visualisations, and time-series dashboards. No recharts, no chart.js, no canvas — every chart is a flat <svg> whose math is computed in React. Hover tooltips and gap-aware paths are built in.

import {
  Sparkline,
  InverseSparkline,
  LineChart,
  MultiLineChart,
  StackedBars,
  DateRangeSelector,
  format_num,
  pick_x_label_indices,
  type ChartDataSeries,
  type MultiSeries,
  type StackedBar,
  type DateRangeOption,
} from 'hazo_ui';

Sparkline

Axisless single-series trend line for KPI cards. Stretches edge-to-edge across the parent (width: 100%), 12% area fill, 2px endpoint dot at the latest non-null value. By design no hover — the numeric value belongs above the sparkline in the KPI card itself.

<Sparkline data={[2, 3, 5, 4, 7, 9, 12]} color="#10b981" height={40} />

Props:

Prop Type Default Description
data (number | null)[] Series. null entries are skipped.
color string Stroke, dot, and area-fill color.
height number 40 Pixel height. Width is always 100%.
className string Container className passthrough.

InverseSparkline

Same shape as Sparkline, but the Y-axis is inverted — lower values plot higher on the screen. Built for GSC average-position trends (rank 1 = best). Fixed 62×18 default to fit inline in scrollable query lists. No area fill, no dot — the line direction is the only signal.

<InverseSparkline data={[12, 10, 8, 5, 3]} color="#3b82f6" />

Props:

Prop Type Default Description
data (number | null)[] Series. null entries are skipped.
color string Stroke color.
width number 62 Pixel width.
height number 18 Pixel height.
className string Container className passthrough.

LineChart

Full-anatomy single-series chart: three dashed gridlines at 0/50/100% of plot height, Y-axis ticks (max/mid/min) at the left, three X-axis labels (start/mid/end) below, marker dot at the last data point with a dashed guide-line back to the Y-axis and a value label rendered above-left.

Hover the plot area to surface a vertical cursor line and a value bubble at the nearest data index. Pass showTooltip={false} for static use (print export, embeds).

<LineChart
  data={[12, 14, 18, 20, 25, 30, 36, 42]}
  dates={['May 1', 'May 2', 'May 3', 'May 4', 'May 5', 'May 6', 'May 7', 'May 8']}
  color="#10b981"
  unit="%"
/>

Props:

Prop Type Default Description
data (number | null)[] Series. null entries are gaps in the line.
dates string[] X-axis label per data point (length should match data).
color string Stroke / area / marker color.
width number 360 viewBox width.
height number 130 viewBox height.
unit string Suffix on the current-value label (e.g. "%").
showTooltip boolean true Disable hover tooltip when false.
className string Container className passthrough.

MultiLineChart

Multiple lines sharing a Y-axis. No area fill — multi-series uses lines only so colors don't muddy each other. Per-series endpoint marker + value label keeps the latest reading visible without hovering. Hover surfaces one cursor with a stacked bubble showing every series value at that index. Optional legend below the SVG.

<MultiLineChart
  series={[
    { label: 'Indexed', color: '#10b981', data: [1, 2, 5, 12, 26, 48, 62] },
    { label: 'Crawled', color: '#f59e0b', data: [3, 4, 8, 15, 22, 28, 30] },
  ]}
  dates={['May 1', 'May 2', 'May 3', 'May 4', 'May 5', 'May 6', 'May 7']}
/>

Props:

Prop Type Default Description
series MultiSeries[] One entry per line (label, color, data).
dates string[] X-axis labels.
width number 360 viewBox width.
height number 140 viewBox height.
showTooltip boolean true Disable hover tooltip when false.
showLegend boolean true Render legend below the chart.
className string Container className passthrough.

StackedBars

Generic stacked-bar over time. One bar per X position; each bar split top-to-bottom into colored bands defined by segments. Good for distribution-over-time visuals (GSC position buckets, traffic-source mix, category breakdowns).

<StackedBars
  bars={[
    { label: 'May 1', segments: [
      { value: 4, color: '#10b981' },
      { value: 6, color: '#3b82f6' },
      { value: 2, color: '#f59e0b' },
    ]},
    // ...one entry per bar
  ]}
/>

Props:

Prop Type Default Description
bars StackedBar[] One entry per column; segments are top-to-bottom.
width number 360 viewBox width.
height number 140 viewBox height.
showYAxis boolean true Render Y-axis ticks (max / mid / 0).
className string Container className passthrough.

DateRangeSelector

Framework-agnostic segmented control (value + onChange) for picking a chart's time window. The active pill is tinted with the --primary CSS variable; inactive pills use muted foreground colors. No router coupling — wire the value into URL state, local state, or any store yourself.

const [range, setRange] = React.useState('30d');

<DateRangeSelector
  value={range}
  onChange={setRange}
  options={[
    { value: '7d',  label: '7d'  },
    { value: '30d', label: '30d' },
    { value: '90d', label: '90d' },
  ]}
/>

Props:

Prop Type Default Description
value string Currently selected option value.
onChange (value: string) => void Fires when the user picks an option.
options DateRangeOption[] { value, label } pairs.
ariaLabel string "Date range" Aria label for the button group.
className string Container className passthrough.

Helpers

Two pure helpers are exported for callers that want to format values or pick label positions consistently with the charts:

format_num(1234);     // "1.2k"
format_num(1_500_000); // "1.5M"
format_num(5.73);     // "5.7"
format_num(null);     // ""

pick_x_label_indices(14); // [0, 7, 13]  — start / mid / end

Types

type ChartDataPoint = number | null;
type ChartDataSeries = ChartDataPoint[];

interface MultiSeries {
  label: string;
  color: string;
  data: ChartDataSeries;
}

interface StackedBar {
  label: string;
  segments: { value: number; color: string }[];
}

interface DateRangeOption {
  value: string;
  label: string;
}

Notes

  • Null handling. A null entry in any series is a gap — the line breaks rather than connecting around the missing point. The Y range is computed from non-null values only, so one missing day won't deform the chart.
  • Axis label color is pinned. Gridlines (#2a3441) and axis labels (#8b949e) are hardcoded for dark-themed dashboards. Theming via CSS variables is a follow-up if a second consumer needs it.
  • All charts are Client Components. The library bundle is "use client"-stamped at build time, so charts work in any Next.js app but don't get RSC-rendered.
  • Hover assumes width: 100%. LineChart / MultiLineChart map clientX to a viewBox X via getBoundingClientRect(). CSS transforms on the wrapper (scale(...)) will desync the mapping.

Celebration Modal

Fire a confetti overlay with an optional 1080×1080 shareable card from anywhere in your app.

Setup

Mount <CelebrationProvider /> once at your app root:

// app/layout.tsx
import { CelebrationProvider } from "hazo_ui";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <CelebrationProvider>
          {children}
        </CelebrationProvider>
      </body>
    </html>
  );
}

Usage

import { celebrate, CELEBRATION_GRADIENT } from "hazo_ui";

// Basic celebration
celebrate({
  id: "first_login",       // sessionStorage dedup key — shown once per session
  title: "Welcome!",
  subtitle: "Your account is ready.",
});

// With shareable card
celebrate({
  id: "milestone_100",
  title: "100 entries!",
  subtitle: "You just crossed 100 entries.",
  autoDismissDelay: 10_000,
  shareableCard: {
    background: CELEBRATION_GRADIENT,  // or any CSS background value
    foreground: <YourCardContent />,
    caption: "100 entries logged",     // optional; defaults to subtitle
  },
  audioChime: true,
});

Session gating

celebrate() is a no-op if hazo_ui_celebration_<id> is already set in sessionStorage. The key is written when the modal opens. Gating resets when the user opens a new tab or browser session.

Cross-session dedup is the consumer's responsibility. Check your server-side milestone state before calling celebrate().

API

Prop Type Default Description
id string required Dedup key. Used as hazo_ui_celebration_<id> in sessionStorage.
title string required Modal heading.
subtitle string Subheading; also used as card caption fallback.
shareableCard CelebrationShareableCard Enables card preview + download/share/copy buttons.
autoDismiss boolean true Auto-close after autoDismissDelay ms.
autoDismissDelay number 8000 Ms until auto-dismiss.
audioChime boolean false Play bundled success chime on open.

Test Harness (v3.0.0)

hazo_ui/test-harness is a dedicated sub-export that ships a complete in-app test runner for consuming packages. It is never included in the main hazo_ui bundle — only imported by test-app code.

Setup

// test-app/app/layout.tsx
import { SidebarLayout, AppSidebar, AutoTestProvider } from 'hazo_ui/test-harness';
import type { NavItem } from 'hazo_ui/test-harness';

const nav: NavItem[] = [
  { href: '/', label: 'Overview' },
  { href: '/my-feature', label: 'My Feature' },
];

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <AutoTestProvider>
          <SidebarLayout sidebar={<AppSidebar nav={nav} title="my_pkg" />}>
            {children}
          </SidebarLayout>
        </AutoTestProvider>
      </body>
    </html>
  );
}

Registering scenarios

// test-app/scenarios/my_feature.ts
import { registerScenario, assertEqual } from 'hazo_ui/test-harness';

registerScenario({
  id: 'my_feature',
  name: 'My Feature',
  pkg: 'my_pkg',
  cases: [
    {
      name: 'returns correct value',
      run: async () => {
        const result = myFunction(1, 2);
        assertEqual(result, 3, 'should add two numbers');
      },
    },
    {
      name: 'throws on invalid input',
      run: async () => {
        await assertThrows(() => myFunction(null, 2), 'invalid input');
      },
    },
  ],
});

Rendering the runner

// test-app/app/my-feature/page.tsx
import { AutoTestRunner } from 'hazo_ui/test-harness';
import '../scenarios/my_feature'; // registers the scenario

export default function MyFeaturePage() {
  return <AutoTestRunner scenarioId="my_feature" />;
}

Copying failures as a Claude prompt

CopyAllFailuresButton copies all failed cases as a structured prompt with 8 sections (what-went-wrong, expected/actual/diff, test code, code under test, error chain, context, ring buffer). Place it in your sidebar or test page header.

import { CopyAllFailuresButton } from 'hazo_ui/test-harness';

// Inside your layout or sidebar:
<CopyAllFailuresButton />

Assertions

Function Description
assertEqual(actual, expected, msg?) Deep-equal assertion
assertThrows(fn, msgOrPattern?) Sync function must throw
assertResolves(promise) Promise must resolve
assertRejects(promise, msgOrPattern?) Promise must reject
assertMatch(value, pattern) String must match regex or substring
assertIncludes(arr, item) Array must include item

All failures throw HazoAssertionError with a structured message.


Troubleshooting

Styles not applying (Tailwind v4)

If you're using Tailwind CSS v4 and components appear unstyled:

  1. Add the @source directive (and tw-animate-css) to your globals.css:

    @import "tailwindcss";
    @import "tw-animate-css";
    @source "../node_modules/hazo_ui/dist";
  2. Ensure you've imported the CSS variables:

    import 'hazo_ui/styles.css';

Missing hover states or colors

If hover states are transparent or colors don't appear:

  • Tailwind v4 users: Add the @source directive (see above)
  • Tailwind v3 users: Verify ./node_modules/hazo_ui/dist/**/*.js is in your content array

Missing CSS variables

If you see errors about missing CSS variables, ensure you've either:

  • Imported hazo_ui/styles.css in your app entry point
  • Or configured your own CSS variables following the shadcn/ui pattern

TypeScript errors

Ensure your tsconfig.json includes:

{
  "compilerOptions": {
    "moduleResolution": "bundler"
  }
}

Select dropdowns clipped in dialogs

If Select dropdown options are cut off when used inside a Dialog:

/* Add to your globals.css */
[data-slot="dialog-content"]:has([data-slot="select-content"]),
[data-slot="dialog-content"]:has([data-state="open"][data-slot="select-trigger"]) {
  overflow: visible !important;
}

[data-slot="select-content"] {
  z-index: 9999 !important;
}

Command pills not appearing (HazoUiTextbox/HazoUiTextarea)

  1. Ensure prefixes prop is properly configured with char and commands arrays
  2. Verify you're typing the correct prefix character (e.g., @, #, /)
  3. Check that @tiptap/react and related extensions are installed

Styling

Both components use Tailwind CSS and follow shadcn/ui design patterns. Make sure your project has Tailwind CSS configured with the following CSS variables:

:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 222.2 47.4% 11.2%;
  --primary-foreground: 210 40% 98%;
  --secondary: 210 40% 96.1%;
  --secondary-foreground: 222.2 47.4% 11.2%;
  --muted: 210 40% 96.1%;
  --muted-foreground: 215.4 16.3% 46.9%;
  --accent: 210 40% 96.1%;
  --accent-foreground: 222.2 47.4% 11.2%;
  --destructive: 0 84.2% 60.2%;
  --destructive-foreground: 210 40% 98%;
  --border: 214.3 31.8% 91.4%;
  --input: 214.3 31.8% 91.4%;
  --ring: 222.2 84% 4.9%;
  --radius: 0.5rem;
}

See the component library's Tailwind config for the complete set of CSS variables.

Development

Build the library

npm run build

Run dev app

The dev-app provides a comprehensive testing environment with dedicated pages for each component:

npm run dev:app

Navigate to http://localhost:3000 to access:

  • Home - Library overview and quick start guide
  • Component pages - Individual test pages with multiple test cases for each component
  • Sidebar navigation - Easy navigation between all component tests

Each component page includes:

  • Multiple test sections covering different configurations
  • Real-time state display
  • Interactive examples

Test before publishing

npm run test:build  # Build library + build dev-app
npm run test:dev    # Build library + run dev-app

License

MIT