@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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Image Pair
|
|
3
|
+
*
|
|
4
|
+
* Two images side by side — no text. A pure editorial layout used in fashion,
|
|
5
|
+
* portfolio, and magazine contexts to present photography at full scale.
|
|
6
|
+
* Both images share the same aspect ratio and fill equal columns.
|
|
7
|
+
*
|
|
8
|
+
* For image + text layouts, use the media() component instead.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {string} opts.leftSrc - Left image URL
|
|
12
|
+
* @param {string} opts.leftAlt - Left image alt text
|
|
13
|
+
* @param {string} opts.rightSrc - Right image URL
|
|
14
|
+
* @param {string} opts.rightAlt - Right image alt text
|
|
15
|
+
* @param {string} opts.ratio - CSS aspect-ratio for both images (default: '3/4')
|
|
16
|
+
* @param {'none'|'sm'|'md'} opts.gap - Gap between images (default: 'none')
|
|
17
|
+
* @param {string} opts.label - Optional aria-label for the section
|
|
18
|
+
* @param {string} opts.leftLabel - Link label below left image (e.g. 'Craftmanship')
|
|
19
|
+
* @param {string} opts.leftHref - href for left link
|
|
20
|
+
* @param {string} opts.rightLabel - Link label below right image (e.g. 'Store')
|
|
21
|
+
* @param {string} opts.rightHref - href for right link
|
|
22
|
+
* @param {string} opts.class
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { escHtml as e } from '../html.js'
|
|
26
|
+
|
|
27
|
+
const GAPS = new Set(['none', 'sm', 'md'])
|
|
28
|
+
|
|
29
|
+
export function imagePair({
|
|
30
|
+
leftSrc = '',
|
|
31
|
+
leftAlt = '',
|
|
32
|
+
rightSrc = '',
|
|
33
|
+
rightAlt = '',
|
|
34
|
+
ratio = '3/4',
|
|
35
|
+
gap = 'none',
|
|
36
|
+
label = '',
|
|
37
|
+
leftLabel = '',
|
|
38
|
+
leftHref = '#',
|
|
39
|
+
rightLabel = '',
|
|
40
|
+
rightHref = '#',
|
|
41
|
+
class: cls = '',
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (!GAPS.has(gap)) gap = 'none'
|
|
44
|
+
|
|
45
|
+
const classes = [
|
|
46
|
+
'ui-image-pair',
|
|
47
|
+
gap !== 'none' && `ui-image-pair--gap-${gap}`,
|
|
48
|
+
(leftLabel || rightLabel) && 'ui-image-pair--captioned',
|
|
49
|
+
cls,
|
|
50
|
+
].filter(Boolean).join(' ')
|
|
51
|
+
|
|
52
|
+
const labelAttr = label ? ` aria-label="${e(label)}"` : ''
|
|
53
|
+
const cropStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
|
|
54
|
+
|
|
55
|
+
const leftCaption = leftLabel ? `<a href="${e(leftHref)}" class="ui-image-pair-link">${e(leftLabel)} →</a>` : ''
|
|
56
|
+
const rightCaption = rightLabel ? `<a href="${e(rightHref)}" class="ui-image-pair-link">${e(rightLabel)} →</a>` : ''
|
|
57
|
+
|
|
58
|
+
return `<div class="${e(classes)}"${labelAttr}>
|
|
59
|
+
<div class="ui-image-pair-col">
|
|
60
|
+
<div class="ui-image-pair-crop"${cropStyle}>
|
|
61
|
+
<img src="${e(leftSrc)}" alt="${e(leftAlt)}" loading="lazy" decoding="async">
|
|
62
|
+
</div>
|
|
63
|
+
${leftCaption}
|
|
64
|
+
</div>
|
|
65
|
+
<div class="ui-image-pair-col">
|
|
66
|
+
<div class="ui-image-pair-crop"${cropStyle}>
|
|
67
|
+
<img src="${e(rightSrc)}" alt="${e(rightAlt)}" loading="lazy" decoding="async">
|
|
68
|
+
</div>
|
|
69
|
+
${rightCaption}
|
|
70
|
+
</div>
|
|
71
|
+
</div>`
|
|
72
|
+
}
|
package/src/ui/index.js
CHANGED
|
@@ -67,9 +67,29 @@ export { fileUpload } from './fileupload.js'
|
|
|
67
67
|
export { breadcrumbs } from './breadcrumbs.js'
|
|
68
68
|
export { stepper } from './stepper.js'
|
|
69
69
|
export { uiImage } from './uiimage.js'
|
|
70
|
+
export { gallery } from './gallery.js'
|
|
71
|
+
export { photoCard } from './photo-card.js'
|
|
72
|
+
export { productCard } from './product-card.js'
|
|
73
|
+
export { editorialHero } from './editorial-hero.js'
|
|
74
|
+
export { imagePair } from './image-pair.js'
|
|
75
|
+
export { brandStatement } from './brand-statement.js'
|
|
76
|
+
export { marquee } from './marquee.js'
|
|
77
|
+
export { serviceCard } from './service-card.js'
|
|
78
|
+
export { contactRow } from './contact-row.js'
|
|
79
|
+
export { trustList } from './trust-list.js'
|
|
80
|
+
export { topStrip } from './top-strip.js'
|
|
81
|
+
export { statGrid } from './stat-grid.js'
|
|
82
|
+
export { decorate } from './decorate.js'
|
|
70
83
|
export { pullquote } from './pullquote.js'
|
|
71
84
|
export { prose } from './prose.js'
|
|
72
85
|
export { heading } from './heading.js'
|
|
86
|
+
|
|
87
|
+
// Editorial components
|
|
88
|
+
export { entryList } from './entry-list.js'
|
|
89
|
+
export { displayHeading } from './display-heading.js'
|
|
90
|
+
export { sectionLabel } from './section-label.js'
|
|
91
|
+
export { dropCap } from './drop-cap.js'
|
|
92
|
+
export { colophon } from './colophon.js'
|
|
73
93
|
export { list } from './list.js'
|
|
74
94
|
export {
|
|
75
95
|
iconArrowLeft, iconArrowRight, iconArrowUp, iconArrowDown,
|
package/src/ui/input.js
CHANGED
|
@@ -43,6 +43,7 @@ export function input({
|
|
|
43
43
|
const wrapClasses = ['ui-field', error ? 'ui-field--error' : '', cls].filter(Boolean).join(' ')
|
|
44
44
|
|
|
45
45
|
const attrsStr = Object.entries(attrs)
|
|
46
|
+
.filter(([, v]) => v !== undefined)
|
|
46
47
|
.map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
|
|
47
48
|
.join('')
|
|
48
49
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Marquee
|
|
3
|
+
*
|
|
4
|
+
* CSS-only infinite scrolling strip. Ideal for logo clouds, trust badges,
|
|
5
|
+
* press mentions, or tag lists. No JavaScript — pure CSS animation.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string[]} opts.items - Array of raw HTML strings (logos, badges, text)
|
|
9
|
+
* @param {number} opts.speed - Duration in seconds for one full loop (default: 30)
|
|
10
|
+
* @param {'sm'|'md'|'lg'} opts.gap - Gap between items (default: 'lg')
|
|
11
|
+
* @param {'left'|'right'} opts.direction - Scroll direction (default: 'left')
|
|
12
|
+
* @param {boolean} opts.pause - Pause animation on hover (default: true)
|
|
13
|
+
* @param {boolean} opts.fade - Fade edges with gradient mask (default: true)
|
|
14
|
+
* @param {string} opts.class
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { escHtml as e } from '../html.js'
|
|
18
|
+
|
|
19
|
+
const GAPS = new Set(['sm', 'md', 'lg'])
|
|
20
|
+
|
|
21
|
+
export function marquee({
|
|
22
|
+
items = [],
|
|
23
|
+
speed = 30,
|
|
24
|
+
gap = 'lg',
|
|
25
|
+
direction = 'left',
|
|
26
|
+
pause = true,
|
|
27
|
+
fade = true,
|
|
28
|
+
class: cls = '',
|
|
29
|
+
} = {}) {
|
|
30
|
+
if (!GAPS.has(gap)) gap = 'lg'
|
|
31
|
+
const safeSpeed = Math.max(5, parseFloat(speed) || 30)
|
|
32
|
+
const dir = direction === 'right' ? 'reverse' : 'normal'
|
|
33
|
+
|
|
34
|
+
const classes = [
|
|
35
|
+
'ui-marquee',
|
|
36
|
+
gap !== 'lg' && `ui-marquee--gap-${gap}`,
|
|
37
|
+
pause && 'ui-marquee--pause',
|
|
38
|
+
fade && 'ui-marquee--fade',
|
|
39
|
+
cls,
|
|
40
|
+
].filter(Boolean).join(' ')
|
|
41
|
+
|
|
42
|
+
// Items are duplicated so the loop is seamless
|
|
43
|
+
const itemsHtml = items.map(item => `<li class="ui-marquee-item">${item}</li>`).join('')
|
|
44
|
+
|
|
45
|
+
return `<div class="${e(classes)}" style="--marquee-speed:${safeSpeed}s;--marquee-dir:${dir}" aria-hidden="true">
|
|
46
|
+
<ul class="ui-marquee-track">${itemsHtml}${itemsHtml}</ul>
|
|
47
|
+
</div>`
|
|
48
|
+
}
|
package/src/ui/nav.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
* @param {object} opts
|
|
21
21
|
* @param {string} opts.logo - Raw HTML slot — SVG, img, or text
|
|
22
22
|
* @param {string} opts.logoHref - Logo link destination (default: '/')
|
|
23
|
-
* @param {Array<{label:string, href?:string, mega?:Array}>} opts.links
|
|
23
|
+
* @param {Array<{label:string, href?:string, current?:boolean, mega?:Array}>} opts.links
|
|
24
|
+
* @param {Array<{label:string, href?:string}>} opts.utilityLinks - Secondary links pushed to the far right
|
|
24
25
|
* @param {string} opts.action - Raw HTML slot — typically a button()
|
|
25
26
|
* @param {boolean} opts.sticky - Position sticky with backdrop blur
|
|
26
27
|
* @param {'right'|'left'} opts.burgerAlign - Mobile burger position (default: 'right')
|
|
@@ -63,7 +64,8 @@ function renderLink(link, navId, idx) {
|
|
|
63
64
|
${megaPanel(link.mega, panelId)}
|
|
64
65
|
</div>`
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
+
const current = link.current ? ' aria-current="page"' : ''
|
|
68
|
+
return `<a href="${e(link.href || '')}" class="ui-nav-link"${current}>${e(link.label || '')}</a>`
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
function renderMobileLink(link) {
|
|
@@ -71,13 +73,15 @@ function renderMobileLink(link) {
|
|
|
71
73
|
const items = link.mega.flatMap(col => col.items || [])
|
|
72
74
|
return `<p class="ui-nav-mobile-section">${e(link.label || '')}</p>` + items.map(item => megaItem(item)).join('')
|
|
73
75
|
}
|
|
74
|
-
|
|
76
|
+
const current = link.current ? ' aria-current="page"' : ''
|
|
77
|
+
return `<a href="${e(link.href || '')}" class="ui-nav-link"${current}>${e(link.label || '')}</a>`
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
export function nav({
|
|
78
81
|
logo = '',
|
|
79
82
|
logoHref = '/',
|
|
80
83
|
links = [],
|
|
84
|
+
utilityLinks = [],
|
|
81
85
|
action = '',
|
|
82
86
|
sticky = false,
|
|
83
87
|
burgerAlign = 'right',
|
|
@@ -89,29 +93,35 @@ export function nav({
|
|
|
89
93
|
const classes = ['ui-nav', sticky && 'ui-nav--sticky', burgerAlign === 'left' && 'ui-nav--burger-left', cls].filter(Boolean).join(' ')
|
|
90
94
|
const styles = [
|
|
91
95
|
background && `background:${background.replace(/"/g, "'")}`,
|
|
92
|
-
color && `color:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
|
|
96
|
+
color && `color:${color.replace(/"/g, "'")};--ui-text:${color.replace(/"/g, "'")};--ui-muted:${color.replace(/"/g, "'")}`,
|
|
93
97
|
].filter(Boolean).join(';')
|
|
94
98
|
const bgStyle = styles ? ` style="${styles}"` : ''
|
|
95
99
|
|
|
96
100
|
const linksHtml = links.map((l, i) => renderLink(l, id, i)).join('')
|
|
97
|
-
const
|
|
101
|
+
const utilityHtml = utilityLinks.map(l => `<a href="${e(l.href || '')}" class="ui-nav-link"${l.current ? ' aria-current="page"' : ''}>${e(l.label || '')}</a>`).join('')
|
|
102
|
+
const mobileLinksHtml = [...links, ...utilityLinks].map(renderMobileLink).join('')
|
|
98
103
|
|
|
99
|
-
const burgerHtml = links.length ? `
|
|
104
|
+
const burgerHtml = (links.length || utilityLinks.length) ? `
|
|
100
105
|
<button class="ui-nav-burger" type="button" aria-label="Toggle menu" aria-expanded="false" aria-controls="${id}-mobile">
|
|
101
106
|
${iconMenu({ size: 20, class: 'ui-nav-burger-open' })}
|
|
102
107
|
${iconX({ size: 20, class: 'ui-nav-burger-close' })}
|
|
103
108
|
</button>` : ''
|
|
104
109
|
|
|
105
|
-
const mobileMenuHtml = links.length ? `
|
|
110
|
+
const mobileMenuHtml = (links.length || utilityLinks.length) ? `
|
|
106
111
|
<div class="ui-nav-mobile" id="${id}-mobile" aria-label="Mobile navigation">
|
|
107
112
|
<nav>${mobileLinksHtml}</nav>
|
|
108
113
|
</div>` : ''
|
|
109
114
|
|
|
115
|
+
const navContent = [
|
|
116
|
+
links.length && `<nav class="ui-nav-links" aria-label="Site navigation">${linksHtml}</nav>`,
|
|
117
|
+
utilityLinks.length && `<nav class="ui-nav-links ui-nav-utility" aria-label="Utility navigation">${utilityHtml}</nav>`,
|
|
118
|
+
action && `<div class="ui-nav-action">${action}</div>`,
|
|
119
|
+
].filter(Boolean).join('')
|
|
120
|
+
|
|
110
121
|
return `<header class="${e(classes)}"${bgStyle}>
|
|
111
122
|
<div class="ui-nav-inner">
|
|
112
123
|
<a href="${e(logoHref)}" class="ui-nav-logo">${logo}</a>
|
|
113
|
-
${
|
|
114
|
-
${action ? `<div class="ui-nav-action">${action}</div>` : ''}${burgerHtml}
|
|
124
|
+
${navContent}${burgerHtml}
|
|
115
125
|
</div>${mobileMenuHtml}
|
|
116
126
|
</header>`
|
|
117
127
|
}
|
package/src/ui/phone-frame.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
* Renders the phone body, dynamic island, side buttons, and home indicator.
|
|
6
6
|
* No images — pure CSS/HTML.
|
|
7
7
|
*
|
|
8
|
-
* @param {object}
|
|
9
|
-
* @param {string}
|
|
10
|
-
* @param {
|
|
8
|
+
* @param {object} opts
|
|
9
|
+
* @param {string} opts.content - HTML to render inside the screen area
|
|
10
|
+
* @param {boolean} opts.animate - enable gentle 3-D tilt on mouse hover (default: false)
|
|
11
|
+
* @param {string} opts.class
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
export function phoneFrame({ content = '', class: cls = '' } = {}) {
|
|
14
|
-
const classes = ['ui-phone', cls].filter(Boolean).join(' ')
|
|
14
|
+
export function phoneFrame({ content = '', animate = false, class: cls = '' } = {}) {
|
|
15
|
+
const classes = ['ui-phone', animate && 'ui-phone--tilt', cls].filter(Boolean).join(' ')
|
|
15
16
|
|
|
16
17
|
return `<div class="${classes}" role="img" aria-label="Phone screen preview">
|
|
17
18
|
<!-- Left buttons: volume up, volume down, mute -->
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — PhotoCard
|
|
3
|
+
*
|
|
4
|
+
* Polaroid-style photo card with optional caption and CSS tilt.
|
|
5
|
+
* Ideal for "meet the team", testimonial portraits, or lifestyle photography.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.src - Image source URL
|
|
9
|
+
* @param {string} opts.alt - Alt text
|
|
10
|
+
* @param {string} opts.caption - Caption text below the photo
|
|
11
|
+
* @param {number} opts.tilt - CSS rotate degrees, e.g. 2 or -1.5. 0 = no tilt (default)
|
|
12
|
+
* @param {boolean} opts.rounded - Adds corner radius to the image (default: false = square crop)
|
|
13
|
+
* @param {string} opts.ratio - CSS aspect-ratio for the image crop (default: '4/3')
|
|
14
|
+
* @param {string} opts.class
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { escHtml as e } from '../html.js'
|
|
18
|
+
|
|
19
|
+
export function photoCard({
|
|
20
|
+
src = '',
|
|
21
|
+
alt = '',
|
|
22
|
+
caption = '',
|
|
23
|
+
tilt = 0,
|
|
24
|
+
rounded = false,
|
|
25
|
+
ratio = '4/3',
|
|
26
|
+
class: cls = '',
|
|
27
|
+
} = {}) {
|
|
28
|
+
const safeTilt = parseFloat(tilt) || 0
|
|
29
|
+
const classes = [
|
|
30
|
+
'ui-photo-card',
|
|
31
|
+
rounded && 'ui-photo-card--rounded',
|
|
32
|
+
cls,
|
|
33
|
+
].filter(Boolean).join(' ')
|
|
34
|
+
|
|
35
|
+
const tiltStyle = safeTilt ? ` style="--photo-tilt:${safeTilt}deg"` : ''
|
|
36
|
+
const cropStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
|
|
37
|
+
|
|
38
|
+
return `<figure class="${e(classes)}"${tiltStyle}>
|
|
39
|
+
<div class="ui-photo-card-crop"${cropStyle}>
|
|
40
|
+
<img src="${e(src)}" alt="${e(alt)}" class="ui-photo-card-img" loading="lazy" decoding="async">
|
|
41
|
+
</div>
|
|
42
|
+
${caption ? `<figcaption class="ui-photo-card-caption">${e(caption)}</figcaption>` : ''}
|
|
43
|
+
</figure>`
|
|
44
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Product Card
|
|
3
|
+
*
|
|
4
|
+
* Full-bleed editorial product card — the image IS the card. The product
|
|
5
|
+
* name (and optional price) overlays at the bottom on hover. Designed for
|
|
6
|
+
* fashion/e-commerce grids where photography is the primary communication.
|
|
7
|
+
*
|
|
8
|
+
* Unlike photoCard (polaroid frame + caption always visible), this card is
|
|
9
|
+
* frameless: the image fills 100% of the card, and metadata is revealed on
|
|
10
|
+
* hover via a gradient overlay.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} opts
|
|
13
|
+
* @param {string} opts.src - Image source URL
|
|
14
|
+
* @param {string} opts.alt - Alt text
|
|
15
|
+
* @param {string} opts.name - Product name
|
|
16
|
+
* @param {string} opts.price - Optional price string
|
|
17
|
+
* @param {string} opts.href - Link destination (default: '#')
|
|
18
|
+
* @param {string} opts.badge - Short badge text ("NEW", "SALE", "−20%")
|
|
19
|
+
* @param {'left'|'right'} opts.badgeAlign - Badge corner (default: 'left')
|
|
20
|
+
* @param {string} opts.ratio - CSS aspect-ratio (default: '4/5')
|
|
21
|
+
* @param {'overlay'|'below'} opts.layout - 'overlay' (hover reveal) or 'below' (caption under image)
|
|
22
|
+
* @param {string} opts.class
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { escHtml as e } from '../html.js'
|
|
26
|
+
|
|
27
|
+
export function productCard({
|
|
28
|
+
src = '',
|
|
29
|
+
alt = '',
|
|
30
|
+
name = '',
|
|
31
|
+
price = '',
|
|
32
|
+
href = '#',
|
|
33
|
+
badge = '',
|
|
34
|
+
badgeAlign = 'left',
|
|
35
|
+
ratio = '4/5',
|
|
36
|
+
layout = 'overlay',
|
|
37
|
+
class: cls = '',
|
|
38
|
+
} = {}) {
|
|
39
|
+
const classes = [
|
|
40
|
+
'ui-product-card',
|
|
41
|
+
badgeAlign === 'right' && 'ui-product-card--badge-right',
|
|
42
|
+
layout === 'below' && 'ui-product-card--below',
|
|
43
|
+
cls,
|
|
44
|
+
].filter(Boolean).join(' ')
|
|
45
|
+
|
|
46
|
+
const cropStyle = ratio ? ` style="aspect-ratio:${e(ratio)}"` : ''
|
|
47
|
+
|
|
48
|
+
const namePrice = name ? (layout === 'below'
|
|
49
|
+
? `<div class="ui-product-card-caption">
|
|
50
|
+
<p class="ui-product-card-name">${e(name)}</p>
|
|
51
|
+
${price ? `<p class="ui-product-card-price">${e(price)}</p>` : ''}
|
|
52
|
+
</div>`
|
|
53
|
+
: `<div class="ui-product-card-overlay" aria-hidden="true">
|
|
54
|
+
<p class="ui-product-card-name">${e(name)}</p>
|
|
55
|
+
${price ? `<p class="ui-product-card-price">${e(price)}</p>` : ''}
|
|
56
|
+
</div>`) : ''
|
|
57
|
+
|
|
58
|
+
return `<a href="${e(href)}" class="${e(classes)}">
|
|
59
|
+
<div class="ui-product-card-crop"${cropStyle}>
|
|
60
|
+
<img src="${e(src)}" alt="${e(alt)}" class="ui-product-card-img" loading="lazy" decoding="async">
|
|
61
|
+
</div>
|
|
62
|
+
${badge ? `<span class="ui-product-card-badge">${e(badge)}</span>` : ''}
|
|
63
|
+
${namePrice}
|
|
64
|
+
</a>`
|
|
65
|
+
}
|
package/src/ui/pullquote.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pulse UI — Pullquote
|
|
3
3
|
*
|
|
4
|
-
* Styled blockquote with
|
|
4
|
+
* Styled blockquote with optional attribution.
|
|
5
|
+
*
|
|
6
|
+
* Two variants:
|
|
7
|
+
* - Default: left accent border — for inline editorial quotes in prose or sidebars
|
|
8
|
+
* - 'editorial': centred large italic serif, no border — for brand voice statements
|
|
9
|
+
* in fashion, luxury, or magazine contexts
|
|
5
10
|
*
|
|
6
11
|
* @param {object} opts
|
|
7
12
|
* @param {string} opts.quote - The quote text
|
|
8
13
|
* @param {string} opts.cite - Attribution (name, role, etc.)
|
|
9
14
|
* @param {'md'|'lg'} opts.size - Size variant (default: 'md')
|
|
15
|
+
* @param {'default'|'editorial'} opts.variant - Visual style (default: 'default')
|
|
10
16
|
* @param {string} opts.class
|
|
11
17
|
*/
|
|
12
18
|
|
|
@@ -16,10 +22,15 @@ export function pullquote({
|
|
|
16
22
|
quote = '',
|
|
17
23
|
cite = '',
|
|
18
24
|
size = 'md',
|
|
25
|
+
variant = 'default',
|
|
19
26
|
class: cls = '',
|
|
20
27
|
} = {}) {
|
|
21
|
-
const
|
|
22
|
-
|
|
28
|
+
const figClasses = [
|
|
29
|
+
'ui-pullquote',
|
|
30
|
+
size === 'lg' && 'ui-pullquote--lg',
|
|
31
|
+
variant === 'editorial' && 'ui-pullquote--editorial',
|
|
32
|
+
cls,
|
|
33
|
+
].filter(Boolean).join(' ')
|
|
23
34
|
|
|
24
35
|
const citeHtml = cite
|
|
25
36
|
? `<figcaption class="ui-pullquote-cite">${e(cite)}</figcaption>`
|
package/src/ui/search.js
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — Section Label
|
|
3
|
+
*
|
|
4
|
+
* Eyebrow + heading + horizontal rule. The editorial equivalent of feature().
|
|
5
|
+
* For: chapter openings, section dividers, content blocks.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} opts
|
|
8
|
+
* @param {string} opts.eyebrow - Small label above heading
|
|
9
|
+
* @param {string} opts.heading - Main heading text
|
|
10
|
+
* @param {number} opts.level - Heading level 1–6 (default 2)
|
|
11
|
+
* @param {boolean} opts.rule - Show horizontal rule after heading (default true)
|
|
12
|
+
* @param {'left'|'center'} opts.align - Text alignment (default 'left')
|
|
13
|
+
* @param {string} opts.class
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { escHtml as e } from '../html.js'
|
|
17
|
+
|
|
18
|
+
export function sectionLabel({
|
|
19
|
+
eyebrow = '',
|
|
20
|
+
heading = '',
|
|
21
|
+
level = 2,
|
|
22
|
+
rule = true,
|
|
23
|
+
align = 'left',
|
|
24
|
+
class: cls = '',
|
|
25
|
+
} = {}) {
|
|
26
|
+
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
27
|
+
const classes = [
|
|
28
|
+
'ui-section-label',
|
|
29
|
+
align === 'center' && 'ui-section-label--center',
|
|
30
|
+
cls,
|
|
31
|
+
].filter(Boolean).join(' ')
|
|
32
|
+
|
|
33
|
+
return `<div class="${e(classes)}">
|
|
34
|
+
${eyebrow ? `<p class="ui-section-label-eyebrow">${e(eyebrow)}</p>` : ''}
|
|
35
|
+
${heading ? `<${tag} class="ui-section-label-heading">${e(heading)}</${tag}>` : ''}
|
|
36
|
+
${rule ? '<hr class="ui-section-label-rule" aria-hidden="true">' : ''}
|
|
37
|
+
</div>`
|
|
38
|
+
}
|
package/src/ui/section.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { escHtml as e } from '../html.js'
|
|
23
23
|
|
|
24
|
-
const VARIANTS = new Set(['default', 'alt', 'dark'])
|
|
24
|
+
const VARIANTS = new Set(['default', 'alt', 'dark', 'diagonal', 'paper', 'spotlight'])
|
|
25
25
|
const PADDINGS = new Set(['sm', 'md', 'lg'])
|
|
26
26
|
const GAPS = new Set(['none', 'sm', 'md', 'lg'])
|
|
27
27
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — ServiceCard
|
|
3
|
+
*
|
|
4
|
+
* A bordered, left-aligned tile with icon, title, and description.
|
|
5
|
+
* The most common pattern for trades/services grids — an unboxed, centred
|
|
6
|
+
* feature() is the wrong tool for these; serviceCard() is built for it.
|
|
7
|
+
*
|
|
8
|
+
* Use inside a grid() or css grid for multi-column layouts.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {string} opts.icon - Raw HTML slot — SVG icon displayed in an accent-tinted box
|
|
12
|
+
* @param {string} opts.title
|
|
13
|
+
* @param {number} opts.level - Heading level 1–6 (default 3)
|
|
14
|
+
* @param {string} opts.description
|
|
15
|
+
* @param {string} opts.href - When set, wraps the card in an <a> making it fully clickable
|
|
16
|
+
* @param {string} opts.class
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { escHtml as e } from '../html.js'
|
|
20
|
+
|
|
21
|
+
export function serviceCard({
|
|
22
|
+
icon = '',
|
|
23
|
+
title = '',
|
|
24
|
+
level = 3,
|
|
25
|
+
description = '',
|
|
26
|
+
href = '',
|
|
27
|
+
class: cls = '',
|
|
28
|
+
} = {}) {
|
|
29
|
+
const tag = `h${Math.min(Math.max(Math.floor(level), 1), 6)}`
|
|
30
|
+
const linked = Boolean(href)
|
|
31
|
+
const classes = [
|
|
32
|
+
'ui-service-card',
|
|
33
|
+
linked && 'ui-service-card--linked',
|
|
34
|
+
cls,
|
|
35
|
+
].filter(Boolean).join(' ')
|
|
36
|
+
|
|
37
|
+
const inner = `
|
|
38
|
+
${icon ? `<div class="ui-service-card-icon" aria-hidden="true">${icon}</div>` : ''}
|
|
39
|
+
${title ? `<${tag} class="ui-service-card-title">${e(title)}</${tag}>` : ''}
|
|
40
|
+
${description ? `<p class="ui-service-card-desc">${e(description)}</p>` : ''}
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
if (linked) {
|
|
44
|
+
return `<a href="${e(href)}" class="${e(classes)}">${inner}</a>`
|
|
45
|
+
}
|
|
46
|
+
return `<div class="${e(classes)}">${inner}</div>`
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse UI — StatGrid
|
|
3
|
+
*
|
|
4
|
+
* A grid of stat tiles with optional coloured panel background.
|
|
5
|
+
* Use when you need multiple stat() tiles arranged together — e.g. a 2×2
|
|
6
|
+
* "at a glance" panel inside a hero or section.
|
|
7
|
+
*
|
|
8
|
+
* Each item is passed directly to stat(). The grid handles layout.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} opts
|
|
11
|
+
* @param {Array} opts.stats - Array of stat() option objects (label, value, change, trend, size, suffix)
|
|
12
|
+
* @param {number} opts.columns - Number of grid columns (default: 2)
|
|
13
|
+
* @param {string} opts.background - CSS background value (e.g. 'var(--ui-surface-raised)')
|
|
14
|
+
* @param {string} opts.color - Foreground/text colour override
|
|
15
|
+
* @param {string} opts.class
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { stat } from './stat.js'
|
|
19
|
+
import { escHtml as e } from '../html.js'
|
|
20
|
+
|
|
21
|
+
export function statGrid({
|
|
22
|
+
stats = [],
|
|
23
|
+
columns = 2,
|
|
24
|
+
background = '',
|
|
25
|
+
color = '',
|
|
26
|
+
class: cls = '',
|
|
27
|
+
} = {}) {
|
|
28
|
+
const classes = ['ui-stat-grid', cls].filter(Boolean).join(' ')
|
|
29
|
+
|
|
30
|
+
const styles = [
|
|
31
|
+
`--stat-cols:${Number(columns) || 2}`,
|
|
32
|
+
background && `background:${background.replace(/"/g, "'")}`,
|
|
33
|
+
color && `color:${color.replace(/"/g, "'")}`,
|
|
34
|
+
].filter(Boolean).join(';')
|
|
35
|
+
const styleAttr = styles ? ` style="${styles}"` : ''
|
|
36
|
+
|
|
37
|
+
const tilesHtml = stats.map(s => stat(s)).join('')
|
|
38
|
+
|
|
39
|
+
return `<div class="${e(classes)}"${styleAttr}>${tilesHtml}</div>`
|
|
40
|
+
}
|
package/src/ui/stat.js
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* @param {string} opts.value - Formatted value string (e.g. "2.4k", "98%")
|
|
9
9
|
* @param {string} opts.change - Change label (e.g. "+12%", "−3")
|
|
10
10
|
* @param {'up'|'down'|'neutral'} opts.trend
|
|
11
|
+
* @param {string} opts.suffix - Raw HTML slot appended after the value — use for icons, ratings, units
|
|
12
|
+
* @param {'sm'|'md'|'lg'} opts.size
|
|
13
|
+
* - 'sm' — compact, for dense grids
|
|
14
|
+
* - 'md' — default
|
|
15
|
+
* - 'lg' — hero-scale, for top-of-page KPIs
|
|
11
16
|
* @param {boolean} opts.center - Centre-align all text
|
|
12
17
|
* @param {string} opts.class
|
|
13
18
|
*/
|
|
@@ -16,6 +21,7 @@ import { escHtml as e } from '../html.js'
|
|
|
16
21
|
import { iconTrendingUp, iconTrendingDown, iconMinus } from './icons.js'
|
|
17
22
|
|
|
18
23
|
const TRENDS = new Set(['up', 'down', 'neutral'])
|
|
24
|
+
const SIZES = new Set(['sm', 'md', 'lg'])
|
|
19
25
|
|
|
20
26
|
const TREND_ICONS = {
|
|
21
27
|
up: iconTrendingUp({ size: 13 }),
|
|
@@ -30,12 +36,20 @@ export function stat({
|
|
|
30
36
|
value = '',
|
|
31
37
|
change = '',
|
|
32
38
|
trend = 'neutral',
|
|
39
|
+
suffix = '',
|
|
40
|
+
size = 'md',
|
|
33
41
|
center = false,
|
|
34
42
|
class: cls = '',
|
|
35
43
|
} = {}) {
|
|
36
44
|
if (!TRENDS.has(trend)) trend = 'neutral'
|
|
45
|
+
if (!SIZES.has(size)) size = 'md'
|
|
37
46
|
|
|
38
|
-
const classes = [
|
|
47
|
+
const classes = [
|
|
48
|
+
'ui-stat',
|
|
49
|
+
size !== 'md' ? `ui-stat--${size}` : '',
|
|
50
|
+
center ? 'ui-stat--center' : '',
|
|
51
|
+
cls,
|
|
52
|
+
].filter(Boolean).join(' ')
|
|
39
53
|
|
|
40
54
|
const changeHtml = change
|
|
41
55
|
? `<p class="ui-stat-change ui-stat-change--${e(trend)}">
|
|
@@ -46,7 +60,7 @@ export function stat({
|
|
|
46
60
|
|
|
47
61
|
return `<div class="${e(classes)}">
|
|
48
62
|
<p class="ui-stat-label">${e(label)}</p>
|
|
49
|
-
<p class="ui-stat-value">${e(value)}</p>
|
|
63
|
+
<p class="ui-stat-value">${e(value)}${suffix ? `<span class="ui-stat-suffix" aria-hidden="true">${suffix}</span>` : ''}</p>
|
|
50
64
|
${changeHtml}
|
|
51
65
|
</div>`
|
|
52
66
|
}
|
package/src/ui/testimonial.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* A customer quote with optional star rating, avatar, and attribution.
|
|
5
5
|
*
|
|
6
6
|
* @param {object} opts
|
|
7
|
-
* @param {string} opts.quote
|
|
8
|
-
* @param {string} opts.name
|
|
9
|
-
* @param {string} opts.role
|
|
10
|
-
* @param {string} opts.src
|
|
11
|
-
* @param {
|
|
7
|
+
* @param {string} opts.quote - The testimonial text
|
|
8
|
+
* @param {string} opts.name - Author full name
|
|
9
|
+
* @param {string} opts.role - Author role/company (e.g. "CEO at Acme")
|
|
10
|
+
* @param {string} opts.src - Avatar image URL; falls back to initials when omitted
|
|
11
|
+
* @param {string} opts.initials - Override auto-derived initials (useful for names containing & or unusual characters)
|
|
12
|
+
* @param {number} opts.rating - Star rating 1–5; omit to hide stars
|
|
12
13
|
* @param {string} opts.class
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -19,6 +20,7 @@ export function testimonial({
|
|
|
19
20
|
name = '',
|
|
20
21
|
role = '',
|
|
21
22
|
src = '',
|
|
23
|
+
initials: initialsOverride = '',
|
|
22
24
|
rating = 0,
|
|
23
25
|
class: cls = '',
|
|
24
26
|
} = {}) {
|
|
@@ -28,7 +30,16 @@ export function testimonial({
|
|
|
28
30
|
? `<p class="ui-testimonial-rating" aria-label="${Math.round(rating)} out of 5 stars">${'★'.repeat(Math.min(5, Math.max(1, Math.round(rating))))}</p>`
|
|
29
31
|
: ''
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
// Filter to letter chars only so names like "Tomás & Ren Okabe" produce "TR" not "T&"
|
|
34
|
+
const derivedInitials = name
|
|
35
|
+
.split(/\s+/)
|
|
36
|
+
.map(w => w.replace(/[^a-zA-ZÀ-ÿ]/g, ''))
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map(w => w[0])
|
|
39
|
+
.join('')
|
|
40
|
+
.slice(0, 2)
|
|
41
|
+
.toUpperCase()
|
|
42
|
+
const initials = initialsOverride || derivedInitials
|
|
32
43
|
|
|
33
44
|
const avatarHtml = src
|
|
34
45
|
? `<img src="${e(src)}" alt="${e(name)}" class="ui-testimonial-avatar" loading="lazy" width="40" height="40">`
|
package/src/ui/textarea.js
CHANGED
|
@@ -42,6 +42,7 @@ export function textarea({
|
|
|
42
42
|
const wrapClasses = ['ui-field', error ? 'ui-field--error' : '', cls].filter(Boolean).join(' ')
|
|
43
43
|
|
|
44
44
|
const attrsStr = Object.entries(attrs)
|
|
45
|
+
.filter(([, v]) => v !== undefined)
|
|
45
46
|
.map(([k, v]) => ` ${e(k)}="${e(String(v))}"`)
|
|
46
47
|
.join('')
|
|
47
48
|
|