@invisibleloop/pulse 0.2.15 → 0.3.0

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.
@@ -1280,6 +1280,82 @@ hr.ui-divider {
1280
1280
  }
1281
1281
  .ui-nav-mobile .ui-nav-link:last-child { border-bottom: none; }
1282
1282
  .ui-nav-mobile-action { padding-top: .75rem; }
1283
+ .ui-nav-mobile-section {
1284
+ font-size: .7rem;
1285
+ font-weight: 600;
1286
+ text-transform: uppercase;
1287
+ letter-spacing: .08em;
1288
+ color: var(--ui-muted, var(--muted, #6b6b80));
1289
+ margin: .75rem 0 .25rem;
1290
+ padding: 0;
1291
+ }
1292
+
1293
+ /* ─── Mega nav ───────────────────────────────────────────────────────────── */
1294
+ .ui-nav-mega-wrap { position: relative; }
1295
+
1296
+ .ui-nav-mega-trigger {
1297
+ background: none;
1298
+ border: none;
1299
+ cursor: pointer;
1300
+ display: inline-flex;
1301
+ align-items: center;
1302
+ gap: .3rem;
1303
+ padding: 0;
1304
+ font: inherit;
1305
+ font-size: .875rem;
1306
+ }
1307
+
1308
+ .ui-nav-mega-chevron { transition: transform .2s ease; flex-shrink: 0; }
1309
+ .ui-nav-mega-wrap--open .ui-nav-mega-chevron { transform: rotate(180deg); }
1310
+
1311
+ .ui-nav-mega-panel {
1312
+ position: absolute;
1313
+ top: calc(100% + .875rem);
1314
+ left: 50%;
1315
+ transform: translateX(-50%);
1316
+ background: var(--ui-bg, var(--bg, #0d0d10));
1317
+ border: 1px solid var(--ui-border, var(--border, #232329));
1318
+ border-radius: var(--ui-radius, 8px);
1319
+ box-shadow: 0 8px 40px rgba(0,0,0,.35);
1320
+ padding: 1.5rem;
1321
+ z-index: 200;
1322
+ min-width: 360px;
1323
+ }
1324
+ .ui-nav-mega-panel[hidden] { display: none !important; }
1325
+
1326
+ .ui-nav-mega-inner { display: flex; gap: 2rem; }
1327
+
1328
+ .ui-nav-mega-col { flex: 1; min-width: 140px; }
1329
+
1330
+ .ui-nav-mega-heading {
1331
+ font-size: .7rem;
1332
+ font-weight: 600;
1333
+ text-transform: uppercase;
1334
+ letter-spacing: .08em;
1335
+ color: var(--ui-muted, var(--muted, #6b6b80));
1336
+ margin: 0 0 .5rem;
1337
+ padding: 0 .5rem;
1338
+ }
1339
+
1340
+ .ui-nav-mega-item {
1341
+ display: flex;
1342
+ flex-direction: column;
1343
+ gap: .15rem;
1344
+ padding: .5rem;
1345
+ border-radius: calc(var(--ui-radius, 8px) * .65);
1346
+ transition: background .15s;
1347
+ }
1348
+ .ui-nav-mega-item:hover { background: var(--ui-surface, var(--surface, #111116)); }
1349
+ .ui-nav-mega-item-label {
1350
+ font-size: .875rem;
1351
+ font-weight: 500;
1352
+ color: var(--ui-text, var(--text, #e2e2ea));
1353
+ text-decoration: none;
1354
+ }
1355
+ .ui-nav-mega-item-label:hover { color: var(--ui-accent, var(--accent, #9b8dff)); }
1356
+ .ui-nav-mega-item-desc { font-size: .75rem; color: var(--ui-muted, var(--muted, #6b6b80)); }
1357
+
1358
+ [data-theme="light"] .ui-nav-mega-panel { box-shadow: 0 8px 40px rgba(0,0,0,.12); }
1283
1359
 
1284
1360
  /* ─── App Badge ──────────────────────────────────────────────────────────── */
1285
1361
  .ui-app-badge {
@@ -131,19 +131,34 @@ document.addEventListener('keydown', (e) => {
131
131
  function initNav(el) {
132
132
  const burger = el.querySelector('.ui-nav-burger')
133
133
  const mobile = el.querySelector('.ui-nav-mobile')
134
- if (!burger || !mobile) return
135
134
 
136
- const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
137
- const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
138
- const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
135
+ // Mobile burger
136
+ if (burger && mobile) {
137
+ const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
138
+ const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
139
+ const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
139
140
 
140
- burger.addEventListener('click', toggle)
141
+ burger.addEventListener('click', toggle)
142
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
143
+ document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
144
+ mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
145
+ }
146
+
147
+ // Mega nav dropdowns
148
+ el.querySelectorAll('.ui-nav-mega-wrap').forEach(wrap => {
149
+ const trigger = wrap.querySelector('.ui-nav-mega-trigger')
150
+ const panel = wrap.querySelector('.ui-nav-mega-panel')
151
+ if (!trigger || !panel) return
141
152
 
142
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
143
- document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
153
+ const openMega = () => { panel.hidden = false; wrap.classList.add('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'true') }
154
+ const closeMega = () => { panel.hidden = true; wrap.classList.remove('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'false') }
155
+ const toggleMega = () => panel.hidden ? openMega() : closeMega()
144
156
 
145
- // Close when a mobile link is clicked (navigating away)
146
- mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
157
+ trigger.addEventListener('click', (e) => { e.stopPropagation(); toggleMega() })
158
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMega() })
159
+ document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) closeMega() })
160
+ panel.querySelectorAll('.ui-nav-mega-item').forEach(a => a.addEventListener('click', closeMega))
161
+ })
147
162
  }
148
163
 
149
164
  function runNavs() { document.querySelectorAll('.ui-nav').forEach(initNav) }
@@ -41,6 +41,47 @@ export default {
41
41
  })`
42
42
  )}
43
43
 
44
+ <h2 class="doc-h2" id="mega">Mega nav</h2>
45
+ <p>Replace a link's <code>href</code> with a <code>mega</code> array to render a dropdown panel. Each entry is a column with an optional heading and a list of items. Items support an optional <code>description</code> line.</p>
46
+ ${demo(
47
+ uiNav({
48
+ logo: '<strong>MyApp</strong>',
49
+ logoHref: '/',
50
+ links: [
51
+ { label: 'Products', mega: [
52
+ { heading: 'Platform', items: [
53
+ { label: 'Analytics', href: '#', description: 'Dashboards and usage metrics' },
54
+ { label: 'Automation', href: '#', description: 'Scheduled jobs and webhooks' },
55
+ { label: 'Storage', href: '#', description: 'Files, blobs, and databases' },
56
+ ]},
57
+ { heading: 'Integrations', items: [
58
+ { label: 'Supabase', href: '#' },
59
+ { label: 'Stripe', href: '#' },
60
+ { label: 'Auth0', href: '#' },
61
+ ]},
62
+ ]},
63
+ { label: 'Pricing', href: '#pricing' },
64
+ { label: 'Docs', href: '#docs' },
65
+ ],
66
+ action: button({ label: 'Get started', size: 'sm' }),
67
+ }),
68
+ `nav({
69
+ links: [
70
+ { label: 'Products', mega: [
71
+ { heading: 'Platform', items: [
72
+ { label: 'Analytics', href: '/analytics', description: 'Dashboards and usage metrics' },
73
+ { label: 'Automation', href: '/automation', description: 'Scheduled jobs and webhooks' },
74
+ ]},
75
+ { heading: 'Integrations', items: [
76
+ { label: 'Supabase', href: '/supabase' },
77
+ { label: 'Stripe', href: '/stripe' },
78
+ ]},
79
+ ]},
80
+ { label: 'Pricing', href: '/pricing' },
81
+ ],
82
+ })`
83
+ )}
84
+
44
85
  <h2 class="doc-h2" id="mobile">Mobile view</h2>
45
86
  <p>At &lt; 640px the links and action are hidden and replaced with a burger button. The panel opens as an overlay — no layout shift.</p>
46
87
 
@@ -73,12 +114,21 @@ export default {
73
114
  ${table(
74
115
  ['Prop', 'Type', 'Default', 'Description'],
75
116
  [
76
- ['<code>logo</code>', 'string (HTML)', '—', 'Raw HTML slot — SVG, img, or text'],
77
- ['<code>logoHref</code>', 'string', "'/'", ''],
78
- ['<code>links</code>', 'array', '[]', '<code>{ label, href }[]</code>'],
79
- ['<code>action</code>', 'string (HTML)', '—', 'Raw HTML slot — shown in desktop bar and mobile menu'],
80
- ['<code>sticky</code>', 'boolean', 'false', 'position: sticky with backdrop blur'],
81
- ['<code>burgerAlign</code>', 'string', "'right'", "'right' or 'left' — mobile burger position"],
117
+ ['<code>logo</code>', 'string (HTML)', '—', 'Raw HTML slot — SVG, img, or text'],
118
+ ['<code>logoHref</code>', 'string', "'/'", ''],
119
+ ['<code>links</code>', 'array', '[]', '<code>{ label, href }[]</code> or <code>{ label, mega: Column[] }</code>'],
120
+ ['<code>action</code>', 'string (HTML)', '—', 'Raw HTML slot — shown in desktop bar and mobile menu'],
121
+ ['<code>sticky</code>', 'boolean', 'false', 'position: sticky with backdrop blur'],
122
+ ['<code>burgerAlign</code>', 'string', "'right'", "'right' or 'left' — mobile burger position"],
123
+ ]
124
+ )}
125
+
126
+ <h3 class="doc-h3" style="margin-top:2rem">Mega nav column</h3>
127
+ ${table(
128
+ ['Prop', 'Type', 'Description'],
129
+ [
130
+ ['<code>heading</code>', 'string', 'Optional column heading (uppercase label)'],
131
+ ['<code>items</code>', 'array', '<code>{ label, href, description? }[]</code>'],
82
132
  ]
83
133
  )}
84
134
  `,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.2.15",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.2.15
1
+ 0.3.0
@@ -1280,6 +1280,82 @@ hr.ui-divider {
1280
1280
  }
1281
1281
  .ui-nav-mobile .ui-nav-link:last-child { border-bottom: none; }
1282
1282
  .ui-nav-mobile-action { padding-top: .75rem; }
1283
+ .ui-nav-mobile-section {
1284
+ font-size: .7rem;
1285
+ font-weight: 600;
1286
+ text-transform: uppercase;
1287
+ letter-spacing: .08em;
1288
+ color: var(--ui-muted, var(--muted, #6b6b80));
1289
+ margin: .75rem 0 .25rem;
1290
+ padding: 0;
1291
+ }
1292
+
1293
+ /* ─── Mega nav ───────────────────────────────────────────────────────────── */
1294
+ .ui-nav-mega-wrap { position: relative; }
1295
+
1296
+ .ui-nav-mega-trigger {
1297
+ background: none;
1298
+ border: none;
1299
+ cursor: pointer;
1300
+ display: inline-flex;
1301
+ align-items: center;
1302
+ gap: .3rem;
1303
+ padding: 0;
1304
+ font: inherit;
1305
+ font-size: .875rem;
1306
+ }
1307
+
1308
+ .ui-nav-mega-chevron { transition: transform .2s ease; flex-shrink: 0; }
1309
+ .ui-nav-mega-wrap--open .ui-nav-mega-chevron { transform: rotate(180deg); }
1310
+
1311
+ .ui-nav-mega-panel {
1312
+ position: absolute;
1313
+ top: calc(100% + .875rem);
1314
+ left: 50%;
1315
+ transform: translateX(-50%);
1316
+ background: var(--ui-bg, var(--bg, #0d0d10));
1317
+ border: 1px solid var(--ui-border, var(--border, #232329));
1318
+ border-radius: var(--ui-radius, 8px);
1319
+ box-shadow: 0 8px 40px rgba(0,0,0,.35);
1320
+ padding: 1.5rem;
1321
+ z-index: 200;
1322
+ min-width: 360px;
1323
+ }
1324
+ .ui-nav-mega-panel[hidden] { display: none !important; }
1325
+
1326
+ .ui-nav-mega-inner { display: flex; gap: 2rem; }
1327
+
1328
+ .ui-nav-mega-col { flex: 1; min-width: 140px; }
1329
+
1330
+ .ui-nav-mega-heading {
1331
+ font-size: .7rem;
1332
+ font-weight: 600;
1333
+ text-transform: uppercase;
1334
+ letter-spacing: .08em;
1335
+ color: var(--ui-muted, var(--muted, #6b6b80));
1336
+ margin: 0 0 .5rem;
1337
+ padding: 0 .5rem;
1338
+ }
1339
+
1340
+ .ui-nav-mega-item {
1341
+ display: flex;
1342
+ flex-direction: column;
1343
+ gap: .15rem;
1344
+ padding: .5rem;
1345
+ border-radius: calc(var(--ui-radius, 8px) * .65);
1346
+ transition: background .15s;
1347
+ }
1348
+ .ui-nav-mega-item:hover { background: var(--ui-surface, var(--surface, #111116)); }
1349
+ .ui-nav-mega-item-label {
1350
+ font-size: .875rem;
1351
+ font-weight: 500;
1352
+ color: var(--ui-text, var(--text, #e2e2ea));
1353
+ text-decoration: none;
1354
+ }
1355
+ .ui-nav-mega-item-label:hover { color: var(--ui-accent, var(--accent, #9b8dff)); }
1356
+ .ui-nav-mega-item-desc { font-size: .75rem; color: var(--ui-muted, var(--muted, #6b6b80)); }
1357
+
1358
+ [data-theme="light"] .ui-nav-mega-panel { box-shadow: 0 8px 40px rgba(0,0,0,.12); }
1283
1359
 
1284
1360
  /* ─── App Badge ──────────────────────────────────────────────────────────── */
1285
1361
  .ui-app-badge {
@@ -131,19 +131,34 @@ document.addEventListener('keydown', (e) => {
131
131
  function initNav(el) {
132
132
  const burger = el.querySelector('.ui-nav-burger')
133
133
  const mobile = el.querySelector('.ui-nav-mobile')
134
- if (!burger || !mobile) return
135
134
 
136
- const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
137
- const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
138
- const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
135
+ // Mobile burger
136
+ if (burger && mobile) {
137
+ const open = () => { el.classList.add('ui-nav--open'); burger.setAttribute('aria-expanded', 'true') }
138
+ const close = () => { el.classList.remove('ui-nav--open'); burger.setAttribute('aria-expanded', 'false') }
139
+ const toggle = () => el.classList.contains('ui-nav--open') ? close() : open()
139
140
 
140
- burger.addEventListener('click', toggle)
141
+ burger.addEventListener('click', toggle)
142
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
143
+ document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
144
+ mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
145
+ }
146
+
147
+ // Mega nav dropdowns
148
+ el.querySelectorAll('.ui-nav-mega-wrap').forEach(wrap => {
149
+ const trigger = wrap.querySelector('.ui-nav-mega-trigger')
150
+ const panel = wrap.querySelector('.ui-nav-mega-panel')
151
+ if (!trigger || !panel) return
141
152
 
142
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
143
- document.addEventListener('click', (e) => { if (!el.contains(e.target)) close() })
153
+ const openMega = () => { panel.hidden = false; wrap.classList.add('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'true') }
154
+ const closeMega = () => { panel.hidden = true; wrap.classList.remove('ui-nav-mega-wrap--open'); trigger.setAttribute('aria-expanded', 'false') }
155
+ const toggleMega = () => panel.hidden ? openMega() : closeMega()
144
156
 
145
- // Close when a mobile link is clicked (navigating away)
146
- mobile.querySelectorAll('.ui-nav-link').forEach(a => a.addEventListener('click', close))
157
+ trigger.addEventListener('click', (e) => { e.stopPropagation(); toggleMega() })
158
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMega() })
159
+ document.addEventListener('click', (e) => { if (!wrap.contains(e.target)) closeMega() })
160
+ panel.querySelectorAll('.ui-nav-mega-item').forEach(a => a.addEventListener('click', closeMega))
161
+ })
147
162
  }
148
163
 
149
164
  function runNavs() { document.querySelectorAll('.ui-nav').forEach(initNav) }
package/src/ui/nav.js CHANGED
@@ -9,21 +9,69 @@
9
9
  * reveals an absolutely-positioned dropdown that overlays page content.
10
10
  * Wired automatically by pulse-ui.js — no extra JS needed.
11
11
  *
12
+ * Links may be plain links or mega-nav dropdowns:
13
+ * { label: 'Docs', href: '/docs' }
14
+ * { label: 'Products', mega: [
15
+ * { heading: 'Solutions', items: [
16
+ * { label: 'Analytics', href: '/analytics', description: 'Track metrics' },
17
+ * ]},
18
+ * ]}
19
+ *
12
20
  * @param {object} opts
13
- * @param {string} opts.logo - Raw HTML slot — SVG, img, or text
14
- * @param {string} opts.logoHref - Logo link destination (default: '/')
15
- * @param {Array<{label: string, href: string}>} opts.links
16
- * @param {string} opts.action - Raw HTML slot — typically a button()
17
- * @param {boolean} opts.sticky - Position sticky with backdrop blur
21
+ * @param {string} opts.logo - Raw HTML slot — SVG, img, or text
22
+ * @param {string} opts.logoHref - Logo link destination (default: '/')
23
+ * @param {Array<{label:string, href?:string, mega?:Array}>} opts.links
24
+ * @param {string} opts.action - Raw HTML slot — typically a button()
25
+ * @param {boolean} opts.sticky - Position sticky with backdrop blur
18
26
  * @param {'right'|'left'} opts.burgerAlign - Mobile burger position (default: 'right')
19
27
  * @param {string} opts.class
20
28
  */
21
29
 
22
30
  import { escHtml as e } from '../html.js'
23
- import { iconMenu, iconX } from './icons.js'
31
+ import { iconMenu, iconX, iconChevronDown } from './icons.js'
24
32
 
25
33
  let _navId = 0
26
34
 
35
+ function megaItem({ label = '', href = '', description = '' }) {
36
+ return `<div class="ui-nav-mega-item">
37
+ <a href="${e(href)}" class="ui-nav-mega-item-label">${e(label)}</a>
38
+ ${description ? `<span class="ui-nav-mega-item-desc">${e(description)}</span>` : ''}
39
+ </div>`
40
+ }
41
+
42
+ function megaPanel(columns, panelId) {
43
+ const cols = columns.map(col => `
44
+ <div class="ui-nav-mega-col">
45
+ ${col.heading ? `<p class="ui-nav-mega-heading">${e(col.heading)}</p>` : ''}
46
+ ${(col.items || []).map(megaItem).join('')}
47
+ </div>`).join('')
48
+ return `<div class="ui-nav-mega-panel" id="${panelId}" hidden>
49
+ <div class="ui-nav-mega-inner">${cols}
50
+ </div>
51
+ </div>`
52
+ }
53
+
54
+ function renderLink(link, navId, idx) {
55
+ if (link.mega) {
56
+ const panelId = `${navId}-mega-${idx}`
57
+ return `<div class="ui-nav-mega-wrap">
58
+ <button type="button" class="ui-nav-link ui-nav-mega-trigger" aria-expanded="false" aria-haspopup="true" aria-controls="${panelId}">
59
+ ${e(link.label || '')}${iconChevronDown({ size: 13, class: 'ui-nav-mega-chevron' })}
60
+ </button>
61
+ ${megaPanel(link.mega, panelId)}
62
+ </div>`
63
+ }
64
+ return `<a href="${e(link.href || '')}" class="ui-nav-link">${e(link.label || '')}</a>`
65
+ }
66
+
67
+ function renderMobileLink(link) {
68
+ if (link.mega) {
69
+ const items = link.mega.flatMap(col => col.items || [])
70
+ return `<p class="ui-nav-mobile-section">${e(link.label || '')}</p>` + items.map(item => megaItem(item)).join('')
71
+ }
72
+ return `<a href="${e(link.href || '')}" class="ui-nav-link">${e(link.label || '')}</a>`
73
+ }
74
+
27
75
  export function nav({
28
76
  logo = '',
29
77
  logoHref = '/',
@@ -36,9 +84,8 @@ export function nav({
36
84
  const id = `ui-nav-${++_navId}`
37
85
  const classes = ['ui-nav', sticky && 'ui-nav--sticky', burgerAlign === 'left' && 'ui-nav--burger-left', cls].filter(Boolean).join(' ')
38
86
 
39
- const linksHtml = links.map(({ label = '', href = '' }) =>
40
- `<a href="${e(href)}" class="ui-nav-link">${e(label)}</a>`
41
- ).join('')
87
+ const linksHtml = links.map((l, i) => renderLink(l, id, i)).join('')
88
+ const mobileLinksHtml = links.map(renderMobileLink).join('')
42
89
 
43
90
  const burgerHtml = links.length ? `
44
91
  <button class="ui-nav-burger" type="button" aria-label="Toggle menu" aria-expanded="false" aria-controls="${id}-mobile">
@@ -48,7 +95,7 @@ export function nav({
48
95
 
49
96
  const mobileMenuHtml = links.length ? `
50
97
  <div class="ui-nav-mobile" id="${id}-mobile" aria-label="Mobile navigation">
51
- <nav>${linksHtml}</nav>
98
+ <nav>${mobileLinksHtml}</nav>
52
99
  </div>` : ''
53
100
 
54
101
  return `<header class="${e(classes)}">