import { ref } from 'vue' export type Token = { name: string; value: string } export async function copyText(text: string): Promise { if (typeof navigator === 'undefined' || typeof window === 'undefined') return false try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text) return true } } catch { // fall through to fallback } try { const textarea = document.createElement('textarea') textarea.value = text textarea.setAttribute('readonly', '') textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.select() const ok = document.execCommand('copy') document.body.removeChild(textarea) return ok } catch { return false } } export function useCopier() { const copiedKey = ref(null) let timer: null | ReturnType = null const copy = async (key: string, text: string = key) => { const ok = await copyText(text) if (!ok) return if (timer) clearTimeout(timer) copiedKey.value = key timer = setTimeout(() => { copiedKey.value = null }, 1200) } return { copiedKey, copy } } type MatcherFn = (name: string) => boolean type Matcher = string | RegExp | MatcherFn const toMatcherFn = (m: Matcher): MatcherFn => { if (typeof m === 'function') return m if (m instanceof RegExp) return (name: string) => m.test(name) return (name: string) => name.startsWith(m) } export function readTokens(include: Matcher[], exclude: Matcher[] = []): Token[] { if (typeof window === 'undefined' || typeof document === 'undefined') return [] const styles = getComputedStyle(document.documentElement) const includeFns = include.map(toMatcherFn) const excludeFns = exclude.map(toMatcherFn) const out: Token[] = [] for (let i = 0; i < styles.length; i++) { const name = styles[i] if (!name.startsWith('--cp-')) continue if (!includeFns.some((fn) => fn(name))) continue if (excludeFns.some((fn) => fn(name))) continue out.push({ name, value: styles.getPropertyValue(name).trim() }) } return out.sort((a, b) => a.name.localeCompare(b.name)) } export type ProbeProperty = 'letter-spacing' | 'line-height' | 'width' /** * Apply a token as the given CSS property on a hidden probe element and read * back the computed value. Defaults to `width`, which is the right choice for * length tokens (rem/px). Use `line-height` or `letter-spacing` to resolve * values that depend on the current font size. */ export function getTokenPixels(tokenName: string, property: ProbeProperty = 'width'): null | string { if (typeof window === 'undefined' || typeof document === 'undefined') return null const probe = document.createElement('div') probe.style.position = 'absolute' probe.style.visibility = 'hidden' probe.style.pointerEvents = 'none' probe.style.fontSize = '16px' if (property === 'width') { probe.style.width = `var(${tokenName})` } else if (property === 'line-height') { probe.style.lineHeight = `var(${tokenName})` probe.textContent = 'M' } else { probe.style.letterSpacing = `var(${tokenName})` } document.body.appendChild(probe) const style = getComputedStyle(probe) let computed: string if (property === 'width') computed = style.width else if (property === 'line-height') computed = style.lineHeight else computed = style.letterSpacing document.body.removeChild(probe) if (!computed || computed === 'auto' || computed === 'normal') return null return computed } const tshirtRank: Record = { none: -1, '3xs': 0, '2xs': 1, xs: 2, sm: 3, 'sm-md': 3.5, md: 4, 'md-lg': 4.5, lg: 5, xl: 6, '2xl': 7, '3xl': 8, '4xl': 9, '5xl': 10, '6xl': 11, '7xl': 12, '8xl': 13, '9xl': 14, full: 9999, } function extractSizeRank(name: string): null | number { const parts = name.replace(/^--cp-/, '').split('-') for (let i = parts.length - 1; i >= 0; i--) { const one = parts[i] const two = i > 0 ? `${parts[i - 1]}-${one}` : null if (two && two in tshirtRank) return tshirtRank[two] if (one in tshirtRank) return tshirtRank[one] } return null } function parsePxNumber(px: null | string): null | number { if (!px) return null const m = px.match(/^(-?\d+(?:\.\d+)?)px$/) return m ? parseFloat(m[1]) : null } export function sortTokensBySize(tokens: Token[], direction: 'asc' | 'desc' = 'desc'): Token[] { const factor = direction === 'desc' ? -1 : 1 return [...tokens].sort((a, b) => { const rankA = extractSizeRank(a.name) const rankB = extractSizeRank(b.name) if (rankA !== null && rankB !== null && rankA !== rankB) { return factor * (rankA - rankB) } if (rankA !== null && rankB === null) return -1 if (rankA === null && rankB !== null) return 1 const pxA = parsePxNumber(getTokenPixels(a.name)) const pxB = parsePxNumber(getTokenPixels(b.name)) if (pxA !== null && pxB !== null && pxA !== pxB) { return factor * (pxA - pxB) } return a.name.localeCompare(b.name) }) } export const copyableClass = 'cp-copyable' export const copyableCopiedClass = 'cp-copyable--copied' export const tokenTableClass = 'cp-token-table' /** * Split a token value into a rem part and a px part. When the raw value is * expressed in rem, both columns are filled (value in rem + computed px). * Otherwise the value goes into whichever column matches its unit. */ export function splitRemPx(token: Token): { px: string; rem: string } { const value = token.value.trim() const resolvedPx = getTokenPixels(token.name) ?? '' const dash = '—' const isRem = /^-?\d*\.?\d+rem$/.test(value) if (isRem) { return { rem: value, px: resolvedPx || dash } } const isPx = /^-?\d*\.?\d+px$/.test(value) if (isPx) { return { rem: dash, px: value } } return { rem: dash, px: resolvedPx || dash } } function ensureFoundationStyles(): void { if (typeof document === 'undefined') return const id = 'cp-foundation-styles' if (document.getElementById(id)) return const style = document.createElement('style') style.id = id style.textContent = ` .${copyableClass} { cursor: pointer; border-radius: 4px; padding: 2px 6px; margin: -2px -6px; transition: background-color 0.15s ease, color 0.15s ease; position: relative; } .${copyableClass}:hover { background-color: #eef0ff; } .${copyableClass}:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; } .${copyableClass}.${copyableCopiedClass} { background-color: #dcfce7; color: #065f46; } .${tokenTableClass} { width: 100%; border-collapse: collapse; text-align: left; } .${tokenTableClass} thead th { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280; padding: 10px 12px; border-bottom: 1px solid #e5e7eb; background: #f9fafb; } .${tokenTableClass} tbody td { padding: 10px 12px; border-bottom: 1px solid #f1f2f6; vertical-align: middle; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #6b7280; word-break: break-all; white-space: nowrap; } .${tokenTableClass} tbody td:first-child { color: #111; } .${tokenTableClass} tbody tr:last-child td { border-bottom: none; } .${tokenTableClass} tbody td.cp-token-table__preview { width: 1%; white-space: nowrap; } ` document.head.appendChild(style) } if (typeof document !== 'undefined') { ensureFoundationStyles() }