/**
* Shared UI primitives. Locked from the Claude Design v2 bundle (2026-05-18).
*
* Every component here is documented in admin-react/DESIGN-SYSTEM.md. That
* doc is the contract. Don't roll your own button/toggle/card; extend these.
*/
import { createContext, useContext, useId, useState } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
import { IC } from './icons';
// ─── ibg(): icon-box background tint ────────────────────────────────────────
// Maps each saturated icon colour to its soft companion. Every HxHead must
// pass its colour through this so the palette stays harmonised.
const IBGS = {
'#7ebc11': '#f1f8e3',
'#2563eb': '#eff6ff',
'#16a34a': '#f0fdf4',
'#d97706': '#fffbeb',
'#b91c1c': '#fef2f2',
'#8b5cf6': '#f5f3ff',
'#0d9488': '#f0fdfa',
'#6366f1': '#eef2ff',
'#10b981': '#ecfdf5',
'#ef4444': '#fef2f2',
'#737373': '#f5f5f5',
'var(--hx-text-muted)': 'var(--hx-surface-2)',
'var(--hx-text-subtle)': 'var(--hx-surface-2)',
'var(--hx-success)': 'var(--hx-success-soft)',
'var(--hx-warning)': 'var(--hx-warning-soft)',
'var(--hx-danger)': 'var(--hx-danger-soft)',
'var(--hx-info)': 'var(--hx-info-soft)',
};
export const ibg = (c) => IBGS[c] || c + '18';
// ─── HxIcon ─────────────────────────────────────────────────────────────────
export const HxIcon = ({ size = 16, color = 'currentColor', sw = 1.75, children, style }) => (
);
// ─── Accessible-name context ───────────────────────────────────────────────
// HxRow and HxField publish the ids of their label and description. HxToggle
// and HxInp read them so a control placed inside either wrapper is named by the
// visible label (aria-labelledby) and described by the help text
// (aria-describedby) without every call site repeating an aria-label. A control
// used outside a wrapper must pass ariaLabel; tests/consistency/admin-a11y-names.mjs
// fails the build check when one is left unnamed.
const HxNameContext = createContext(null);
const nameProps = (ariaLabel, ctx) => {
if (ariaLabel) {
return { 'aria-label': ariaLabel, 'aria-describedby': ctx && ctx.descId };
}
if (ctx) {
return { 'aria-labelledby': ctx.labelId, 'aria-describedby': ctx.descId };
}
return {};
};
// ─── HxToggle ──────────────────────────────────────────────────────────────
// ON = --hx-text (black). OFF = --hx-border-strong (grey). NEVER orange.
export const HxToggle = ({ on, onChange, ariaLabel, disabled }) => {
const ctx = useContext(HxNameContext);
return (
);
};
// ─── HxBtn ─────────────────────────────────────────────────────────────────
export const HxBtn = ({ children, variant = 'default', onClick, size = 'md', style: sx = {}, disabled, type = 'button', href, full, ariaLabel, external, ariaExpanded, ariaControls, btnRef }) => {
const [hov, setHov] = useState(false);
// v0.50.26. default variant used `bg: --hx-text` (black in light, white in dark)
// with a hard-coded `#fff` fg, so in dark mode the button rendered white-on-white
// (the "Visit live site" bug on the Connection tab). fg now uses --hx-surface so
// it inverts correctly: light gives black bg + white text, dark gives white bg + dark text.
const v = {
// A transparent border keeps the button's outline visible in forced-colors (high contrast) mode.
default: { bg: 'var(--hx-text)', fg: 'var(--hx-surface)', bd: '1px solid transparent', wt: 600 },
brand: { bg: 'var(--hx-primary)', fg: 'var(--hx-on-primary)', bd: '1px solid transparent', wt: 600 },
ghost: { bg: 'var(--hx-surface)', fg: 'var(--hx-text)', bd: '1px solid var(--hx-border-strong)', wt: 500 },
danger: { bg: 'var(--hx-danger)', fg: 'var(--hx-on-danger)', bd: '1px solid transparent', wt: 600 },
}[variant] || {};
const pad = size === 'sm' ? '7px 14px' : '10px 20px';
const base = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: pad,
borderRadius: 999,
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: 14,
fontWeight: v.wt,
lineHeight: 1,
fontFamily: 'inherit',
background: v.bg,
color: v.fg,
border: v.bd || 'none',
transition: 'opacity .2s var(--hx-ease), transform .2s var(--hx-ease)',
opacity: disabled ? 0.5 : hov ? 0.84 : 1,
transform: hov && !disabled ? 'translateY(-1px)' : 'none',
textDecoration: 'none',
width: full ? '100%' : undefined,
...sx,
};
if (href) {
// Open in a new tab only for links that leave this site, or when asked.
let leaves = !!external;
if (external === undefined) {
try { leaves = new URL(href, window.location.href).origin !== window.location.origin; } catch (e) { leaves = false; }
}
return (
setHov(true)}
onMouseLeave={() => setHov(false)}
style={base}
>
{children}
);
}
return (
);
};
// ─── HxBadge ───────────────────────────────────────────────────────────────
export const HxBadge = ({ children, color = 'neutral' }) => {
// Status colours come from the shared state tokens so both admin colour
// schemes stay readable. Text keeps the normal foreground colour for
// contrast; the status shows as a small dot.
const c = {
neutral: { bg: 'var(--hx-surface-2)' },
orange: { bg: 'var(--hx-primary-subtle)', dot: 'var(--hx-primary)' },
green: { bg: 'var(--hx-success-subtle)', dot: 'var(--hx-success)' },
yellow: { bg: 'var(--hx-warning-subtle)', dot: 'var(--hx-warning)' },
red: { bg: 'var(--hx-danger-subtle)', dot: 'var(--hx-danger)' },
blue: { bg: 'var(--hx-info-subtle)', dot: 'var(--hx-info)' },
mono: { bg: 'var(--hx-surface-2)', mono: true },
}[color] || { bg: 'var(--hx-surface-2)' };
return (
{c.dot && }
{children}
);
};
// ─── HxCard ────────────────────────────────────────────────────────────────
// hover prop: subtle box-shadow lift. Only use when the whole card IS the
// click target (or invites scrutiny). Static info cards leave it off.
//
// status prop: 'success' | 'warning' | 'danger' | 'info' tints border + bg
// per DESIGN-SYSTEM.md §2. Use for inline callouts (going-headless info,
// no-app-password warning, etc.) instead of hand-rolled coloured divs.
const CARD_STATUS = {
success: { bg: 'var(--hx-success-soft)', border: 'var(--hx-success-edge)' },
warning: { bg: 'var(--hx-warning-soft)', border: 'var(--hx-warning-edge)' },
danger: { bg: 'var(--hx-danger-soft)', border: 'var(--hx-danger-edge)' },
info: { bg: 'var(--hx-info-soft)', border: 'var(--hx-info-edge)' },
};
export const HxCard = ({ children, style: sx = {}, hover, status, as: As = 'div', ...rest }) => {
const s = status && CARD_STATUS[status] ? CARD_STATUS[status] : null;
return (
{children}
);
};
// ─── HxRow ─────────────────────────────────────────────────────────────────
export const HxRow = ({ label, desc, children, last, tone, stack }) => {
const uid = useId();
const ctx = { labelId: uid + '-l', descId: desc ? uid + '-d' : undefined };
return (
{label}
{desc && (
{desc}
)}
{children}
);
};
// ─── HxHead ────────────────────────────────────────────────────────────────
// Card header: 38×38 icon-box (tint via ibg) + title + desc. mb=20 standard.
export const HxHead = ({ iconChildren, iconColor = 'var(--hx-text-muted)', title, desc, mb = 20, action, nowrap = false }) => (
{iconChildren}
{title}
{desc && (
{desc}
)}
{action &&
{action}
}
);
// Back-compat alias for tabs still importing the old name.
export const HxSectionHead = HxHead;
// ─── HxSeg (segmented control) ─────────────────────────────────────────────
// Pill-based per DESIGN-SYSTEM.md §4, fully rounded container, fully rounded
// active thumb. Matches the dashboard tab nav language.
export const HxSeg = ({ options, value, onChange }) => (
{options.map((o) => {
const on = value === o.value;
return (
);
})}
);
// ─── HxGL (group label) ────────────────────────────────────────────────────
export const HxGL = ({ children, badge }) => (
{children}
{badge}
);
export const HxGroupLabel = HxGL;
// ─── HxInp ─────────────────────────────────────────────────────────────────
const pickDataAttrs = (props) => Object.fromEntries(Object.entries(props).filter(([k]) => k.startsWith('data-')));
export const HxInp = ({ placeholder, value, onChange, mono, type = 'text', full = true, defaultValue, pattern, autoComplete, spellCheck, id, name, ariaLabel, disabled, readOnly, onKeyDown, min, max, step, ...rest }) => {
const ctx = useContext(HxNameContext);
return (
{ e.target.style.borderColor = 'var(--hx-text)'; }}
onBlur={(e) => { e.target.style.borderColor = 'var(--hx-border-strong)'; }}
/>
);
};
// ─── HxNativeInput ─────────────────────────────────────────────────────────
// A plain that takes its accessible name from the enclosing HxRow or
// HxField, for the few controls that need their own styling (number fields).
export const HxNativeInput = ({ 'aria-label': ariaLabel, ...rest }) => {
const ctx = useContext(HxNameContext);
return ;
};
// ─── Chip (pill picker) ────────────────────────────────────────────────────
export const Chip = ({ label, active, onClick, readOnly }) => {
const style = {
padding: '6px 14px',
borderRadius: 999,
cursor: readOnly ? 'default' : 'pointer',
fontFamily: 'inherit',
fontSize: 13,
fontWeight: 500,
border: active ? '1.5px solid var(--hx-text)' : '1.5px solid var(--hx-border-strong)',
background: active ? 'var(--hx-text)' : 'var(--hx-surface)',
color: active ? 'var(--hx-surface)' : 'var(--hx-text-muted)',
transition: 'background .2s var(--hx-ease), border-color .2s var(--hx-ease), color .2s var(--hx-ease)',
};
if (readOnly) {
// A status pill, not a control. The word tells screen readers and
// colour-blind users whether it is on.
return (
{active ? '\u2713' : '\u2013'}
{label}
{active ? __('On', 'hatch-connect') : __('Off', 'hatch-connect')}
);
}
return (
);
};
// ─── HxCode (inline code inside descriptions) ─────────────────────────────
export const HxCode = ({ children, tone }) => (
{children}
);
// ─── HxSelect (native select, same 36px field as HxInp) ───────────────────
// Native