/**
* 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,
};