// Towbook Android — core component library // Material 3 conventions, Towbook tokens. All components are self-contained, no external deps beyond // React and the global I (icons) / TBT (tokens) helpers. const { useState: useStateC, useEffect: useEffectC, useRef: useRefC } = React; // ─────── Buttons ────────────────────────────────────────────── // Variants: filled, tonal, outlined, text, destructive // Sizes: sm | md | lg function TBButton({ variant = 'filled', size = 'md', icon, trailingIcon, children, onClick, disabled, fullWidth, style, }) { const [pressed, setPressed] = useStateC(false); const [hover, setHover] = useStateC(false); const h = size === 'sm' ? 36 : size === 'lg' ? 52 : 44; const padX = size === 'sm' ? 14 : size === 'lg' ? 24 : 18; const fs = size === 'sm' ? 13 : size === 'lg' ? 16 : 14; const iconSize = size === 'lg' ? 20 : 18; const palettes = { filled: { bg: pressed ? 'var(--tb-primary-press)' : (hover ? 'var(--tb-primary-hover)' : 'var(--tb-primary)'), fg: 'white', border: 'transparent', shadow: '0 1px 2px rgba(7,113,242,0.20)', }, tonal: { bg: pressed ? 'var(--blue-5)' : (hover ? 'var(--blue-4)' : 'var(--blue-3)'), fg: 'var(--blue-11)', border: 'transparent', shadow: 'none', }, outlined: { bg: pressed ? 'var(--slate-3)' : (hover ? 'var(--slate-2)' : 'transparent'), fg: 'var(--tb-on-surface)', border: 'var(--tb-border-strong)', shadow: 'none', }, text: { bg: pressed ? 'var(--blue-a4)' : (hover ? 'var(--blue-a3)' : 'transparent'), fg: 'var(--blue-11)', border: 'transparent', shadow: 'none', }, destructive: { bg: pressed ? 'var(--red-10)' : (hover ? 'var(--red-9)' : 'var(--red-9)'), fg: 'white', border: 'transparent', shadow: '0 1px 2px rgba(220,62,66,0.20)', }, }; const p = palettes[variant]; return ( setHover(true)} onMouseLeave={() => { setHover(false); setPressed(false); }} onMouseDown={() => setPressed(true)} onMouseUp={() => setPressed(false)} style={{ height: h, padding: `0 ${padX}px`, background: disabled ? 'var(--slate-3)' : p.bg, color: disabled ? 'var(--slate-9)' : p.fg, border: `1px solid ${disabled ? 'transparent' : p.border}`, borderRadius: 'var(--tb-r-button)', fontFamily: 'var(--font-sans)', fontSize: fs, fontWeight: 600, letterSpacing: '0.005em', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8, cursor: disabled ? 'not-allowed' : 'pointer', boxShadow: disabled ? 'none' : p.shadow, width: fullWidth ? '100%' : 'auto', transition: 'background 120ms var(--ease-in), box-shadow 120ms var(--ease-in)', whiteSpace: 'nowrap', ...style, }}> {icon ? React.createElement(icon, { size: iconSize }) : null} {children} {trailingIcon ? React.createElement(trailingIcon, { size: iconSize }) : null} ); } // FAB — floating action button. Variants: regular, extended (with label), small. function TBFab({ icon = I.Plus, label, size = 'regular', onClick, color = 'primary', style }) { const [pressed, setPressed] = useStateC(false); const dim = size === 'small' ? 40 : 56; const iconSize = size === 'small' ? 20 : 24; const isExtended = !!label; const palette = color === 'primary' ? { bg: 'var(--tb-primary)', fg: 'white' } : color === 'tonal' ? { bg: 'var(--blue-3)', fg: 'var(--blue-11)' } : { bg: 'var(--tb-surface)', fg: 'var(--blue-11)' }; return ( setPressed(true)} onMouseUp={() => setPressed(false)} onMouseLeave={() => setPressed(false)} style={{ height: dim, width: isExtended ? undefined : dim, minWidth: isExtended ? 80 : dim, padding: isExtended ? '0 20px 0 16px' : 0, borderRadius: isExtended ? 16 : (dim/2), background: palette.bg, color: palette.fg, border: 0, boxShadow: pressed ? '0 2px 4px rgba(7,113,242,0.22)' : 'var(--tb-elev-fab)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 10, fontFamily: 'var(--font-sans)', fontSize: 15, fontWeight: 600, cursor: 'pointer', transform: pressed ? 'translateY(1px)' : 'none', transition: 'transform 80ms var(--ease-in), box-shadow 120ms var(--ease-in)', ...style, }}> {React.createElement(icon, { size: iconSize })} {label} ); } // ─────── Inputs ────────────────────────────────────────────── // Outlined text field (M3) with floating label, supporting + error states. function TBTextField({ label, value, onChange, placeholder, leadingIcon, trailingIcon, supporting, error, type = 'text', disabled, fullWidth = true, multiline, rows = 4, }) { const [focused, setFocused] = useStateC(false); const filled = (value !== undefined && value !== '') || focused; const borderColor = error ? 'var(--red-9)' : focused ? 'var(--tb-primary)' : 'var(--tb-border-strong)'; const labelColor = error ? 'var(--red-11)' : focused ? 'var(--blue-11)' : 'var(--tb-on-surface-muted)'; const input = multiline ? ( onChange?.(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} disabled={disabled} placeholder={focused ? placeholder : ''} style={{ width: '100%', resize: 'none', background: 'transparent', border: 0, outline: 'none', fontFamily: 'var(--font-sans)', fontSize: 15, color: 'var(--tb-on-surface)', padding: 0, }}/> ) : ( onChange?.(e.target.value)} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} disabled={disabled} placeholder={focused ? placeholder : ''} style={{ width: '100%', height: 24, background: 'transparent', border: 0, outline: 'none', fontFamily: 'var(--font-sans)', fontSize: 15, color: 'var(--tb-on-surface)', }}/> ); return ( {leadingIcon && {React.createElement(leadingIcon, { size: 20 })}} {label && ( {label} )} {input} {trailingIcon && {React.createElement(trailingIcon, { size: 20 })}} {(supporting || error) && ( {error || supporting} )} ); } // Switch (Material 3) function TBSwitch({ checked, onChange, disabled }) { return ( !disabled && onChange?.(!checked)} disabled={disabled} style={{ width: 52, height: 32, padding: 2, borderRadius: 999, border: `2px solid ${checked ? 'var(--tb-primary)' : 'var(--tb-border-strong)'}`, background: checked ? 'var(--tb-primary)' : 'transparent', position: 'relative', cursor: disabled ? 'not-allowed' : 'pointer', transition: 'all 160ms var(--ease-in)', opacity: disabled ? 0.4 : 1, }}> ); } // Checkbox function TBCheckbox({ checked, onChange, indeterminate, disabled }) { return ( !disabled && onChange?.(!checked)} disabled={disabled} style={{ width: 20, height: 20, border: `2px solid ${checked || indeterminate ? 'var(--tb-primary)' : 'var(--tb-border-strong)'}`, background: checked || indeterminate ? 'var(--tb-primary)' : 'transparent', borderRadius: 3, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: disabled ? 'not-allowed' : 'pointer', padding: 0, color: 'white', opacity: disabled ? 0.4 : 1, }}> {checked && } {indeterminate && !checked && } ); } // Radio function TBRadio({ checked, onChange, disabled }) { return ( !disabled && onChange?.(true)} disabled={disabled} style={{ width: 20, height: 20, border: `2px solid ${checked ? 'var(--tb-primary)' : 'var(--tb-border-strong)'}`, background: 'transparent', borderRadius: '50%', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: disabled ? 'not-allowed' : 'pointer', padding: 0, opacity: disabled ? 0.4 : 1, }}> {checked && } ); } // ─────── Cards / surfaces ──────────────────────────────────── function TBCard({ children, elevated, style, onClick, padding = 16 }) { const [hover, setHover] = useStateC(false); return ( setHover(true)} onMouseLeave={() => setHover(false)} style={{ background: 'var(--tb-surface)', borderRadius: 'var(--tb-r-card)', border: elevated ? 'none' : '1px solid var(--tb-border)', boxShadow: elevated ? (hover ? 'var(--tb-elev-2)' : 'var(--tb-elev-1)') : 'none', padding, cursor: onClick ? 'pointer' : 'default', transition: 'box-shadow 120ms var(--ease-in)', ...style, }}> {children} ); } // ─────── Chips ────────────────────────────────────────────── // Variants: assist, filter (selected/unselected), input function TBChip({ children, selected, onClick, leadingIcon, trailingIcon, variant = 'filter', onRemove }) { const [hover, setHover] = useStateC(false); const palette = variant === 'filter' ? selected ? { bg: 'var(--blue-3)', fg: 'var(--blue-11)', border: 'var(--tb-primary)' } : { bg: hover ? 'var(--slate-2)' : 'transparent', fg: 'var(--tb-on-surface)', border: 'var(--tb-border-strong)' } : variant === 'input' ? { bg: 'var(--slate-3)', fg: 'var(--tb-on-surface)', border: 'transparent' } : { bg: hover ? 'var(--slate-2)' : 'transparent', fg: 'var(--tb-on-surface)', border: 'var(--tb-border-strong)' }; return ( setHover(true)} onMouseLeave={() => setHover(false)} style={{ height: 32, padding: '0 12px', background: palette.bg, color: palette.fg, border: `1px solid ${palette.border}`, borderRadius: 'var(--tb-r-chip)', fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', }}> {selected && variant === 'filter' && } {leadingIcon && React.createElement(leadingIcon, { size: 16 })} {children} {variant === 'input' && onRemove && ( { e.stopPropagation(); onRemove(); }} style={{ display: 'inline-flex' }}> )} {trailingIcon && React.createElement(trailingIcon, { size: 16 })} ); } // ─────── Tabs ────────────────────────────────────────────── // Primary tabs (Material 3) — full-width or scrollable function TBTabs({ items, value, onChange, variant = 'primary', scrollable }) { return ( {items.map((it) => { const isActive = it.value === value; const fg = variant === 'primary' ? (isActive ? 'white' : 'rgba(255,255,255,0.78)') : (isActive ? 'var(--tb-primary)' : 'var(--tb-on-surface-muted)'); return ( onChange?.(it.value)} style={{ flex: scrollable ? '0 0 auto' : 1, minWidth: scrollable ? 100 : 0, height: 48, padding: '0 16px', border: 0, background: 'transparent', fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: fg, borderBottom: `3px solid ${isActive ? (variant === 'primary' ? 'white' : 'var(--tb-primary)') : 'transparent'}`, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, transition: 'color 120ms var(--ease-in)', }}> {it.label} {it.count !== undefined && ( {it.count} )} ); })} ); } // ─────── Sub nav (scrollable, blue underline) ────────────── // Horizontal sub-navigation that overflows the viewport. Selected item shows // brand-blue text + 2px underline. Sliding arrows appear when content overflows // and scroll the strip 70% of the visible width per click. function TBSubNav({ items, value, onChange, sticky = true }) { const trackRef = useRefC(null); const [overflowL, setOverflowL] = useStateC(false); const [overflowR, setOverflowR] = useStateC(false); const recalc = () => { const el = trackRef.current; if (!el) return; setOverflowL(el.scrollLeft > 4); setOverflowR(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); }; useEffectC(() => { const el = trackRef.current; if (!el) return; recalc(); el.addEventListener('scroll', recalc, { passive: true }); const ro = new ResizeObserver(recalc); ro.observe(el); return () => { el.removeEventListener('scroll', recalc); ro.disconnect(); }; }, [items]); // Auto-scroll selected into view useEffectC(() => { const el = trackRef.current; if (!el) return; const sel = el.querySelector('[data-selected="true"]'); if (sel) { const elRect = el.getBoundingClientRect(); const selRect = sel.getBoundingClientRect(); if (selRect.left < elRect.left + 24 || selRect.right > elRect.right - 24) { sel.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); } } }, [value]); const slide = (dir) => { const el = trackRef.current; if (!el) return; el.scrollBy({ left: dir * el.clientWidth * 0.7, behavior: 'smooth' }); }; return ( {items.map((it) => { const isActive = it.value === value; return ( onChange?.(it.value)} style={{ flex: '0 0 auto', height: 44, padding: '0 16px', border: 0, background: 'transparent', fontFamily: 'var(--font-sans)', fontSize: 14, fontWeight: isActive ? 600 : 500, color: isActive ? 'var(--tb-primary)' : 'var(--tb-on-surface-muted)', position: 'relative', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', transition: 'color 120ms var(--ease-in)', }}> {it.label} {it.count !== undefined && ( {it.count} )} ); })} {/* Edge fades — visual hint that more content is available */} {overflowL && ( )} {overflowR && ( )} ); } // ─────── List item ────────────────────────────────────────── function TBListItem({ leading, headline, supporting, trailing, onClick, divider = true, twoLine }) { const [pressed, setPressed] = useStateC(false); return ( setPressed(true)} onMouseUp={() => setPressed(false)} onMouseLeave={() => setPressed(false)} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '12px 16px', minHeight: twoLine ? 72 : 56, background: pressed ? 'var(--slate-3)' : 'transparent', cursor: onClick ? 'pointer' : 'default', borderBottom: divider ? '1px solid var(--tb-divider)' : 'none', transition: 'background 80ms var(--ease-in)', }}> {leading && {leading}} {headline} {supporting && ( {supporting} )} {trailing && {trailing}} ); } // ─────── Dialog (alert) ───────────────────────────────────── function TBDialog({ open, title, children, actions, icon, onDismiss }) { if (!open) return null; return ( e.stopPropagation()} style={{ background: 'var(--tb-surface)', borderRadius: 28, padding: 24, width: '100%', maxWidth: 312, boxShadow: 'var(--tb-elev-3)', animation: 'tb-scale 200ms var(--ease-in)', textAlign: icon ? 'center' : 'left', }}> {icon && ( {React.createElement(icon, { size: 24 })} )} {title} {children} {actions} ); } // ─────── Bottom sheet ─────────────────────────────────────── function TBBottomSheet({ open, onDismiss, children, title, height }) { return ( e.stopPropagation()} style={{ position: 'absolute', left: 0, right: 0, bottom: 0, background: 'var(--tb-surface)', borderRadius: '28px 28px 0 0', boxShadow: 'var(--tb-elev-3)', padding: '12px 0 8px', transform: open ? 'translateY(0)' : 'translateY(100%)', transition: 'transform 240ms var(--ease-in)', maxHeight: height || '70%', display: 'flex', flexDirection: 'column', }}> {title && {title}} {children} ); } // ─────── Snackbar ─────────────────────────────────────────── function TBSnackbar({ open, children, action, onDismiss }) { return ( {children} {action && ( {action.label} )} {onDismiss && ( )} ); } // ─────── Linear progress ──────────────────────────────────── function TBLinearProgress({ value, indeterminate }) { return ( {indeterminate ? ( ) : ( )} ); } // Circular progress (used as pull-to-refresh + spinner) function TBSpinner({ size = 24, strokeWidth = 3, color = 'var(--tb-primary)' }) { return ( ); } Object.assign(window, { TBButton, TBFab, TBTextField, TBSwitch, TBCheckbox, TBRadio, TBCard, TBChip, TBTabs, TBSubNav, TBListItem, TBDialog, TBBottomSheet, TBSnackbar, TBLinearProgress, TBSpinner, });