IGrow Group of Companies

React Component Library

← Design System
/**
 * IGrow Design System — React Component Library
 *
 * Single-file component set. Import the components you need and the
 * companion CSS (system/components.css) into your app entry point.
 *
 *   // app.jsx
 *   import "igrow-web-design-system/system/components.css";
 *   import { Button, PropertyCard, Hero } from "igrow-web-design-system/react";
 *
 * Notes:
 *   - These components are PRESENTATIONAL — they render markup and accept
 *     props. Behaviour (data fetching, form submit, modal-open state) is
 *     up to the consuming app.
 *   - All accept a `className` prop that's MERGED, not replaced, so you
 *     can add layout classes from your app.
 *   - JSX, not TSX. Convert at your project boundary if you use TS.
 */

import React from 'react';

/** Tiny class-name joiner so we don't pull in classnames/clsx as a dep. */
const cx = (...xs) => xs.filter(Boolean).join(' ');

/* ──────────────────────────────────────────────────────────────────
 * BUTTON
 * ────────────────────────────────────────────────────────────────── */
export function Button({
  variant = 'primary',  // primary | secondary | ghost | inverse | danger
  size,                 // sm | lg
  block,
  iconOnly,
  iconStart,
  iconEnd,
  className,
  children,
  as: Tag = 'button',
  ...rest
}) {
  return (
    <Tag
      className={cx(
        'ig-btn',
        `ig-btn--${variant}`,
        size && `ig-btn--${size}`,
        block && 'ig-btn--block',
        iconOnly && 'ig-btn--icon-only',
        className,
      )}
      {...rest}
    >
      {iconStart && <span className="ig-btn__icon" aria-hidden="true">{iconStart}</span>}
      {children}
      {iconEnd && <span className="ig-btn__icon" aria-hidden="true">{iconEnd}</span>}
    </Tag>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * FORM FIELDS
 * ────────────────────────────────────────────────────────────────── */
export function Field({ label, required, helper, error, success, children, className, htmlFor }) {
  const id = htmlFor || (children && children.props && children.props.id);
  return (
    <div className={cx('ig-field', error && 'ig-field--error', success && 'ig-field--success', className)}>
      {label && (
        <label htmlFor={id} className="ig-field__label" data-required={required || undefined}>
          {label}
        </label>
      )}
      {children}
      {helper && !error && <span className="ig-field__helper">{helper}</span>}
      {error && <span className="ig-field__error" role="alert">{error}</span>}
    </div>
  );
}

export const Input    = React.forwardRef((p, ref) => <input    ref={ref} {...p} className={cx('ig-input', p.className)} />);
export const Textarea = React.forwardRef((p, ref) => <textarea ref={ref} {...p} className={cx('ig-textarea', p.className)} />);
export const Select   = React.forwardRef(({ children, ...p }, ref) => (
  <select ref={ref} {...p} className={cx('ig-select', p.className)}>{children}</select>
));

export function Checkbox({ label, ...rest }) {
  return (
    <label className="ig-check">
      <input type="checkbox" className="ig-check__input" {...rest} />
      <span>{label}</span>
    </label>
  );
}

// Switch — native checkbox with role="switch" so AT announces it as
// "switch, on/off" rather than "checkbox, checked/not checked".
export function Switch({ label, ...rest }) {
  return (
    <label className="ig-switch">
      <input type="checkbox" role="switch" {...rest} />
      <span className="ig-switch__track" aria-hidden="true" />
      <span>{label}</span>
    </label>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * CARDS
 * ────────────────────────────────────────────────────────────────── */
export function Card({ bordered, interactive, className, children, ...rest }) {
  return (
    <article
      className={cx('ig-card', bordered && 'ig-card--bordered', interactive && 'ig-card--interactive', className)}
      {...rest}
    >
      {children}
    </article>
  );
}

export function CardMedia({ src, alt = '', badge, badgeVariant, children }) {
  return (
    <div className="ig-card__media">
      {badge && <span className={cx('ig-card__badge', badgeVariant && `ig-card__badge--${badgeVariant}`)}>{badge}</span>}
      {src && <img src={src} alt={alt} loading="lazy" />}
      {children}
    </div>
  );
}

/**
 * PropertyCard — closely matches the rentals.igrow.co.za pattern.
 *
 * Usage:
 *   <PropertyCard
 *     image="/uploads/2026/04/310_abc.jpg"
 *     type="Apartment"
 *     title="1 Bedroom Apartment To Rent in Witfield"
 *     price="R 7 000"
 *     address="1 Unit 309, Cnr Sett Street"
 *     excerpt="…"
 *     specs={[{label:'Bed', value:'1'}, {label:'Bath', value:'1'}, {label:'m²', value:'44'}]}
 *     petsAllowed
 *     href="/properties/…"
 *   />
 */
export function PropertyCard({
  image, type, title, price, address, excerpt,
  specs = [], petsAllowed, href, className,
}) {
  const inner = (
    <Card interactive className={cx('ig-property-card', className)}>
      <CardMedia src={image} alt={title} badge={type} />
      <div className="ig-card__body">
        <h3 className="ig-card__title">{title}</h3>
        {price && <p className="ig-property-card__price">{price}</p>}
        {address && <p className="ig-property-card__address">{address}</p>}
        {excerpt && <p className="ig-card__excerpt">{excerpt}</p>}
        {specs.length > 0 && (
          <ul className="ig-property-card__specs">
            {specs.map((s, i) => <li key={i}>{s.value} {s.label}</li>)}
            {petsAllowed && <li>Pets allowed</li>}
          </ul>
        )}
      </div>
    </Card>
  );
  if (href) {
    return (
      <a href={href} style={{ textDecoration: 'none', color: 'inherit', display: 'flex', height: '100%' }}>
        {inner}
      </a>
    );
  }
  return inner;
}

export function StatCard({ label, value, delta, deltaDirection }) {
  return (
    <article className="ig-stat-card">
      <span className="ig-stat-card__label">{label}</span>
      <span className="ig-stat-card__value">{value}</span>
      {delta && (
        <span className={cx('ig-stat-card__delta', deltaDirection === 'down' && 'ig-stat-card__delta--down')}>
          {delta}
        </span>
      )}
    </article>
  );
}

export function Testimonial({ quote, author, rating = 5 }) {
  return (
    <article className="ig-testimonial">
      <div className="ig-testimonial__rating" aria-label={`${rating} out of 5 stars`}>
        {'★'.repeat(rating)}{'☆'.repeat(5 - rating)}
      </div>
      <blockquote className="ig-testimonial__quote">{quote}</blockquote>
      <footer className="ig-testimonial__author">— {author}</footer>
    </article>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * NAVIGATION
 * ────────────────────────────────────────────────────────────────── */
export function Nav({ logoSrc, logoAlt = 'IGrow', links = [], ctaLabel, ctaHref, onMenuOpen }) {
  return (
    <nav className="ig-nav" aria-label="Primary">
      <div className="ig-container ig-nav__inner">
        <a className="ig-nav__brand" href="/"><img src={logoSrc} alt={logoAlt} /></a>
        <ul className="ig-nav__links">
          {links.map((l) => (
            <li key={l.href}>
              <a
                className="ig-nav__link"
                href={l.href}
                aria-current={l.current ? 'page' : undefined}
              >
                {l.label}
              </a>
            </li>
          ))}
        </ul>
        {ctaLabel && (
          <a className="ig-btn ig-btn--primary ig-nav__cta" href={ctaHref}>{ctaLabel}</a>
        )}
        <button
          type="button"
          className="ig-nav__toggle"
          aria-label="Open menu"
          onClick={onMenuOpen}
        >
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
          </svg>
        </button>
      </div>
    </nav>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * HERO
 * ────────────────────────────────────────────────────────────────── */
export function Hero({
  background,            // image URL
  eyebrow,
  title,
  lead,
  actions,
  variant = 'rentals',   // rentals | dark
  compact,
  motif,
  motifSide = 'left',
  motifSrc = '/assets/banner-line-graphic.svg',
  className,
}) {
  return (
    <section
      className={cx(
        'ig-hero-section',
        variant === 'dark' && 'ig-hero-section--dark',
        compact && 'ig-hero-section--compact',
        className,
      )}
      style={background ? { backgroundImage: `url(${background})` } : undefined}
    >
      <div className="ig-container">
        {eyebrow && <span className="ig-hero-section__eyebrow">{eyebrow}</span>}
        {title && <h1 className="ig-hero-section__title">{title}</h1>}
        {lead && <p className="ig-hero-section__lead">{lead}</p>}
        {actions && <div className="ig-hero-section__actions">{actions}</div>}
      </div>
      {motif && (
        <img
          className={cx('ig-hero-section__motif', motifSide === 'right' && 'ig-hero-section__motif--right')}
          src={motifSrc}
          alt=""
        />
      )}
    </section>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * FOOTER (sub-brand variant prop)
 * ────────────────────────────────────────────────────────────────── */
export function Footer({
  subBrand = 'rentals',  // rentals | properties | accounting | home-loans | private-wealth | store
  logoSrc,
  intro,
  columns = [],          // [{heading, links: [{label, href}]}]
  social = [],           // [{label, href, icon: <svg/>}]
  legal,
}) {
  return (
    <footer className={cx('ig-footer', subBrand !== 'rentals' && `ig-footer--${subBrand}`)}>
      <div className="ig-container ig-footer__grid">
        <div className="ig-footer__brand">
          {logoSrc && <img src={logoSrc} alt="IGrow" />}
          {intro && <p className="ig-footer__intro">{intro}</p>}
          {social.length > 0 && (
            <div className="ig-footer__social" aria-label="Social media">
              {social.map((s) => (
                <a key={s.href} href={s.href} aria-label={s.label}>{s.icon}</a>
              ))}
            </div>
          )}
        </div>
        {columns.map((col, i) => (
          <div key={i}>
            <h4 className="ig-footer__heading">{col.heading}</h4>
            <ul className="ig-footer__list">
              {col.links.map((l) => <li key={l.href}><a href={l.href}>{l.label}</a></li>)}
            </ul>
          </div>
        ))}
      </div>
      <div className="ig-container ig-footer__legal">
        {legal}
      </div>
    </footer>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * MODAL — built on native <dialog>.
 *
 * The browser handles: focus trap, Escape-to-close (fires `cancel`),
 * automatic `inert` on the rest of the page, top-layer rendering.
 *
 * Prop API is unchanged from v2.0:
 *   open      boolean — controls showModal()/close()
 *   onClose   () => void — called on Escape, backdrop click, or × button
 *   title     string — rendered as <h2> + adds aria-labelledby implicitly
 *   footer    ReactNode — rendered in the modal footer
 * ────────────────────────────────────────────────────────────────── */
export function Modal({ open, onClose, title, footer, children }) {
  const ref = React.useRef(null);

  // Drive showModal()/close() from the `open` prop.
  React.useEffect(() => {
    const d = ref.current;
    if (!d) return;
    if (open && !d.open) {
      // Feature-detect for very old Safari (<15.4). Fall back to setAttribute.
      if (typeof d.showModal === 'function') d.showModal();
      else d.setAttribute('open', '');
    } else if (!open && d.open) {
      if (typeof d.close === 'function') d.close();
      else d.removeAttribute('open');
    }
  }, [open]);

  // Wire Escape (native `cancel` event) + backdrop click to onClose.
  React.useEffect(() => {
    const d = ref.current;
    if (!d) return;
    const handleCancel = (e) => { e.preventDefault(); onClose?.(); };
    const handleClick = (e) => { if (e.target === d) onClose?.(); };
    d.addEventListener('cancel', handleCancel);
    d.addEventListener('click', handleClick);
    return () => {
      d.removeEventListener('cancel', handleCancel);
      d.removeEventListener('click', handleClick);
    };
  }, [onClose]);

  return (
    <dialog ref={ref} className="ig-modal" aria-labelledby={title ? 'ig-modal-title' : undefined}>
      {title && (
        <header className="ig-modal__header">
          <h2 id="ig-modal-title" className="ig-modal__title">{title}</h2>
          <button type="button" className="ig-modal__close" aria-label="Close" onClick={onClose}>
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
          </button>
        </header>
      )}
      {children}
      {footer && <footer className="ig-modal__footer">{footer}</footer>}
    </dialog>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * ALERT
 * ────────────────────────────────────────────────────────────────── */
export function Alert({ variant = 'info', title, children, icon }) {
  return (
    <div className={cx('ig-alert', `ig-alert--${variant}`)} role={variant === 'danger' ? 'alert' : 'status'}>
      {icon && <span className="ig-alert__icon" aria-hidden="true">{icon}</span>}
      <div>
        {title && <p className="ig-alert__title">{title}</p>}
        <p className="ig-alert__body">{children}</p>
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * PAGINATION
 * Renders a windowed page list. Pass currentPage, totalPages, getHref.
 * ────────────────────────────────────────────────────────────────── */
export function Pagination({ currentPage, totalPages, getHref = (p) => `?pg=${p}` }) {
  if (totalPages <= 1) return null;
  const pages = [];
  const push = (p) => pages.push(p);

  push(1);
  if (currentPage > 3) push('…');
  for (let p = Math.max(2, currentPage - 1); p <= Math.min(totalPages - 1, currentPage + 1); p++) push(p);
  if (currentPage < totalPages - 2) push('…');
  if (totalPages > 1) push(totalPages);

  return (
    <nav aria-label="Pagination">
      <ol className="ig-pagination">
        {currentPage > 1 && (
          <li><a className="ig-pagination__link" href={getHref(currentPage - 1)} aria-label="Previous">‹</a></li>
        )}
        {pages.map((p, i) =>
          p === '…'
            ? <li key={`e${i}`} aria-hidden="true">…</li>
            : (
              <li key={p}>
                <a
                  className="ig-pagination__link"
                  href={getHref(p)}
                  aria-current={p === currentPage ? 'page' : undefined}
                >
                  {p}
                </a>
              </li>
            )
        )}
        {currentPage < totalPages && (
          <li><a className="ig-pagination__link" href={getHref(currentPage + 1)} aria-label="Next">›</a></li>
        )}
      </ol>
    </nav>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * BREADCRUMB
 * ────────────────────────────────────────────────────────────────── */
export function Breadcrumb({ items = [] }) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="ig-breadcrumb">
        {items.map((item, i) => {
          const isLast = i === items.length - 1;
          return (
            <li key={i} aria-current={isLast ? 'page' : undefined}>
              {isLast ? item.label : <a href={item.href}>{item.label}</a>}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * EMPTY STATE
 * ────────────────────────────────────────────────────────────────── */
export function EmptyState({ icon, title, body, action }) {
  return (
    <div className="ig-empty">
      {icon && <span className="ig-empty__icon" aria-hidden="true">{icon}</span>}
      {title && <h3 className="ig-empty__title">{title}</h3>}
      {body && <p className="ig-empty__body">{body}</p>}
      {action}
    </div>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * ICON — sprite-based Lucide wrapper.
 *
 * Two usage modes:
 *
 *   <Icon name="bed" />                      // sprite reference
 *   <Icon><path d="…"/></Icon>               // inline SVG body
 *
 * Both enforce the IGrow icon contract: 24×24 viewBox, currentColor,
 * stroke-width 1.75, rounded caps/joins.
 *
 * To use the sprite, set `IconSpriteUrl` once at app boot:
 *   import { setIconSpriteUrl } from "./components.jsx";
 *   setIconSpriteUrl("/wp-content/themes/your-child/igrow-web-design-system/system/icons/sprite.svg");
 *
 * If you prefer `lucide-react`, ignore this wrapper and import directly —
 * the size/strokeWidth contract is the same:
 *   <Bed size={24} strokeWidth={1.75} aria-hidden="true" />
 * ────────────────────────────────────────────────────────────────── */
let __spriteUrl = '/system/icons/sprite.svg';
export function setIconSpriteUrl(url) { __spriteUrl = url; }

export function Icon({
  name,
  size = 24,
  strokeWidth = 1.75,
  className,
  decorative = true,
  label,
  children,
  ...rest
}) {
  const a11y = decorative
    ? { 'aria-hidden': 'true' }
    : { role: 'img', 'aria-label': label || name };
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={strokeWidth}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={cx('ig-icon', className)}
      {...a11y}
      {...rest}
    >
      {children || (name ? <use href={`${__spriteUrl}#${name}`} /> : null)}
    </svg>
  );
}

/* ──────────────────────────────────────────────────────────────────
 * Spinner / Skeleton
 * ────────────────────────────────────────────────────────────────── */
export const Spinner  = ({ label = 'Loading' }) => <span className="ig-spinner" role="status" aria-label={label} />;
export const Skeleton = ({ variant = 'text', style, className }) => (
  <span className={cx('ig-skeleton', `ig-skeleton--${variant}`, className)} style={style} />
);

/* ──────────────────────────────────────────────────────────────────
 * BARREL EXPORT for `import { ... } from "igrow-web-design-system/react"`.
 * ────────────────────────────────────────────────────────────────── */
export default {
  Button, Field, Input, Textarea, Select, Checkbox, Switch,
  Card, CardMedia, PropertyCard, StatCard, Testimonial,
  Nav, Hero, Footer,
  Modal, Alert,
  Pagination, Breadcrumb, EmptyState,
  Spinner, Skeleton,
  Icon, setIconSpriteUrl,
};