@panphora/hyper-cms 0.11.6 → 0.12.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.
package/src/toggle.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // Floating bottom-right button that toggles the CMS open/closed, so a page
2
2
  // owner can reopen the CMS after closing it (the shell's X is the only other
3
- // affordance, and nothing reopens it). Injected at page load only when the
4
- // visitor is in edit mode AND the page actually has cms rules, so regular
5
- // visitors and rules-less pages (hypercms rides in broad presets) never see it.
3
+ // affordance, and nothing reopens it). Injected at page load when the visitor
4
+ // may edit AND the page actually has cms rules, so regular visitors and
5
+ // rules-less pages (hypercms rides in broad presets) never see it. On a ClayJS
6
+ // page "may edit" is clay.canEdit alone: view mode is where the toggle is
7
+ // needed most, since it is the way into editing.
6
8
  // Runtime-only chrome: no-save + snapshot-remove keep it out of every save and
7
9
  // snapshot (a static no-save element written into the file would be stripped
8
10
  // from disk on the first save), save-ignore keeps it out of autosave/undo,
@@ -16,16 +18,9 @@
16
18
  // public custom property, so overriding still works. Everything cosmetic is a
17
19
  // normal declaration selected by [data-hcms-toggle-host], which an author's own
18
20
  // #hcms-toggle rule outranks on specificity.
19
- //
20
- // With two views to choose between it is a split button: the main button plus an
21
- // arrow that opens a menu. The arrow's background and the menu's whole geometry
22
- // are pinned for exactly the reason the host's position is — an unpinned arrow
23
- // goes transparent under a page's `button { background: none !important }`, and
24
- // an unpinned menu drops into the flow and shoves the page. The menu's `display`
25
- // is pinned too: [hidden] is a UA rule with no specificity, so any page rule
26
- // matching the element beats it and the menu would sit open forever.
27
21
 
28
22
  import { reensureStyles } from './shell.js'
23
+ import { modeLifecycle } from './session.js'
29
24
 
30
25
  const TOGGLE_ID = 'hcms-toggle'
31
26
  const HOST_ATTR = 'data-hcms-toggle-host'
@@ -36,39 +31,9 @@ const STYLE_ATTR = 'data-hcms-toggle-style'
36
31
  // sets nothing on <body>, so a body-class label reads "Edit content" over an
37
32
  // open inline editor.
38
33
  const SESSION_ATTR = 'data-hcms-session'
39
- const SPLIT_ATTR = 'data-hcms-split'
40
-
41
- // The preference, one setting across the origin. No version suffix: there are
42
- // exactly two valid values and the membership check on read is what makes any
43
- // other value — a stale name, a hand-edited entry, a future view this build does
44
- // not have — read as first run rather than as a broken session.
45
- export const VIEW_STORAGE_KEY = 'hcms.view'
46
- const VIEW_NAMES = ['sidebar', 'inline']
47
- const VIEW_LABELS = { sidebar: 'In the sidebar', inline: 'On the page' }
48
34
 
49
35
  const SURFACE_VAR = 'var(--hcms-toggle-bg, var(--hcms-toggle-_surface))'
50
36
 
51
- export function readStoredView(win) {
52
- try {
53
- const value = win && win.localStorage ? win.localStorage.getItem(VIEW_STORAGE_KEY) : null
54
- return VIEW_NAMES.includes(value) ? value : null
55
- } catch (_) {
56
- return null
57
- }
58
- }
59
-
60
- // Written when someone picks a view, and only once that view is actually up: a
61
- // mount that throws must not teach the next page load to try the same broken
62
- // view. localStorage throws outright in a few configurations (Safari private
63
- // mode, cookies disabled), and a remembered view is a convenience, not a
64
- // requirement.
65
- export function writeStoredView(win, view) {
66
- if (!VIEW_NAMES.includes(view)) return
67
- try {
68
- win?.localStorage?.setItem(VIEW_STORAGE_KEY, view)
69
- } catch (_) {}
70
- }
71
-
72
37
  // The pill's surface is opaque and neutral, picked at inject time from the two
73
38
  // values below by whichever has more contrast against the page's own ink. It is
74
39
  // not a tint of currentColor: the button floats over a backdrop it cannot see,
@@ -89,8 +54,7 @@ export const TOGGLE_STYLE = `
89
54
  [${HOST_ATTR}][data-hcms-surface="dark"] {
90
55
  --hcms-toggle-_surface: ${SURFACE_DARK};
91
56
  }
92
- [${HOST_ATTR}] .hcms-toggle__main,
93
- [${HOST_ATTR}] .hcms-toggle__arrow {
57
+ [${HOST_ATTR}] .hcms-toggle__main {
94
58
  all: unset;
95
59
  box-sizing: border-box;
96
60
  display: inline-flex;
@@ -109,75 +73,22 @@ export const TOGGLE_STYLE = `
109
73
  cursor: pointer;
110
74
  box-shadow: none;
111
75
  }
112
- [${HOST_ATTR}] .hcms-toggle__main:hover,
113
- [${HOST_ATTR}] .hcms-toggle__arrow:hover {
76
+ [${HOST_ATTR}] .hcms-toggle__main:hover {
114
77
  border-color: var(--mirk-focus-color, #C7AE93);
115
78
  box-shadow: none;
116
79
  }
117
- [${HOST_ATTR}] .hcms-toggle__main:active,
118
- [${HOST_ATTR}] .hcms-toggle__arrow:active {
80
+ [${HOST_ATTR}] .hcms-toggle__main:active {
119
81
  border-color: var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF);
120
82
  }
121
- [${HOST_ATTR}] .hcms-toggle__main:focus-visible,
122
- [${HOST_ATTR}] .hcms-toggle__arrow:focus-visible {
83
+ [${HOST_ATTR}] .hcms-toggle__main:focus-visible {
123
84
  outline: 2px solid currentColor;
124
85
  outline-offset: -5px;
125
86
  }
126
87
  [${HOST_ATTR}] .hcms-toggle__close { display: none; }
127
88
  [${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__open { display: none; }
128
89
  [${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__close { display: inline; }
129
- [${HOST_ATTR}][${SPLIT_ATTR}] .hcms-toggle__main {
130
- border-radius: 0;
131
- padding-right: 12px;
132
- }
133
- [${HOST_ATTR}] .hcms-toggle__arrow {
134
- gap: 0;
135
- padding: 0 10px;
136
- border-radius: 0;
137
- border-left-width: 0;
138
- }
139
- [${HOST_ATTR}] .hcms-toggle__menu {
140
- all: unset;
141
- box-sizing: border-box;
142
- min-width: 180px;
143
- padding: 4px;
144
- font-family: inherit;
145
- font-size: 14px;
146
- line-height: 1.5;
147
- color: var(--hcms-toggle-color, currentColor);
148
- background: ${SURFACE_VAR};
149
- border: 1px solid var(--mirk-input-border, #D8C8AF);
150
- border-radius: var(--mirk-radius, 5px);
151
- box-shadow: 0 14px 34px -14px rgba(0, 0, 0, .45);
152
- }
153
- [${HOST_ATTR}] .hcms-toggle__item {
154
- all: unset;
155
- box-sizing: border-box;
156
- display: block;
157
- width: 100%;
158
- padding: 9px 10px;
159
- border-radius: 0;
160
- cursor: pointer;
161
- }
162
- [${HOST_ATTR}] .hcms-toggle__item::before {
163
- content: "○";
164
- margin-right: 8px;
165
- opacity: .55;
166
- }
167
- [${HOST_ATTR}] .hcms-toggle__item[aria-checked="true"]::before {
168
- content: "●";
169
- opacity: 1;
170
- }
171
- [${HOST_ATTR}] .hcms-toggle__item:hover {
172
- background: color-mix(in srgb, currentColor 12%, transparent);
173
- }
174
- [${HOST_ATTR}] .hcms-toggle__item:focus-visible {
175
- outline: 2px solid currentColor;
176
- outline-offset: -3px;
177
- }
178
90
  @media (pointer: coarse) {
179
- [${HOST_ATTR}] .hcms-toggle__main,
180
- [${HOST_ATTR}] .hcms-toggle__arrow { min-height: 44px; }
91
+ [${HOST_ATTR}] .hcms-toggle__main { min-height: 44px; }
181
92
  }
182
93
  `
183
94
 
@@ -385,7 +296,7 @@ function scheduleSurface(host) {
385
296
  }
386
297
  }
387
298
 
388
- export function injectToggle({ open, close, isOpen, getTheme = () => null, views = VIEW_NAMES }, doc = document) {
299
+ export function injectToggle({ open, close, isOpen, getTheme = () => null }, doc = document) {
389
300
  const existing = doc.querySelector(`[${HOST_ATTR}]`)
390
301
  if (existing) return existing
391
302
 
@@ -419,21 +330,7 @@ export function injectToggle({ open, close, isOpen, getTheme = () => null, views
419
330
  '<span class="hcms-toggle__close">Close editor</span>' +
420
331
  '</button>'
421
332
 
422
- const win = doc.defaultView
423
333
  const main = host.querySelector('.hcms-toggle__main')
424
- // Fixed order, so the menu does not reorder itself from page to page, and
425
- // filtered against the views this build actually has.
426
- const available = VIEW_NAMES.filter((name) => views.includes(name))
427
- // An arrow offering a choice of one is a menu that can only repeat what the
428
- // button already does, so with a single view there is no arrow at all.
429
- const split = available.length > 1
430
- const arrow = split ? makeArrow(doc) : null
431
- const menu = split ? makeMenu(doc, available) : null
432
- if (split) {
433
- host.setAttribute(SPLIT_ATTR, '')
434
- host.appendChild(arrow)
435
- host.appendChild(menu)
436
- }
437
334
 
438
335
  // The only declarations a page's `* { ... !important }` reset can take from
439
336
  // us and leave the control broken rather than merely ugly. Each reads its
@@ -445,100 +342,40 @@ export function injectToggle({ open, close, isOpen, getTheme = () => null, views
445
342
  'z-index': 'var(--hcms-toggle-z, 2147482900)',
446
343
  })
447
344
  pin(main, { background: SURFACE_VAR })
448
- if (arrow) pin(arrow, { background: SURFACE_VAR })
449
- if (menu) {
450
- pin(menu, {
451
- position: 'absolute',
452
- right: '0',
453
- bottom: 'calc(100% + 8px)',
454
- 'z-index': 'var(--hcms-toggle-z, 2147482900)',
455
- display: 'none',
456
- })
457
- }
458
-
459
- const items = () => (menu ? [...menu.querySelectorAll('[role="menuitemradio"]')] : [])
460
-
461
- function focusEl(el) {
462
- if (!el || typeof el.focus !== 'function') return
463
- try { el.focus({ preventScroll: true }) } catch (_) { el.focus() }
464
- }
465
-
466
- function focusItem(index) {
467
- const list = items()
468
- if (!list.length) return
469
- focusEl(list[(index + list.length) % list.length])
470
- }
471
345
 
472
- // Read fresh every time the menu opens: the selection made two clicks ago is
473
- // the one the radio should be sitting on.
474
- function markChecked() {
475
- const stored = readStoredView(win)
476
- for (const item of items()) {
477
- item.setAttribute('aria-checked', String(item.getAttribute('data-hcms-view') === stored))
478
- }
479
- }
480
-
481
- function onOutsidePointer(event) {
482
- if (host.contains(event.target)) return
483
- closeMenu()
484
- }
485
-
486
- function setMenuOpen(show) {
487
- if (!menu) return
488
- menu.hidden = !show
489
- pin(menu, { display: show ? 'block' : 'none' })
490
- arrow.setAttribute('aria-expanded', String(show))
491
- if (show) {
492
- markChecked()
493
- // Capture, so a page that swallows pointer events on the way up cannot
494
- // leave the menu standing open over content it no longer belongs to.
495
- doc.addEventListener('pointerdown', onOutsidePointer, true)
496
- } else {
497
- doc.removeEventListener('pointerdown', onOutsidePointer, true)
498
- }
499
- }
500
-
501
- function openMenu(index = 0) {
502
- setMenuOpen(true)
503
- focusItem(index)
504
- }
505
-
506
- function closeMenu({ focusArrow = false } = {}) {
507
- if (!menu || menu.hidden) return
508
- setMenuOpen(false)
509
- if (focusArrow) focusEl(arrow)
510
- }
511
-
512
- // A remembered view this page cannot render is ignored HERE and left in
513
- // storage: the preference belongs to the person, not to the page, and the
514
- // next page may well be able to honour it.
515
- function rememberedView() {
516
- const stored = readStoredView(win)
517
- return stored && available.includes(stored) ? stored : null
346
+ function requestMode(mode) {
347
+ const win = doc.defaultView
348
+ if (!modeLifecycle(win)) return null
349
+ const setMode = win.clay && win.clay.setMode
350
+ if (typeof setMode !== 'function') return null
351
+ return setMode.call(win.clay, mode)
518
352
  }
519
353
 
520
- // The one path that writes the preference, because it is the one where the
521
- // person chose. After the open, never before: a mount that throws leaves the
522
- // preference exactly as it was.
523
- async function chooseView(name) {
524
- await open({ view: name })
525
- writeStoredView(win, name)
354
+ // The mode the page is actually in. clay.mode is a live getter over the
355
+ // committed facts, so it answers the same in view and after a live switch.
356
+ function currentMode() {
357
+ const win = doc.defaultView
358
+ return win && win.clay ? win.clay.mode : null
526
359
  }
527
360
 
528
361
  async function mainAction() {
529
362
  if (isOpen()) {
363
+ const exit = requestMode('view')
364
+ if (exit) return exit
530
365
  close()
531
366
  return
532
367
  }
533
- const remembered = rememberedView()
534
- if (remembered) return open({ view: remembered })
535
- // First run. With a real choice to make the button asks rather than
536
- // guessing; with one view there is nothing to ask.
537
- if (split) return openMenu()
538
- // Opening the only view a page has is not a choice, so it is not
539
- // remembered: writing it here would take the preference away from every
540
- // page that does offer both, which is deletion by another name.
541
- return open(available[0] ? { view: available[0] } : {})
368
+ // Already in edit: the CMS is the only thing left to do, and the page is
369
+ // already writable.
370
+ if (currentMode() === 'edit') return open()
371
+ // In view the switch comes first and the CMS only after it committed. A
372
+ // refusal (busy, not-allowed, save-failed) leaves the CMS closed, since a
373
+ // page that never became writable cannot host it.
374
+ const enter = requestMode('edit')
375
+ if (!enter) return open()
376
+ const receipt = await enter
377
+ if (receipt && receipt.ok === false) return
378
+ return open()
542
379
  }
543
380
 
544
381
  async function run(action) {
@@ -550,63 +387,11 @@ export function injectToggle({ open, close, isOpen, getTheme = () => null, views
550
387
  }
551
388
 
552
389
  // Delegated, so both a real click on the inner button and a programmatic
553
- // host.click() fire exactly once. The arrow and the menu own their own
554
- // clicks, which bubble through here on their way out.
555
- host.addEventListener('click', async (event) => {
556
- if (arrow && arrow.contains(event.target)) return
557
- if (menu && menu.contains(event.target)) return
390
+ // host.click() fire exactly once.
391
+ host.addEventListener('click', async () => {
558
392
  await run(mainAction)
559
393
  })
560
394
 
561
- if (arrow) {
562
- arrow.addEventListener('click', (event) => {
563
- event.preventDefault()
564
- if (menu.hidden) openMenu()
565
- else closeMenu({ focusArrow: true })
566
- })
567
- arrow.addEventListener('keydown', (event) => {
568
- if (event.key === 'ArrowUp') {
569
- event.preventDefault()
570
- openMenu(-1)
571
- return
572
- }
573
- // preventDefault on Enter and Space stops the button's own activation
574
- // click, which would otherwise arrive and toggle the menu straight back
575
- // shut.
576
- if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
577
- event.preventDefault()
578
- openMenu(0)
579
- }
580
- })
581
- menu.addEventListener('click', (event) => {
582
- const item = event.target.closest?.('[role="menuitemradio"]')
583
- if (!item) return
584
- event.preventDefault()
585
- closeMenu()
586
- run(() => chooseView(item.getAttribute('data-hcms-view')))
587
- })
588
- menu.addEventListener('keydown', (event) => {
589
- const list = items()
590
- const current = list.indexOf(doc.activeElement)
591
- switch (event.key) {
592
- case 'ArrowDown': event.preventDefault(); focusItem(current + 1); break
593
- case 'ArrowUp': event.preventDefault(); focusItem(current - 1); break
594
- case 'Home': event.preventDefault(); focusItem(0); break
595
- case 'End': event.preventDefault(); focusItem(list.length - 1); break
596
- case 'Enter':
597
- case ' ':
598
- event.preventDefault()
599
- if (current === -1) break
600
- closeMenu()
601
- run(() => chooseView(list[current].getAttribute('data-hcms-view')))
602
- break
603
- case 'Escape': event.preventDefault(); closeMenu({ focusArrow: true }); break
604
- // No preventDefault: Tab leaves the menu, it does not cycle inside it.
605
- case 'Tab': closeMenu(); break
606
- }
607
- })
608
- }
609
-
610
395
  // Which label the main button shows, and nothing else decides it. hcms:close
611
396
  // fires BEFORE the session is torn down, so isOpen() still answers true inside
612
397
  // that handler: the event says what happened and re-asking would leave the
@@ -628,54 +413,53 @@ export function injectToggle({ open, close, isOpen, getTheme = () => null, views
628
413
  return host
629
414
  }
630
415
 
631
- function makeArrow(doc) {
632
- const arrow = doc.createElement('button')
633
- arrow.type = 'button'
634
- arrow.className = 'hcms-toggle__arrow mirk-button'
635
- arrow.setAttribute('aria-haspopup', 'menu')
636
- arrow.setAttribute('aria-expanded', 'false')
637
- // The arrow is a glyph, so unlike the main button it has no visible text to
638
- // take its accessible name from.
639
- arrow.setAttribute('aria-label', 'Choose where to edit')
640
- arrow.textContent = '▾'
641
- return arrow
642
- }
643
-
644
- function makeMenu(doc, available) {
645
- const menu = doc.createElement('div')
646
- menu.className = 'hcms-toggle__menu'
647
- menu.setAttribute('role', 'menu')
648
- menu.setAttribute('aria-label', 'Where to edit')
649
- menu.hidden = true
650
- for (const name of available) {
651
- const item = doc.createElement('button')
652
- item.type = 'button'
653
- item.className = 'hcms-toggle__item'
654
- // menuitemradio, not menuitem: the two are one choice with one answer, and
655
- // the remembered one is checked.
656
- item.setAttribute('role', 'menuitemradio')
657
- item.setAttribute('aria-checked', 'false')
658
- item.setAttribute('data-hcms-view', name)
659
- // Roving focus: the menu is entered from the arrow and moved through with
660
- // the arrow keys, never tabbed into item by item.
661
- item.tabIndex = -1
662
- item.textContent = VIEW_LABELS[name]
663
- menu.appendChild(item)
664
- }
665
- return menu
666
- }
667
-
668
416
  // Inject at page load when in edit mode and the page has cms rules. DOM-ready
669
417
  // deferral matters when the module evaluates while the document is still
670
418
  // parsing (the rules tag or <body> may not exist yet).
671
419
  export function maybeInjectToggle(api) {
672
420
  if (typeof window === 'undefined' || typeof document === 'undefined') return
673
- const forced = window.__hyperclayEditMode != null ? window.__hyperclayEditMode : null
674
- if (!detectEditMode({ search: window.location.search, cookie: document.cookie, forced })) return
421
+ // On a ClayJS page the toggle is the way INTO editing, so it is mounted exactly
422
+ // when ClayJS says the person may edit (clay.canEdit), in view as well as in
423
+ // edit. That permission can arrive after load, and ClayJS announces every
424
+ // change of it with clay:capabilitychange on the document: the first change to
425
+ // canEdit === true mounts, and losing it afterwards takes the toggle away.
426
+ // Without ClayJS the boot global keeps deciding, exactly as before.
427
+ const clay = window.clay
428
+ const managed = !!clay && typeof clay.setMode === 'function'
675
429
  const run = () => { if (document.body && api.hasRules(document)) injectToggle(api) }
676
- if (document.readyState === 'loading') {
677
- document.addEventListener('DOMContentLoaded', run, { once: true })
678
- } else {
679
- run()
430
+ const atDomReady = (fn) => {
431
+ if (document.readyState === 'loading') {
432
+ document.addEventListener('DOMContentLoaded', fn, { once: true })
433
+ } else {
434
+ fn()
435
+ }
436
+ }
437
+ if (managed) {
438
+ // A page that loads ClayJS's menu opens the CMS from there, so the toggle stays away
439
+ // while that menu is planned, and comes back only if the menu fails to load.
440
+ let menuFailed = false
441
+ const menuOwnsLauncher = () => {
442
+ const plugins = window.clay && window.clay.plugins
443
+ return !menuFailed && Array.isArray(plugins) && plugins.includes('menu')
444
+ }
445
+ const sync = () => {
446
+ if (window.clay && window.clay.canEdit === true && !menuOwnsLauncher()) {
447
+ run()
448
+ return
449
+ }
450
+ const host = document.querySelector(`[${HOST_ATTR}]`)
451
+ if (host) host.remove()
452
+ }
453
+ atDomReady(sync)
454
+ document.addEventListener('clay:capabilitychange', sync)
455
+ document.addEventListener('clay:plugin-failed', (event) => {
456
+ if (!event.detail || event.detail.plugin !== 'menu') return
457
+ menuFailed = true
458
+ sync()
459
+ })
460
+ return
680
461
  }
462
+ const forced = window.__hyperclayEditMode != null ? window.__hyperclayEditMode : null
463
+ if (!detectEditMode({ search: window.location.search, cookie: document.cookie, forced })) return
464
+ atDomReady(run)
681
465
  }
@@ -0,0 +1,46 @@
1
+ import { VARIANT_ICONS } from './catalogs/variant-icons.js'
2
+ import { humanize } from './templates.js'
3
+
4
+ // "2:cols-2" → { value: '2', icon: 'cols-2' }; a bare "cards" → its own icon
5
+ // when one exists. Split on the first colon only, so a value never loses text.
6
+ export function parseVariantOptions(tokens) {
7
+ return (tokens || []).map((token) => {
8
+ const colon = token.indexOf(':')
9
+ const value = colon === -1 ? token : token.slice(0, colon)
10
+ const iconName = colon === -1 ? token : token.slice(colon + 1)
11
+ const icon = VARIANT_ICONS[iconName] || null
12
+ return { value, icon, label: icon ? icon.label : humanize(value) }
13
+ })
14
+ }
15
+
16
+ // Build one native radio per option from the template's prototype row. The
17
+ // current value is prepended when it is not offered, so opening the editor
18
+ // never silently changes the page.
19
+ export function populateVariant(node, { tokens, value, name, doc }) {
20
+ const options = parseVariantOptions(tokens)
21
+ const current = value == null ? '' : String(value)
22
+ if (current !== '' && !options.some((o) => o.value === current)) {
23
+ options.unshift({ value: current, icon: null, label: humanize(current) })
24
+ }
25
+ const proto = node.querySelector('.hcms-variant-option')
26
+ if (!proto || !proto.parentNode) return options.length
27
+ for (const o of options) {
28
+ const row = proto.cloneNode(true)
29
+ const input = row.querySelector('input[type="radio"]')
30
+ if (input) {
31
+ input.value = o.value
32
+ input.name = name
33
+ }
34
+ const iconSlot = row.querySelector('.hcms-variant-icon')
35
+ if (iconSlot) {
36
+ if (o.icon) iconSlot.innerHTML = o.icon.svg
37
+ else iconSlot.remove()
38
+ }
39
+ const label = row.querySelector('.hcms-variant-label')
40
+ if (label) label.textContent = o.label
41
+ row.setAttribute('title', o.label)
42
+ proto.parentNode.insertBefore(row, proto)
43
+ }
44
+ proto.remove()
45
+ return options.length
46
+ }