@invisibleloop/pulse 0.12.2 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/verify.md +9 -5
- package/.claude/pulse-checklist.md +21 -1
- package/.github/workflows/publish.yml +1 -0
- package/.pulse-verified +1 -0
- package/CLAUDE.md +56 -15
- package/docs/.claude/commands/verify.md +4 -0
- package/docs/.claude/pulse-checklist.md +36 -2
- package/docs/public/.pulse-ui-version +1 -1
- package/docs/public/andypixel.css +237 -0
- package/docs/public/bookworms.css +422 -0
- package/docs/public/coutumes.css +584 -0
- package/docs/public/images/bookworms/hero-books.jpg +0 -0
- package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/docs/public/images/journey/178.jpg +0 -0
- package/docs/public/images/journey/29.jpg +0 -0
- package/docs/public/images/retrowheels/hero.jpg +0 -0
- package/docs/public/images/shop/devbook-pro.jpg +0 -0
- package/docs/public/images/suzukibento/1015.jpg +0 -0
- package/docs/public/images/suzukibento/1025.jpg +0 -0
- package/docs/public/images/suzukibento/1043.jpg +0 -0
- package/docs/public/images/suzukibento/1059.jpg +0 -0
- package/docs/public/images/suzukibento/1062.jpg +0 -0
- package/docs/public/images/suzukibento/1084.jpg +0 -0
- package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/docs/public/journey.css +528 -0
- package/docs/public/pulse-ui.css +1051 -25
- package/docs/public/pulse-ui.js +6 -0
- package/docs/public/retrowheels.css +402 -0
- package/docs/public/shop.css +235 -0
- package/docs/public/sparkys.css +349 -0
- package/docs/public/suzukibento.css +517 -0
- package/docs/public/swish.css +394 -0
- package/docs/server.js +19 -4
- package/docs/src/lib/nav.js +14 -3
- package/docs/src/pages/components/decorate.js +74 -0
- package/docs/src/pages/components/gallery.js +102 -0
- package/docs/src/pages/components/hero.js +1 -0
- package/docs/src/pages/components/marquee.js +76 -0
- package/docs/src/pages/components/phone-frame.js +26 -7
- package/docs/src/pages/components/photo-card.js +74 -0
- package/docs/src/pages/components/section.js +40 -1
- package/docs/src/pages/meta.js +26 -0
- package/docs/src/pages/templates/andypixel.js +260 -0
- package/docs/src/pages/templates/bookworms.js +227 -0
- package/docs/src/pages/templates/coutumes.js +226 -0
- package/docs/src/pages/templates/journey.js +186 -0
- package/docs/src/pages/templates/retrowheels.js +342 -0
- package/docs/src/pages/templates/shop.js +257 -0
- package/docs/src/pages/templates/sparkys.js +249 -0
- package/docs/src/pages/templates/suzukibento.js +200 -0
- package/docs/src/pages/templates/swish.js +313 -0
- package/docs/src/pages/templates.js +313 -2
- package/docs/src/pages/testing.js +2 -1
- package/examples/andypixel.css +237 -0
- package/examples/andypixel.js +258 -0
- package/examples/bookworms.css +422 -0
- package/examples/bookworms.js +225 -0
- package/examples/contact.js +1 -0
- package/examples/counter.js +1 -0
- package/examples/dev.server.js +17 -0
- package/examples/journey.css +528 -0
- package/examples/journey.js +186 -0
- package/examples/pricing.js +1 -0
- package/examples/quiz.js +1 -0
- package/examples/retrowheels.css +402 -0
- package/examples/retrowheels.js +340 -0
- package/examples/shared.js +6 -0
- package/examples/suzukibento.css +513 -0
- package/examples/suzukibento.js +200 -0
- package/examples/swish.css +394 -0
- package/examples/swish.js +311 -0
- package/examples/todos.js +1 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/public/images/bookworms/hero-books.jpg +0 -0
- package/public/images/bookworms/how-we-read-now.jpg +0 -0
- package/public/images/bookworms/lantern-keepers.jpg +0 -0
- package/public/images/bookworms/provincial-bookseller.jpg +0 -0
- package/public/images/journey/178.jpg +0 -0
- package/public/images/journey/29.jpg +0 -0
- package/public/images/retrowheels/hero.jpg +0 -0
- package/public/images/shop/devbook-pro.jpg +0 -0
- package/public/images/suzukibento/1015.jpg +0 -0
- package/public/images/suzukibento/1025.jpg +0 -0
- package/public/images/suzukibento/1043.jpg +0 -0
- package/public/images/suzukibento/1059.jpg +0 -0
- package/public/images/suzukibento/1062.jpg +0 -0
- package/public/images/suzukibento/1084.jpg +0 -0
- package/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
- package/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
- package/public/images/swish/1485518882345-15568b007407.jpg +0 -0
- package/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
- package/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
- package/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
- package/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
- package/public/images/swish/1551803091-e20673f15770.jpg +0 -0
- package/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
- package/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
- package/public/pulse-ui.css +1051 -22
- package/public/pulse-ui.js +6 -0
- package/scripts/build.js +72 -18
- package/src/agent/checklist.md +20 -2
- package/src/agent/commands/verify.md +4 -0
- package/src/agent/guide-components.md +146 -8
- package/src/agent/guide-design-gallery.md +288 -0
- package/src/agent/guide-design-references.md +172 -0
- package/src/agent/guide-explore.md +313 -0
- package/src/agent/guide-styles.md +197 -11
- package/src/agent/guide-templates.md +220 -10
- package/src/agent/guide.md +9 -4
- package/src/agent/workflow.md +208 -58
- package/src/cli/dev.js +39 -7
- package/src/cli/fmt.js +113 -0
- package/src/cli/index.js +63 -30
- package/src/cli/scaffold.js +2 -2
- package/src/html.js +6 -1
- package/src/mcp/server.js +2415 -101
- package/src/mcp/validate-worker.js +34 -5
- package/src/runtime/ssr.js +4 -1
- package/src/server/index.js +22 -2
- package/src/spec/schema.js +5 -0
- package/src/testing/html.js +130 -4
- package/src/testing/testing.test.js +58 -0
- package/src/ui/banner.js +24 -9
- package/src/ui/brand-statement.js +58 -0
- package/src/ui/button.js +12 -1
- package/src/ui/card.js +20 -1
- package/src/ui/colophon.js +29 -0
- package/src/ui/contact-row.js +41 -0
- package/src/ui/cta.js +18 -4
- package/src/ui/decorate.js +55 -0
- package/src/ui/display-heading.js +44 -0
- package/src/ui/drop-cap.js +29 -0
- package/src/ui/editorial-hero.js +63 -0
- package/src/ui/entry-list.js +36 -0
- package/src/ui/feature.js +20 -3
- package/src/ui/footer.js +78 -9
- package/src/ui/gallery.js +56 -0
- package/src/ui/hero.js +68 -18
- package/src/ui/image-pair.js +72 -0
- package/src/ui/index.js +20 -0
- package/src/ui/input.js +1 -0
- package/src/ui/marquee.js +48 -0
- package/src/ui/nav.js +19 -9
- package/src/ui/phone-frame.js +6 -5
- package/src/ui/photo-card.js +44 -0
- package/src/ui/product-card.js +65 -0
- package/src/ui/pullquote.js +14 -3
- package/src/ui/search.js +1 -0
- package/src/ui/section-label.js +38 -0
- package/src/ui/section.js +1 -1
- package/src/ui/service-card.js +47 -0
- package/src/ui/stat-grid.js +40 -0
- package/src/ui/stat.js +16 -2
- package/src/ui/testimonial.js +17 -6
- package/src/ui/textarea.js +1 -0
- package/src/ui/top-strip.js +47 -0
- package/src/ui/trust-list.js +46 -0
- package/src/ui/ui.test.js +35 -0
|
@@ -18,7 +18,23 @@ let mod
|
|
|
18
18
|
try {
|
|
19
19
|
mod = await import(tmpFile)
|
|
20
20
|
} catch (err) {
|
|
21
|
-
|
|
21
|
+
// Give a targeted message for common import-resolution failures
|
|
22
|
+
const msg = err.message || ''
|
|
23
|
+
if (msg.includes('Cannot find package') || msg.includes('Cannot find module') || msg.includes('ERR_MODULE_NOT_FOUND')) {
|
|
24
|
+
const bare = msg.match(/Cannot find (?:package|module) '([^']+)'/)?.[1]
|
|
25
|
+
if (bare && !bare.startsWith('.') && !bare.startsWith('/') && !bare.startsWith('@invisibleloop/pulse')) {
|
|
26
|
+
process.stdout.write(
|
|
27
|
+
`Invalid: spec has an unresolvable import — \`${bare}\` is not a valid module specifier.\n` +
|
|
28
|
+
` • Use relative paths for project-local files: \`'../components/nav.js'\`\n` +
|
|
29
|
+
` • Use \`'@invisibleloop/pulse/ui'\` for Pulse components\n` +
|
|
30
|
+
` • Bare paths like \`'src/...' \` or \`'components/...'\` are not valid — they are not packages`
|
|
31
|
+
)
|
|
32
|
+
} else {
|
|
33
|
+
process.stdout.write(`Invalid: could not import spec — ${msg.split('\n')[0]}`)
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
process.stdout.write(`Invalid: could not parse — ${err.message}`)
|
|
37
|
+
}
|
|
22
38
|
process.exit(0)
|
|
23
39
|
}
|
|
24
40
|
|
|
@@ -37,6 +53,12 @@ if (!valid) {
|
|
|
37
53
|
// Render the view and run additional HTML checks
|
|
38
54
|
const warnings = [...schemaWarnings]
|
|
39
55
|
|
|
56
|
+
// meta.theme missing — a page without theme set will use the user's system preference,
|
|
57
|
+
// which can produce unexpected dark/light rendering for spec authors who built for one mode.
|
|
58
|
+
if (!spec.meta?.theme) {
|
|
59
|
+
warnings.push('meta.theme is not set — the page will follow the user\'s system colour-scheme preference. Add meta: { theme: \'light\' } or meta: { theme: \'dark\' } to lock the appearance. Most Pulse pages should set this explicitly.')
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
// Raw-response specs (contentType + render) produce non-HTML output —
|
|
41
63
|
// skip all view rendering and HTML-specific checks for them.
|
|
42
64
|
const isRawResponse = typeof spec.render === 'function' ||
|
|
@@ -98,7 +120,7 @@ try {
|
|
|
98
120
|
}
|
|
99
121
|
|
|
100
122
|
// Unknown u-* utility classes — catch invented classes that have no CSS definition
|
|
101
|
-
const KNOWN_UTILS = new Set(['u-absolute','u-bg-accent','u-bg-surface','u-bg-surface2','u-block','u-border','u-border-b','u-border-t','u-flex','u-flex-1','u-flex-col','u-flex-wrap','u-font-bold','u-font-medium','u-font-normal','u-font-semibold','u-gap-1','u-gap-2','u-gap-3','u-gap-4','u-gap-5','u-gap-6','u-gap-8','u-hidden','u-inline','u-inline-block','u-items-center','u-items-end','u-items-start','u-items-stretch','u-justify-between','u-justify-center','u-justify-end','u-justify-start','u-leading-loose','u-leading-normal','u-leading-relaxed','u-leading-snug','u-leading-tight','u-max-w-lg','u-max-w-md','u-max-w-prose','u-max-w-sm','u-max-w-xl','u-max-w-xs','u-mb-0','u-mb-1','u-mb-10','u-mb-12','u-mb-16','u-mb-2','u-mb-3','u-mb-4','u-mb-5','u-mb-6','u-mb-8','u-ml-auto','u-mr-auto','u-mt-0','u-mt-1','u-mt-10','u-mt-12','u-mt-16','u-mt-2','u-mt-3','u-mt-4','u-mt-5','u-mt-6','u-mt-8','u-mx-auto','u-opacity-50','u-opacity-75','u-overflow-auto','u-overflow-hidden','u-p-0','u-p-1','u-p-2','u-p-3','u-p-4','u-p-5','u-p-6','u-p-8','u-px-0','u-px-2','u-px-3','u-px-4','u-px-5','u-px-6','u-px-8','u-py-0','u-py-2','u-py-3','u-py-4','u-py-5','u-py-6','u-py-8','u-relative','u-rounded','u-rounded-full','u-rounded-lg','u-rounded-md','u-rounded-xl','u-shrink-0','u-text-2xl','u-text-3xl','u-text-4xl','u-text-accent','u-text-balance','u-text-base','u-text-blue','u-text-center','u-text-default','u-text-green','u-text-left','u-text-lg','u-text-muted','u-text-red','u-text-right','u-text-sm','u-text-xl','u-text-xs','u-text-yellow','u-w-auto','u-w-full'])
|
|
123
|
+
const KNOWN_UTILS = new Set(['u-absolute','u-bg-accent','u-bg-surface','u-bg-surface2','u-block','u-border','u-border-b','u-border-t','u-flex','u-flex-1','u-flex-col','u-flex-wrap','u-font-bold','u-font-medium','u-font-normal','u-font-semibold','u-gap-1','u-gap-2','u-gap-3','u-gap-4','u-gap-5','u-gap-6','u-gap-8','u-hidden','u-inline','u-inline-block','u-items-center','u-items-end','u-items-start','u-items-stretch','u-justify-between','u-justify-center','u-justify-end','u-justify-start','u-leading-loose','u-leading-normal','u-leading-relaxed','u-leading-snug','u-leading-tight','u-max-w-lg','u-max-w-md','u-max-w-prose','u-max-w-sm','u-max-w-xl','u-max-w-xs','u-mb-0','u-mb-1','u-mb-10','u-mb-12','u-mb-16','u-mb-2','u-mb-3','u-mb-4','u-mb-5','u-mb-6','u-mb-8','u-ml-auto','u-mr-auto','u-mt-0','u-mt-1','u-mt-10','u-mt-12','u-mt-16','u-mt-2','u-mt-3','u-mt-4','u-mt-5','u-mt-6','u-mt-8','u-mx-auto','u-opacity-50','u-opacity-75','u-overflow-auto','u-overflow-hidden','u-p-0','u-p-1','u-p-2','u-p-3','u-p-4','u-p-5','u-p-6','u-p-8','u-px-0','u-px-2','u-px-3','u-px-4','u-px-5','u-px-6','u-px-8','u-py-0','u-py-2','u-py-3','u-py-4','u-py-5','u-py-6','u-py-8','u-relative','u-rounded','u-rounded-full','u-rounded-lg','u-rounded-md','u-rounded-xl','u-shrink-0','u-tabular-nums','u-text-2xl','u-text-3xl','u-text-4xl','u-text-accent','u-text-balance','u-text-base','u-text-blue','u-text-center','u-text-default','u-text-green','u-text-left','u-text-lg','u-text-muted','u-text-red','u-text-right','u-text-sm','u-text-xl','u-text-xs','u-text-yellow','u-w-auto','u-w-full'])
|
|
102
124
|
const usedUtils = new Set([...html.matchAll(/\bu-([\w-]+)/g)].map(m => 'u-' + m[1]))
|
|
103
125
|
const unknownUtils = [...usedUtils].filter(c => !KNOWN_UTILS.has(c))
|
|
104
126
|
if (unknownUtils.length) {
|
|
@@ -110,9 +132,16 @@ try {
|
|
|
110
132
|
warnings.push('input[type="file"] detected — use the fileUpload() component instead. It provides the drag-and-drop zone, correct styling, and pulse-ui.js integration. import { fileUpload } from \'@invisibleloop/pulse/ui\'')
|
|
111
133
|
}
|
|
112
134
|
|
|
113
|
-
// Unescaped apostrophes
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
// Unescaped apostrophes — only flag inside attribute values (attr='it's wrong'),
|
|
136
|
+
// not in text content (it's fine in body text). Match: attr='...apostrophe...'
|
|
137
|
+
// Pattern: an attribute assignment with single-quote delimiter containing a second single quote
|
|
138
|
+
if (/\s[\w-]+=\s*'[^'<>]*'[^']*'/.test(html)) {
|
|
139
|
+
warnings.push('Possible unescaped apostrophe in an HTML attribute value — use ' or ' or switch to double-quoted attributes')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Inline <style> blocks in view — blocked by Pulse CSP nonce policy
|
|
143
|
+
if (/<style[\s>]/i.test(html)) {
|
|
144
|
+
warnings.push('Inline <style> block detected in view — Pulse\'s CSP (style-src with nonces) will silently block these styles at runtime. Move styles to public/app.css instead.')
|
|
116
145
|
}
|
|
117
146
|
} catch { /* view may depend on server data — skip HTML checks */ }
|
|
118
147
|
|
package/src/runtime/ssr.js
CHANGED
|
@@ -314,7 +314,10 @@ export function wrapDocument({ content, spec = {}, serverState = {}, storeState
|
|
|
314
314
|
const meta = spec.meta || {}
|
|
315
315
|
const title = meta.title || 'Pulse'
|
|
316
316
|
|
|
317
|
-
const bodyAttr =
|
|
317
|
+
const bodyAttr = [
|
|
318
|
+
meta.theme ? `data-theme="${esc(meta.theme)}"` : '',
|
|
319
|
+
meta.vibe ? `data-vibe="${esc(meta.vibe)}"` : '',
|
|
320
|
+
].filter(Boolean).map(a => ` ${a}`).join('')
|
|
318
321
|
|
|
319
322
|
const metaTags = [
|
|
320
323
|
meta.description ? `<meta name="description" content="${esc(meta.description)}">` : '',
|
package/src/server/index.js
CHANGED
|
@@ -39,6 +39,7 @@ const SECURITY_HEADERS = {
|
|
|
39
39
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
40
40
|
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
41
41
|
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
42
|
+
'X-Pulse': version,
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -779,7 +780,23 @@ export async function createServer(entries, options = {}) {
|
|
|
779
780
|
process.on('SIGINT', shutdown)
|
|
780
781
|
|
|
781
782
|
server.listen(port, () => {
|
|
782
|
-
|
|
783
|
+
import('../cli/fmt.js').then(({ c, table, icon }) => {
|
|
784
|
+
const url = `http://localhost:${port}`
|
|
785
|
+
const rows = specs.map(s => {
|
|
786
|
+
const hydrated = s.hydrate ? c.green('✓') : c.dim('—')
|
|
787
|
+
const method = s.method?.toUpperCase() || 'GET'
|
|
788
|
+
const route = s.route || '?'
|
|
789
|
+
return [c.cyan(route), c.dim(method), hydrated]
|
|
790
|
+
})
|
|
791
|
+
console.log(`\n ${icon.bolt()} ${c.bold('Pulse')} ${c.dim(`→ ${url}`)}\n`)
|
|
792
|
+
if (rows.length > 0) {
|
|
793
|
+
const tbl = table(['Route', 'Method', 'Hydrated'], rows)
|
|
794
|
+
console.log(tbl.split('\n').map(l => ' ' + l).join('\n'))
|
|
795
|
+
console.log()
|
|
796
|
+
}
|
|
797
|
+
}).catch(() => {
|
|
798
|
+
console.log(`⚡ Pulse running at http://localhost:${port}`)
|
|
799
|
+
})
|
|
783
800
|
})
|
|
784
801
|
|
|
785
802
|
return {
|
|
@@ -981,7 +998,10 @@ async function handleStreamResponse(spec, ctx, req, res, extraBody = '', dev = f
|
|
|
981
998
|
meta.schema ? ` <script type="application/ld+json">${JSON.stringify(meta.schema)}</script>` : '',
|
|
982
999
|
].filter(Boolean).join('\n')
|
|
983
1000
|
|
|
984
|
-
const bodyAttr =
|
|
1001
|
+
const bodyAttr = [
|
|
1002
|
+
meta.theme ? `data-theme="${escHtml(meta.theme)}"` : '',
|
|
1003
|
+
meta.vibe ? `data-vibe="${escHtml(meta.vibe)}"` : '',
|
|
1004
|
+
].filter(Boolean).map(a => ` ${a}`).join('')
|
|
985
1005
|
|
|
986
1006
|
const docOpen = `<!DOCTYPE html>
|
|
987
1007
|
<html lang="en">
|
package/src/spec/schema.js
CHANGED
|
@@ -389,6 +389,11 @@ export function validateSpec(spec) {
|
|
|
389
389
|
} else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
|
|
390
390
|
warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
|
|
391
391
|
}
|
|
392
|
+
// If meta.theme is not set, Pulse defaults to dark — warn so agents don't accidentally
|
|
393
|
+
// ship a dark-themed page when they intended light, or forget to declare intent.
|
|
394
|
+
if (spec.meta.theme === undefined && typeof spec.meta.theme !== 'function') {
|
|
395
|
+
warnings.push('spec.meta.theme is not set — Pulse defaults to dark. Add meta.theme: \'dark\' (or \'light\') to make the intent explicit and suppress this warning.')
|
|
396
|
+
}
|
|
392
397
|
}
|
|
393
398
|
|
|
394
399
|
return {
|
package/src/testing/html.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* No dependencies. Handles common HTML patterns well enough for test assertions.
|
|
5
5
|
* Supports: tag, .class, #id, [attr], [attr="value"] and combinations thereof.
|
|
6
|
-
*
|
|
6
|
+
* Supports descendant combinators (div p, .parent span) — findFirst/findAll walk the ancestor stack.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
@@ -152,14 +152,64 @@ export function tokenize(html) {
|
|
|
152
152
|
// Selector parsing
|
|
153
153
|
// ---------------------------------------------------------------------------
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Split a selector on whitespace that is NOT inside [...] attribute brackets.
|
|
157
|
+
* "span[aria-label="foo bar"]" → ["span[aria-label="foo bar"]"] (single part)
|
|
158
|
+
* "div p span" → ["div", "p", "span"]
|
|
159
|
+
*
|
|
160
|
+
* @param {string} selector
|
|
161
|
+
* @returns {string[]}
|
|
162
|
+
*/
|
|
163
|
+
function splitDescendant(selector) {
|
|
164
|
+
const parts = []
|
|
165
|
+
let current = ''
|
|
166
|
+
let inBracket = false
|
|
167
|
+
let inQuote = false
|
|
168
|
+
let quoteChar = ''
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < selector.length; i++) {
|
|
171
|
+
const c = selector[i]
|
|
172
|
+
if (inQuote) {
|
|
173
|
+
current += c
|
|
174
|
+
if (c === quoteChar) inQuote = false
|
|
175
|
+
} else if (inBracket) {
|
|
176
|
+
current += c
|
|
177
|
+
if (c === '"' || c === "'") { inQuote = true; quoteChar = c }
|
|
178
|
+
else if (c === ']') inBracket = false
|
|
179
|
+
} else if (c === '[') {
|
|
180
|
+
inBracket = true
|
|
181
|
+
current += c
|
|
182
|
+
} else if (c === ' ') {
|
|
183
|
+
if (current.trim()) parts.push(current.trim())
|
|
184
|
+
current = ''
|
|
185
|
+
} else {
|
|
186
|
+
current += c
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (current.trim()) parts.push(current.trim())
|
|
190
|
+
return parts
|
|
191
|
+
}
|
|
192
|
+
|
|
155
193
|
/**
|
|
156
194
|
* Parse a simple CSS selector string.
|
|
157
195
|
* Supports: tag .class #id [attr] [attr="value"] and combinations.
|
|
196
|
+
* Also supports descendant combinators: parent child grandchild
|
|
158
197
|
*
|
|
159
198
|
* @param {string} selector
|
|
160
|
-
* @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }}
|
|
199
|
+
* @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }} | Array of same
|
|
161
200
|
*/
|
|
162
201
|
export function parseSelector(selector) {
|
|
202
|
+
const trimmed = selector.trim()
|
|
203
|
+
const parts = splitDescendant(trimmed)
|
|
204
|
+
|
|
205
|
+
if (parts.length > 1) {
|
|
206
|
+
return parts.map(parseSingleSelector)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return parseSingleSelector(trimmed)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseSingleSelector(selector) {
|
|
163
213
|
const result = { tag: null, id: null, classes: [], attrs: [] }
|
|
164
214
|
let s = selector.trim()
|
|
165
215
|
|
|
@@ -255,29 +305,105 @@ export function extractText(tokens) {
|
|
|
255
305
|
|
|
256
306
|
/**
|
|
257
307
|
* Find the first token matching selector.
|
|
308
|
+
* Supports descendant combinators: parent child
|
|
258
309
|
* @param {Array} tokens
|
|
259
310
|
* @param {string} selector
|
|
260
311
|
* @returns {{ token, index } | null}
|
|
261
312
|
*/
|
|
262
313
|
export function findFirst(tokens, selector) {
|
|
263
314
|
const sel = parseSelector(selector)
|
|
315
|
+
|
|
316
|
+
// Simple selector (no spaces)
|
|
317
|
+
if (!Array.isArray(sel)) {
|
|
318
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
319
|
+
if (matchesToken(tokens[i], sel)) return { token: tokens[i], index: i }
|
|
320
|
+
}
|
|
321
|
+
return null
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Descendant selector (array of selectors)
|
|
325
|
+
const target = sel[sel.length - 1] // Last selector is the element we're looking for
|
|
326
|
+
const ancestors = sel.slice(0, -1) // Earlier selectors are ancestor requirements
|
|
327
|
+
|
|
264
328
|
for (let i = 0; i < tokens.length; i++) {
|
|
265
|
-
if (matchesToken(tokens[i],
|
|
329
|
+
if (matchesToken(tokens[i], target)) {
|
|
330
|
+
// Check if all ancestor requirements are met
|
|
331
|
+
if (hasAncestors(tokens, i, ancestors)) {
|
|
332
|
+
return { token: tokens[i], index: i }
|
|
333
|
+
}
|
|
334
|
+
}
|
|
266
335
|
}
|
|
267
336
|
return null
|
|
268
337
|
}
|
|
269
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Check if a token at index has all required ancestors.
|
|
341
|
+
* @param {Array} tokens
|
|
342
|
+
* @param {number} index - index of the target token
|
|
343
|
+
* @param {Array} ancestorSels - array of ancestor selectors to match
|
|
344
|
+
*/
|
|
345
|
+
function hasAncestors(tokens, index, ancestorSels) {
|
|
346
|
+
if (ancestorSels.length === 0) return true
|
|
347
|
+
|
|
348
|
+
// Build a stack of open tags from start to index
|
|
349
|
+
const stack = []
|
|
350
|
+
for (let i = 0; i < index; i++) {
|
|
351
|
+
if (tokens[i].type === 'open') {
|
|
352
|
+
stack.push(tokens[i])
|
|
353
|
+
} else if (tokens[i].type === 'close') {
|
|
354
|
+
stack.pop()
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Now check if the stack contains all required ancestors in order
|
|
359
|
+
// We need to find each ancestor selector somewhere in the stack, in order
|
|
360
|
+
let stackIdx = 0
|
|
361
|
+
for (const ancestorSel of ancestorSels) {
|
|
362
|
+
let found = false
|
|
363
|
+
while (stackIdx < stack.length) {
|
|
364
|
+
if (matchesToken(stack[stackIdx], ancestorSel)) {
|
|
365
|
+
found = true
|
|
366
|
+
stackIdx++ // Move past this one for the next ancestor search
|
|
367
|
+
break
|
|
368
|
+
}
|
|
369
|
+
stackIdx++
|
|
370
|
+
}
|
|
371
|
+
if (!found) return false
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return true
|
|
375
|
+
}
|
|
376
|
+
|
|
270
377
|
/**
|
|
271
378
|
* Find all tokens matching selector.
|
|
379
|
+
* Supports descendant combinators: parent child
|
|
272
380
|
* @param {Array} tokens
|
|
273
381
|
* @param {string} selector
|
|
274
382
|
* @returns {Array<{ token, index }>}
|
|
275
383
|
*/
|
|
276
384
|
export function findAll(tokens, selector) {
|
|
277
385
|
const sel = parseSelector(selector)
|
|
386
|
+
|
|
387
|
+
// Simple selector (no spaces)
|
|
388
|
+
if (!Array.isArray(sel)) {
|
|
389
|
+
const results = []
|
|
390
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
391
|
+
if (matchesToken(tokens[i], sel)) results.push({ token: tokens[i], index: i })
|
|
392
|
+
}
|
|
393
|
+
return results
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Descendant selector (array of selectors)
|
|
397
|
+
const target = sel[sel.length - 1]
|
|
398
|
+
const ancestors = sel.slice(0, -1)
|
|
399
|
+
|
|
278
400
|
const results = []
|
|
279
401
|
for (let i = 0; i < tokens.length; i++) {
|
|
280
|
-
if (matchesToken(tokens[i],
|
|
402
|
+
if (matchesToken(tokens[i], target)) {
|
|
403
|
+
if (hasAncestors(tokens, i, ancestors)) {
|
|
404
|
+
results.push({ token: tokens[i], index: i })
|
|
405
|
+
}
|
|
406
|
+
}
|
|
281
407
|
}
|
|
282
408
|
return results
|
|
283
409
|
}
|
|
@@ -214,6 +214,24 @@ await test('matches attribute selector [attr="value"] (negative)', async () => {
|
|
|
214
214
|
assert(!result.has('input[type="password"]'), 'Expected no password input')
|
|
215
215
|
})
|
|
216
216
|
|
|
217
|
+
await test('matches attribute selector [attr="value with spaces"]', async () => {
|
|
218
|
+
const spec = {
|
|
219
|
+
route: '/test-attr-spaces',
|
|
220
|
+
view: () => `<div><span aria-label="3 items in cart">3</span></div>`,
|
|
221
|
+
}
|
|
222
|
+
const result = renderSync(spec)
|
|
223
|
+
assert(result.has('span[aria-label="3 items in cart"]'), 'Expected span with aria-label containing spaces')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await test('descendant selector not confused by spaces in attr value', async () => {
|
|
227
|
+
const spec = {
|
|
228
|
+
route: '/test-desc-attr',
|
|
229
|
+
view: () => `<nav><a href="/cart" aria-label="3 items in cart">Cart</a></nav>`,
|
|
230
|
+
}
|
|
231
|
+
const result = renderSync(spec)
|
|
232
|
+
assert(result.has('nav a[aria-label="3 items in cart"]'), 'Expected descendant with spaced attr value')
|
|
233
|
+
})
|
|
234
|
+
|
|
217
235
|
// ---------------------------------------------------------------------------
|
|
218
236
|
|
|
219
237
|
console.log('\nresult.find() and result.get()\n')
|
|
@@ -444,6 +462,46 @@ await test('renderSync returns raw html string', async () => {
|
|
|
444
462
|
assert(typeof result.html === 'string', 'Expected html to be a string')
|
|
445
463
|
})
|
|
446
464
|
|
|
465
|
+
console.log('\nDescendant selectors\n')
|
|
466
|
+
|
|
467
|
+
await test('descendant selector finds nested elements', async () => {
|
|
468
|
+
const spec = {
|
|
469
|
+
route: '/list',
|
|
470
|
+
state: {},
|
|
471
|
+
view: () => `<div><ul class="items"><li>A</li><li>B</li></ul><ul class="other"><li>X</li></ul></div>`,
|
|
472
|
+
}
|
|
473
|
+
const result = renderSync(spec)
|
|
474
|
+
assertEqual(result.count('.items li'), 2)
|
|
475
|
+
assertEqual(result.count('.other li'), 1)
|
|
476
|
+
assertEqual(result.count('ul li'), 3)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
await test('descendant selector with multiple levels', async () => {
|
|
480
|
+
const spec = {
|
|
481
|
+
route: '/table',
|
|
482
|
+
state: {},
|
|
483
|
+
view: () => `<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Alice</td></tr><tr><td>Bob</td></tr></tbody></table>`,
|
|
484
|
+
}
|
|
485
|
+
const result = renderSync(spec)
|
|
486
|
+
assertEqual(result.count('tbody tr'), 2)
|
|
487
|
+
assertEqual(result.count('thead tr'), 1)
|
|
488
|
+
const cells = result.findAll('tbody td')
|
|
489
|
+
assertEqual(cells.length, 2)
|
|
490
|
+
assertEqual(cells[0].text, 'Alice')
|
|
491
|
+
assertEqual(cells[1].text, 'Bob')
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
await test('descendant selector returns empty for no matches', async () => {
|
|
495
|
+
const spec = {
|
|
496
|
+
route: '/empty',
|
|
497
|
+
state: {},
|
|
498
|
+
view: () => `<div class="container"><span>text</span></div>`,
|
|
499
|
+
}
|
|
500
|
+
const result = renderSync(spec)
|
|
501
|
+
assertEqual(result.count('.container li'), 0)
|
|
502
|
+
assertEqual(result.find('.container li'), null)
|
|
503
|
+
})
|
|
504
|
+
|
|
447
505
|
// ---------------------------------------------------------------------------
|
|
448
506
|
|
|
449
507
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
|
package/src/ui/banner.js
CHANGED
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
* Full-width announcement bar — sits above the nav for promotions,
|
|
5
5
|
* notices, or launch announcements.
|
|
6
6
|
*
|
|
7
|
-
* @param {object}
|
|
8
|
-
* @param {string}
|
|
9
|
-
* @param {'info'|'promo'|'warning'} opts.variant
|
|
10
|
-
* @param {
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.content - Raw HTML slot
|
|
9
|
+
* @param {'info'|'promo'|'warning'} opts.variant - Visual style (default: 'info')
|
|
10
|
+
* @param {'top'|'bottom'} opts.position - 'bottom' fixes the bar to the bottom of the viewport
|
|
11
|
+
* @param {boolean} opts.dismissible - Show a close (×) button
|
|
12
|
+
* @param {string} opts.dismissEvent - Mutation name fired on close (default: 'dismissBanner')
|
|
13
|
+
* @param {string} opts.class
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import { escHtml as e } from '../html.js'
|
|
@@ -15,12 +18,24 @@ import { escHtml as e } from '../html.js'
|
|
|
15
18
|
const VARIANTS = new Set(['info', 'promo', 'warning'])
|
|
16
19
|
|
|
17
20
|
export function banner({
|
|
18
|
-
content
|
|
19
|
-
variant
|
|
20
|
-
|
|
21
|
+
content = '',
|
|
22
|
+
variant = 'info',
|
|
23
|
+
position = 'top',
|
|
24
|
+
dismissible = false,
|
|
25
|
+
dismissEvent = 'dismissBanner',
|
|
26
|
+
class: cls = '',
|
|
21
27
|
} = {}) {
|
|
22
28
|
if (!VARIANTS.has(variant)) variant = 'info'
|
|
23
|
-
const classes = [
|
|
29
|
+
const classes = [
|
|
30
|
+
'ui-banner',
|
|
31
|
+
`ui-banner--${variant}`,
|
|
32
|
+
position === 'bottom' && 'ui-banner--bottom',
|
|
33
|
+
cls,
|
|
34
|
+
].filter(Boolean).join(' ')
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
const closeBtn = dismissible
|
|
37
|
+
? `<button class="ui-banner-close" data-event="${e(dismissEvent)}" aria-label="Close announcement">×</button>`
|
|
38
|
+
: ''
|
|
39
|
+
|
|
40
|
+
return `<div class="${e(classes)}" role="banner">${content}${closeBtn}</div>`
|
|
26
41
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Brand Statement
|
|
3
|
+
*
|
|
4
|
+
* Full-width centred editorial statement — large serif or display type used
|
|
5
|
+
* for brand voice paragraphs between visual sections. This is not a pull-quote
|
|
6
|
+
* (no attribution border) — it is a first-person brand declaration.
|
|
7
|
+
*
|
|
8
|
+
* Typical use: "pieces that are both eclectic and everyday. Subtly precious,
|
|
9
|
+
* expressive, and refined…"
|
|
10
|
+
*
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {string} opts.text - Statement text (can include basic HTML like <em>, <br>)
|
|
13
|
+
* @param {string} opts.attribution - Optional small attribution or tagline below
|
|
14
|
+
* @param {'serif'|'display'|'sans'} opts.font - Font style (default: 'serif')
|
|
15
|
+
* @param {'sm'|'md'|'lg'} opts.size - Text size (default: 'md')
|
|
16
|
+
* @param {'left'|'center'|'right'} opts.align - Text alignment (default: 'center')
|
|
17
|
+
* @param {string} opts.maxWidth - CSS max-width value (default: '860px')
|
|
18
|
+
* @param {string} opts.class
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { escHtml as e } from '../html.js'
|
|
22
|
+
|
|
23
|
+
const FONTS = new Set(['serif', 'display', 'sans'])
|
|
24
|
+
const SIZES = new Set(['sm', 'md', 'lg'])
|
|
25
|
+
const ALIGNS = new Set(['left', 'center', 'right'])
|
|
26
|
+
|
|
27
|
+
export function brandStatement({
|
|
28
|
+
text = '',
|
|
29
|
+
attribution = '',
|
|
30
|
+
font = 'serif',
|
|
31
|
+
size = 'md',
|
|
32
|
+
align = 'center',
|
|
33
|
+
maxWidth = '',
|
|
34
|
+
class: cls = '',
|
|
35
|
+
} = {}) {
|
|
36
|
+
if (!FONTS.has(font)) font = 'serif'
|
|
37
|
+
if (!SIZES.has(size)) size = 'md'
|
|
38
|
+
if (!ALIGNS.has(align)) align = 'center'
|
|
39
|
+
|
|
40
|
+
const classes = [
|
|
41
|
+
'ui-brand-statement',
|
|
42
|
+
font !== 'serif' && `ui-brand-statement--${font}`,
|
|
43
|
+
size !== 'md' && `ui-brand-statement--${size}`,
|
|
44
|
+
align !== 'center' && `ui-brand-statement--${align}`,
|
|
45
|
+
cls,
|
|
46
|
+
].filter(Boolean).join(' ')
|
|
47
|
+
|
|
48
|
+
const innerStyle = maxWidth
|
|
49
|
+
? ` style="max-width:${e(typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth)}"`
|
|
50
|
+
: ''
|
|
51
|
+
|
|
52
|
+
return `<div class="${e(classes)}">
|
|
53
|
+
<div class="ui-brand-statement-inner"${innerStyle}>
|
|
54
|
+
<p class="ui-brand-statement-text">${text}</p>
|
|
55
|
+
${attribution ? `<p class="ui-brand-statement-attr">${e(attribution)}</p>` : ''}
|
|
56
|
+
</div>
|
|
57
|
+
</div>`
|
|
58
|
+
}
|
package/src/ui/button.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* All visual variation goes through CSS modifier classes — never inline styles.
|
|
6
6
|
*
|
|
7
7
|
* @param {object} opts
|
|
8
|
-
* @param {string} opts.label - Visible text
|
|
8
|
+
* @param {string} opts.label - Visible text — HTML is escaped. For SVG/icon content use opts.icon or opts.iconAfter.
|
|
9
9
|
* @param {'primary'|'secondary'|'ghost'|'danger'} opts.variant
|
|
10
10
|
* @param {'sm'|'md'|'lg'} opts.size
|
|
11
11
|
* @param {string} opts.href - Renders as <a> when set
|
|
@@ -39,6 +39,16 @@ export function button({
|
|
|
39
39
|
if (!VARIANTS.has(variant)) variant = 'primary'
|
|
40
40
|
if (!SIZES.has(size)) size = 'md'
|
|
41
41
|
|
|
42
|
+
// Warn if href passed via attrs instead of the href prop (produces invalid <button href>)
|
|
43
|
+
if (typeof window === 'undefined' && !href && attrs.href) {
|
|
44
|
+
console.warn('[Pulse button] href passed via attrs produces invalid HTML (<button href>). Use button({ href: "..." }) instead — it renders an <a> automatically.')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Warn if label contains HTML — it will be escaped. Use icon/iconAfter for SVG content.
|
|
48
|
+
if (typeof window === 'undefined' && label && label.includes('<')) {
|
|
49
|
+
console.warn('[Pulse button] label is HTML-escaped — SVG/HTML passed as label will render as text. Use icon: svgString or iconAfter: svgString instead.')
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
const classes = [
|
|
43
53
|
'ui-btn',
|
|
44
54
|
`ui-btn--${variant}`,
|
|
@@ -59,6 +69,7 @@ export function button({
|
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
const attrsStr = Object.entries(attrs)
|
|
72
|
+
.filter(([, v]) => v !== undefined)
|
|
62
73
|
.map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
|
|
63
74
|
.join('')
|
|
64
75
|
|
package/src/ui/card.js
CHANGED
|
@@ -11,20 +11,39 @@
|
|
|
11
11
|
* @param {string} opts.content - HTML string for the card body
|
|
12
12
|
* @param {string} opts.footer - HTML string for the card footer (optional)
|
|
13
13
|
* @param {boolean} opts.flush - Remove internal padding (for full-bleed content)
|
|
14
|
+
* @param {'default'|'elevated'|'bordered'|'flat'|'glass'|'tinted'} opts.variant
|
|
15
|
+
* Visual style:
|
|
16
|
+
* - 'default' — standard surface with subtle shadow (same as no variant)
|
|
17
|
+
* - 'elevated' — stronger shadow, lifts above the page
|
|
18
|
+
* - 'bordered' — no shadow, prominent border
|
|
19
|
+
* - 'flat' — no shadow, no border, background only
|
|
20
|
+
* - 'glass' — frosted-glass effect with backdrop blur
|
|
21
|
+
* - 'tinted' — accent-tinted background, no shadow
|
|
14
22
|
* @param {string} opts.class
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
25
|
import { escHtml as e } from '../html.js'
|
|
18
26
|
|
|
27
|
+
const VARIANTS = new Set(['default', 'elevated', 'bordered', 'flat', 'glass', 'tinted'])
|
|
28
|
+
|
|
19
29
|
export function card({
|
|
20
30
|
title = '',
|
|
21
31
|
level = 3,
|
|
22
32
|
content = '',
|
|
23
33
|
footer = '',
|
|
24
34
|
flush = false,
|
|
35
|
+
variant = 'default',
|
|
25
36
|
class: cls = '',
|
|
26
37
|
} = {}) {
|
|
27
|
-
|
|
38
|
+
if (!VARIANTS.has(variant)) variant = 'default'
|
|
39
|
+
|
|
40
|
+
const classes = [
|
|
41
|
+
'ui-card',
|
|
42
|
+
variant !== 'default' ? `ui-card--${variant}` : '',
|
|
43
|
+
flush ? 'ui-card--flush' : '',
|
|
44
|
+
cls,
|
|
45
|
+
].filter(Boolean).join(' ')
|
|
46
|
+
|
|
28
47
|
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
29
48
|
|
|
30
49
|
const titleHtml = title ? `<div class="ui-card-header"><${tag} class="ui-card-title">${e(title)}</${tag}></div>` : ''
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Colophon
|
|
3
|
+
*
|
|
4
|
+
* Paperback-style end-of-page metadata block. A minimal footer
|
|
5
|
+
* variant with tight type, no columns, centered layout.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.content - Raw HTML content slot
|
|
9
|
+
* @param {'left'|'center'} opts.align - Text alignment (default 'center')
|
|
10
|
+
* @param {string} opts.class
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { escHtml as e } from '../html.js'
|
|
14
|
+
|
|
15
|
+
export function colophon({
|
|
16
|
+
content = '',
|
|
17
|
+
align = 'center',
|
|
18
|
+
class: cls = '',
|
|
19
|
+
} = {}) {
|
|
20
|
+
const classes = [
|
|
21
|
+
'ui-colophon',
|
|
22
|
+
align === 'left' && 'ui-colophon--left',
|
|
23
|
+
cls,
|
|
24
|
+
].filter(Boolean).join(' ')
|
|
25
|
+
|
|
26
|
+
return `<footer class="${e(classes)}">
|
|
27
|
+
<div class="ui-colophon-inner">${content}</div>
|
|
28
|
+
</footer>`
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — ContactRow
|
|
3
|
+
*
|
|
4
|
+
* A horizontal strip of contact items — phone, email, address, hours.
|
|
5
|
+
* Universal on local-business and trades sites. Renders as a <ul> of
|
|
6
|
+
* icon + label pairs, each optionally linked.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {Array<{icon?: string, label: string, href?: string}>} opts.items
|
|
10
|
+
* @param {'sm'|'md'} opts.size - Text size (default: 'md')
|
|
11
|
+
* @param {string} opts.class
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { escHtml as e } from '../html.js'
|
|
15
|
+
|
|
16
|
+
const SIZES = new Set(['sm', 'md'])
|
|
17
|
+
|
|
18
|
+
export function contactRow({
|
|
19
|
+
items = [],
|
|
20
|
+
size = 'md',
|
|
21
|
+
class: cls = '',
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (!SIZES.has(size)) size = 'md'
|
|
24
|
+
|
|
25
|
+
const classes = [
|
|
26
|
+
'ui-contact-row',
|
|
27
|
+
size !== 'md' && `ui-contact-row--${size}`,
|
|
28
|
+
cls,
|
|
29
|
+
].filter(Boolean).join(' ')
|
|
30
|
+
|
|
31
|
+
const itemsHtml = items.map(({ icon = '', label = '', href = '' }) => {
|
|
32
|
+
const iconHtml = icon ? `<span class="ui-contact-row-icon" aria-hidden="true">${icon}</span>` : ''
|
|
33
|
+
const inner = `${iconHtml}<span>${e(label)}</span>`
|
|
34
|
+
const content = href
|
|
35
|
+
? `<a href="${e(href)}">${inner}</a>`
|
|
36
|
+
: inner
|
|
37
|
+
return `<li class="ui-contact-row-item">${content}</li>`
|
|
38
|
+
}).join('')
|
|
39
|
+
|
|
40
|
+
return `<ul class="${e(classes)}">${itemsHtml}</ul>`
|
|
41
|
+
}
|