Initial commit: Apothecary v0.4.0
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
// Shared utilities + small primitives
|
||||
// Exported via window at bottom
|
||||
|
||||
const fmt = {
|
||||
g: (n) => (n == null ? "—" : `${(+n).toFixed(2).replace(/\.?0+$/, "") || "0"} g`),
|
||||
money: (n) => (n == null ? "—" : `$${(+n).toFixed(2)}`),
|
||||
moneyShort: (n) => (n == null ? "—" : n >= 100 ? `$${Math.round(n)}` : `$${(+n).toFixed(2)}`),
|
||||
pct: (n) => (n == null ? "—" : `${(+n).toFixed(1)}%`),
|
||||
date: (s) => {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
},
|
||||
dateShort: (s) => {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
},
|
||||
daysAgo: (s) => {
|
||||
if (!s) return "—";
|
||||
const ms = Date.now() - new Date(s).getTime();
|
||||
const d = Math.floor(ms / 86400000);
|
||||
if (d === 0) return "today";
|
||||
if (d === 1) return "yesterday";
|
||||
if (d < 30) return `${d}d ago`;
|
||||
if (d < 365) return `${Math.floor(d/30)}mo ago`;
|
||||
return `${Math.floor(d/365)}y ago`;
|
||||
}
|
||||
};
|
||||
|
||||
const TYPE_GLYPHS = {
|
||||
"Flower": "✿",
|
||||
"Concentrate": "◆",
|
||||
"Edible": "◐",
|
||||
"Vaporizer": "▢",
|
||||
"Pre-roll": "│",
|
||||
"Tincture": "◯"
|
||||
};
|
||||
|
||||
// Compute aggregate stats — derived from purchases + consumed/gone records + audits
|
||||
function computeStats(data) {
|
||||
const today = new Date(data.today || "2026-04-25");
|
||||
const todayStr = today.toISOString().slice(0, 10);
|
||||
const products = data.products;
|
||||
const H = window.DATA_HELPERS;
|
||||
const dayKey = (d) => d.toISOString().slice(0, 10);
|
||||
|
||||
const active = products.filter(p => p.status === "active");
|
||||
const consumed = products.filter(p => p.status === "consumed" && p.consumedDate);
|
||||
const gone = products.filter(p => p.status === "gone");
|
||||
|
||||
// Window helper for purchases
|
||||
const purchasesIn = (days) => {
|
||||
const cutoff = new Date(today); cutoff.setDate(cutoff.getDate() - days);
|
||||
return products.filter(p => new Date(p.purchaseDate) >= cutoff);
|
||||
};
|
||||
const last7p = purchasesIn(7);
|
||||
const last30p = purchasesIn(30);
|
||||
const last90p = purchasesIn(90);
|
||||
|
||||
// Bulk-grams equivalent: bulk uses weight (or ml for tincture, ignored from grams);
|
||||
// discrete uses unitWeight × units consumed
|
||||
const bulkGrams = (p) => {
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0; // not "weed grams"
|
||||
if (p.kind === "bulk") return p.weight;
|
||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
||||
};
|
||||
const bulkGramsConsumed = (p) => {
|
||||
// For consumed products: full weight equivalent
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") return p.weight;
|
||||
return (p.countOriginal || 0) * (p.unitWeight || 0);
|
||||
};
|
||||
const bulkGramsUsedSoFar = (p) => {
|
||||
// For active products: estimated grams used to date
|
||||
if (p.type === "Tincture" || p.type === "Edible") return 0;
|
||||
if (p.kind === "bulk") {
|
||||
const est = H.estimatedRemaining(p, todayStr);
|
||||
return Math.max(0, p.weight - est);
|
||||
}
|
||||
// discrete: (original - current) × unitWeight
|
||||
const cur = p.countLastAudit != null ? p.countLastAudit : p.countOriginal;
|
||||
return Math.max(0, (p.countOriginal - cur)) * (p.unitWeight || 0);
|
||||
};
|
||||
|
||||
// Daily attribution: spread used grams over (purchase → consumed/today) for consumed/active.
|
||||
// GONE items contribute $0 NOT grams (they were lost, not used).
|
||||
const dailyGramsAttribution = {};
|
||||
consumed.forEach(p => {
|
||||
const g = bulkGramsConsumed(p);
|
||||
if (g <= 0) return;
|
||||
const start = new Date(p.purchaseDate);
|
||||
const end = new Date(p.consumedDate);
|
||||
const days = Math.max(1, Math.round((end - start) / 86400000));
|
||||
const perDay = g / days;
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(start); d.setDate(d.getDate() + i);
|
||||
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
|
||||
}
|
||||
});
|
||||
active.forEach(p => {
|
||||
const used = bulkGramsUsedSoFar(p);
|
||||
if (used <= 0) return;
|
||||
const start = new Date(p.purchaseDate);
|
||||
const days = Math.max(1, Math.round((today - start) / 86400000));
|
||||
const perDay = used / days;
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(start); d.setDate(d.getDate() + i);
|
||||
dailyGramsAttribution[dayKey(d)] = (dailyGramsAttribution[dayKey(d)] || 0) + perDay;
|
||||
}
|
||||
});
|
||||
|
||||
const seriesFor = (days) => {
|
||||
const out = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today); d.setDate(d.getDate() - i);
|
||||
const k = dayKey(d);
|
||||
out.push({ date: k, grams: dailyGramsAttribution[k] || 0 });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const series7 = seriesFor(7);
|
||||
const series30 = seriesFor(30);
|
||||
const series90 = seriesFor(90);
|
||||
|
||||
const sumG = (xs) => xs.reduce((s, x) => s + x.grams, 0);
|
||||
const dailyAvg = sumG(series30) / 30;
|
||||
const weeklyAvg = sumG(series30) / (30/7);
|
||||
const monthlyAvg = sumG(series90) / 3;
|
||||
|
||||
// Spend
|
||||
const totalSpend = products.reduce((s, p) => s + p.price, 0);
|
||||
const goneSpend = gone.reduce((s, p) => s + p.price, 0);
|
||||
const totalGrams = products.reduce((s, p) => s + bulkGrams(p), 0);
|
||||
const avgPerGram = totalGrams ? totalSpend / totalGrams : 0;
|
||||
const spend30 = last30p.reduce((s, p) => s + p.price, 0);
|
||||
const spend7 = last7p.reduce((s, p) => s + p.price, 0);
|
||||
const spend90 = last90p.reduce((s, p) => s + p.price, 0);
|
||||
|
||||
// Inventory value (active, prorated by est. remaining %)
|
||||
const inventoryValue = active.reduce((s, p) => {
|
||||
return s + p.price * H.pctRemaining(p, todayStr);
|
||||
}, 0);
|
||||
|
||||
// THC mg using avg THC of products
|
||||
const avgThc = products.length ? products.reduce((s,p)=>s+p.thc,0) / products.length : 20;
|
||||
const thcLast7 = Math.round(sumG(series7) * avgThc * 10);
|
||||
const thcLast30 = Math.round(sumG(series30) * avgThc * 10);
|
||||
|
||||
// Avg lifespan of consumed
|
||||
const lifespans = consumed.map(p => Math.max(1, Math.round((new Date(p.consumedDate) - new Date(p.purchaseDate))/86400000)));
|
||||
const avgLifespan = lifespans.length ? lifespans.reduce((a,b)=>a+b,0) / lifespans.length : 0;
|
||||
|
||||
// Favorite shop / brand — keyed by id, look up name
|
||||
const shopCount = {};
|
||||
const brandCount = {};
|
||||
products.forEach(p => {
|
||||
if (p.shopId) shopCount[p.shopId] = (shopCount[p.shopId] || 0) + 1;
|
||||
if (p.brandId) brandCount[p.brandId] = (brandCount[p.brandId] || 0) + 1;
|
||||
});
|
||||
const topShopEntry = Object.entries(shopCount).sort((a,b)=>b[1]-a[1])[0];
|
||||
const topBrandEntry = Object.entries(brandCount).sort((a,b)=>b[1]-a[1])[0];
|
||||
const favShop = topShopEntry ? [H.shopName(data, topShopEntry[0]), topShopEntry[1]] : ["—", 0];
|
||||
const favBrand = topBrandEntry ? [H.brandName(data, topBrandEntry[0]), topBrandEntry[1]] : ["—", 0];
|
||||
|
||||
// Type breakdown by est. grams on hand (active only)
|
||||
const typeBreakdown = {};
|
||||
active.forEach(p => {
|
||||
let g;
|
||||
if (p.type === "Tincture") g = H.estimatedRemaining(p, todayStr) * 0.5; // ml → display weight rough
|
||||
else if (p.type === "Edible") g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * 0.3; // each gummy ~0.3g for chart only
|
||||
else if (p.kind === "bulk") g = H.estimatedRemaining(p, todayStr);
|
||||
else g = (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
|
||||
if (g > 0) typeBreakdown[p.type] = (typeBreakdown[p.type] || 0) + g;
|
||||
});
|
||||
|
||||
// Flower-equivalent supply
|
||||
const flowerEquivalent = active
|
||||
.filter(p => p.type === "Flower" || p.type === "Pre-roll")
|
||||
.reduce((s, p) => {
|
||||
if (p.kind === "bulk") return s + H.estimatedRemaining(p, todayStr);
|
||||
return s + (p.countLastAudit != null ? p.countLastAudit : p.countOriginal) * (p.unitWeight || 0);
|
||||
}, 0);
|
||||
const daysOfSupply = dailyAvg > 0 ? flowerEquivalent / dailyAvg : 0;
|
||||
|
||||
// Avg days between buys
|
||||
const sortedDates = [...products].sort((a,b)=>new Date(a.purchaseDate)-new Date(b.purchaseDate)).map(p => new Date(p.purchaseDate));
|
||||
const gaps = [];
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
gaps.push((sortedDates[i] - sortedDates[i-1]) / 86400000);
|
||||
}
|
||||
const avgGap = gaps.length ? gaps.reduce((a,b)=>a+b,0)/gaps.length : 0;
|
||||
|
||||
// ─── Audits & low stock ───────────────────────────────────────
|
||||
const overdueAudits = active.filter(p => H.auditOverdue(data, p, todayStr));
|
||||
|
||||
// Low stock:
|
||||
// - Bulk: pctRemaining < 0.25
|
||||
// - Discrete: GROUPED BY (brand + type) — total count ≤ 2
|
||||
const lowStockBulk = active.filter(p => p.kind === "bulk" && H.pctRemaining(p, todayStr) < 0.25);
|
||||
|
||||
const discreteBrandGroups = {};
|
||||
active.filter(p => p.kind === "discrete").forEach(p => {
|
||||
const k = `${p.brandId}|${p.type}|${p.name}`;
|
||||
if (!discreteBrandGroups[k]) {
|
||||
discreteBrandGroups[k] = {
|
||||
key: k, name: p.name, type: p.type, brandId: p.brandId,
|
||||
items: [], totalCount: 0
|
||||
};
|
||||
}
|
||||
discreteBrandGroups[k].items.push(p);
|
||||
discreteBrandGroups[k].totalCount += (p.countLastAudit != null ? p.countLastAudit : p.countOriginal);
|
||||
});
|
||||
const lowStockDiscreteGroups = Object.values(discreteBrandGroups).filter(g => g.totalCount <= 2);
|
||||
|
||||
return {
|
||||
dailyAvg, weeklyAvg, monthlyAvg,
|
||||
totalSpend, avgPerGram, spend7, spend30, spend90, goneSpend,
|
||||
inventoryValue,
|
||||
thcLast7, thcLast30,
|
||||
avgLifespan,
|
||||
favShop, favBrand,
|
||||
typeBreakdown,
|
||||
daysOfSupply,
|
||||
avgGap,
|
||||
series7, series30, series90,
|
||||
activeCount: active.length,
|
||||
consumedCount: consumed.length,
|
||||
goneCount: gone.length,
|
||||
archivedCount: consumed.length + gone.length,
|
||||
overdueAudits,
|
||||
lowStockBulk,
|
||||
lowStockDiscreteGroups
|
||||
};
|
||||
}
|
||||
|
||||
// Subtle inline icons (1px stroke)
|
||||
const Icon = ({name, size = 18, color = "currentColor"}) => {
|
||||
const paths = {
|
||||
home: "M3 11l9-8 9 8M5 9v12h5v-7h4v7h5V9",
|
||||
box: "M3 7l9-4 9 4v10l-9 4-9-4V7zM3 7l9 4 9-4M12 11v10",
|
||||
chart: "M3 21V3M3 21h18M7 17v-7M11 17v-4M15 17v-9M19 17v-2",
|
||||
plus: "M12 5v14M5 12h14",
|
||||
check: "M5 13l4 4L19 7",
|
||||
settings: "M12 3v3M12 18v3M5 5l2 2M17 17l2 2M3 12h3M18 12h3M5 19l2-2M17 7l2-2M12 8a4 4 0 100 8 4 4 0 000-8z",
|
||||
search: "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4-4",
|
||||
filter: "M3 5h18M6 12h12M10 19h4",
|
||||
bin: "M4 7h16M9 7V4h6v3M6 7v13h12V7",
|
||||
leaf: "M5 19c0-7 5-14 14-14 0 9-5 14-14 14zM5 19l7-7",
|
||||
flame: "M12 3c1 4 4 5 4 9a4 4 0 11-8 0c0-2 2-3 2-6 0-1 1-2 2-3z",
|
||||
droplet: "M12 3l5 7a5 5 0 11-10 0l5-7z",
|
||||
arrow: "M5 12h14M13 5l7 7-7 7",
|
||||
arrowDown: "M12 5v14M5 13l7 7 7-7",
|
||||
close: "M6 6l12 12M18 6L6 18",
|
||||
edit: "M4 20h4l10-10-4-4L4 16v4zM14 6l4 4",
|
||||
star: "M12 3l3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z",
|
||||
calendar: "M5 5h14v15H5zM3 10h18M9 3v4M15 3v4",
|
||||
tag: "M3 12V3h9l9 9-9 9-9-9zM7 7h.01"
|
||||
};
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{flexShrink: 0}}>
|
||||
<path d={paths[name] || ""} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Sparkline
|
||||
const Sparkline = ({values, width = 120, height = 32, color = "var(--ink)", fill = false}) => {
|
||||
if (!values || values.length === 0) return null;
|
||||
const max = Math.max(...values, 0.001);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = max - min || 1;
|
||||
const step = width / (values.length - 1 || 1);
|
||||
const pts = values.map((v, i) => [i * step, height - ((v - min) / span) * (height - 4) - 2]);
|
||||
const path = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" ");
|
||||
const fillPath = fill ? path + ` L ${width} ${height} L 0 ${height} Z` : null;
|
||||
return (
|
||||
<svg width={width} height={height} style={{display: "block", overflow: "visible"}}>
|
||||
{fillPath && <path d={fillPath} fill={color} opacity="0.12" />}
|
||||
<path d={path} stroke={color} strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Bar chart
|
||||
const BarChart = ({data, height = 160, color = "var(--sage)", labels = false}) => {
|
||||
if (!data || !data.length) return null;
|
||||
const max = Math.max(...data.map(d => d.value), 0.001);
|
||||
return (
|
||||
<div style={{display: "flex", alignItems: "flex-end", gap: 2, height, width: "100%"}}>
|
||||
{data.map((d, i) => (
|
||||
<div key={i} style={{flex: 1, display: "flex", flexDirection: "column", alignItems: "center", gap: 4, minWidth: 0}}>
|
||||
<div style={{
|
||||
width: "100%",
|
||||
height: `${(d.value / max) * 100}%`,
|
||||
background: d.value > 0 ? color : "var(--line)",
|
||||
borderRadius: "2px 2px 0 0",
|
||||
minHeight: d.value > 0 ? 2 : 1,
|
||||
opacity: d.muted ? 0.4 : 1
|
||||
}} />
|
||||
{labels && <div style={{fontSize: 9, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{d.label}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Donut chart
|
||||
const Donut = ({segments, size = 160, thickness = 22}) => {
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
const r = size / 2 - thickness / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
let offset = 0;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{transform: "rotate(-90deg)"}}>
|
||||
<circle cx={size/2} cy={size/2} r={r} stroke="var(--line)" strokeWidth={thickness} fill="none" opacity="0.3" />
|
||||
{segments.map((s, i) => {
|
||||
const len = (s.value / total) * c;
|
||||
const dash = `${len} ${c - len}`;
|
||||
const el = (
|
||||
<circle key={i} cx={size/2} cy={size/2} r={r} stroke={s.color} strokeWidth={thickness} fill="none"
|
||||
strokeDasharray={dash} strokeDashoffset={-offset} strokeLinecap="butt" />
|
||||
);
|
||||
offset += len;
|
||||
return el;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// Reusable card
|
||||
const Card = ({children, style, padded = true, ...rest}) => {
|
||||
const { padded: _ignored, ...domProps } = rest;
|
||||
return (
|
||||
<div {...domProps} style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: padded ? 20 : 0,
|
||||
...style
|
||||
}}>{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Stat card
|
||||
const Stat = ({label, value, unit, sub, spark, accent, big}) => (
|
||||
<div style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: 18,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<div className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</div>
|
||||
<div style={{display: "flex", alignItems: "baseline", gap: 6}}>
|
||||
<div className="serif" style={{fontSize: big ? 44 : 32, lineHeight: 1, color: accent || "var(--ink)", fontWeight: 500, letterSpacing: "-0.01em"}}>{value}</div>
|
||||
{unit && <div style={{fontSize: 13, color: "var(--ink-3)", fontFamily: "var(--mono)"}}>{unit}</div>}
|
||||
</div>
|
||||
{sub && <div style={{fontSize: 12, color: "var(--ink-3)"}}>{sub}</div>}
|
||||
{spark && <div style={{marginTop: 4}}>{spark}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Pill / badge
|
||||
const Pill = ({children, tone = "neutral", style}) => {
|
||||
const tones = {
|
||||
neutral: { bg: "var(--bg-3)", color: "var(--ink-2)" },
|
||||
sage: { bg: "var(--sage-soft)", color: "var(--sage)" },
|
||||
terra: { bg: "var(--terracotta-soft)", color: "var(--terracotta)" },
|
||||
amber: { bg: "var(--amber-soft)", color: "oklch(48% 0.10 75)" },
|
||||
outline: { bg: "transparent", color: "var(--ink-2)", border: "1px solid var(--line-strong)" }
|
||||
};
|
||||
const t = tones[tone] || tones.neutral;
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 8px",
|
||||
borderRadius: 999,
|
||||
background: t.bg,
|
||||
color: t.color,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "0.02em",
|
||||
border: t.border || "1px solid transparent",
|
||||
...style
|
||||
}}>{children}</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Button — high contrast across themes
|
||||
const Btn = ({children, variant = "ghost", icon, onClick, style, type, disabled}) => {
|
||||
// Disabled: keep solid surface, dim the text only — never become low-contrast on bg
|
||||
const variants = {
|
||||
primary: disabled
|
||||
? { background: "var(--bg-3)", color: "var(--ink-3)", border: "1px solid var(--line-strong)" }
|
||||
: { background: "var(--ink)", color: "var(--bg)", border: "1px solid var(--ink)" },
|
||||
secondary: { background: "var(--surface)", color: "var(--ink)", border: "1px solid var(--line-strong)" },
|
||||
ghost: { background: "transparent", color: "var(--ink-2)", border: "1px solid transparent" },
|
||||
danger: { background: "var(--terracotta)", color: "oklch(98% 0.01 40)", border: "1px solid var(--terracotta)" },
|
||||
sage: { background: "var(--sage)", color: "oklch(98% 0.01 145)", border: "1px solid var(--sage)" }
|
||||
};
|
||||
const v = variants[variant];
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled} style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "8px 14px",
|
||||
borderRadius: "var(--r-md)",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
transition: "all 120ms",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
...v,
|
||||
...style
|
||||
}}>
|
||||
{icon && <Icon name={icon} size={14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Field
|
||||
const Field = ({label, children, hint, span = 1}) => (
|
||||
<label style={{display: "flex", flexDirection: "column", gap: 6, gridColumn: `span ${span}`}}>
|
||||
<span className="smallcaps" style={{color: "var(--ink-3)"}}>{label}</span>
|
||||
{children}
|
||||
{hint && <span style={{fontSize: 11, color: "var(--ink-3)"}}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
const inputStyle = {
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
color: "var(--ink)",
|
||||
outline: "none",
|
||||
fontFamily: "var(--sans)",
|
||||
width: "100%"
|
||||
};
|
||||
|
||||
const Input = (props) => <input style={inputStyle} {...props} />;
|
||||
const Select = ({children, ...rest}) => <select style={{...inputStyle, appearance: "auto"}} {...rest}>{children}</select>;
|
||||
const Textarea = (props) => <textarea style={{...inputStyle, minHeight: 80, resize: "vertical"}} {...props} />;
|
||||
|
||||
Object.assign(window, {
|
||||
fmt, computeStats, TYPE_GLYPHS,
|
||||
Icon, Sparkline, BarChart, Donut,
|
||||
Card, Stat, Pill, Btn, Field, Input, Select, Textarea, inputStyle
|
||||
});
|
||||
Reference in New Issue
Block a user