@panphora/hyper-cms 0.10.0 → 0.11.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
@@ -7,24 +7,178 @@
7
7
  // snapshot (a static no-save element written into the file would be stripped
8
8
  // from disk on the first save), save-ignore keeps it out of autosave/undo,
9
9
  // mirroring the shell's own attributes.
10
+ //
11
+ // The control is a <hypercms-toggle> host wrapping a real <button>. The host is
12
+ // an unregistered custom element on purpose: a host page's `button { ... }`
13
+ // reset cannot reach it, and that reset is the common way an injected control
14
+ // gets mangled. Only the structural facts that must survive a hostile
15
+ // `* { ... !important }` reset are pinned inline with !important; each reads a
16
+ // public custom property, so overriding still works. Everything cosmetic is a
17
+ // normal declaration selected by [data-hcms-toggle-host], which an author's own
18
+ // #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
+
28
+ import { reensureStyles } from './shell.js'
10
29
 
11
30
  const TOGGLE_ID = 'hcms-toggle'
31
+ const HOST_ATTR = 'data-hcms-toggle-host'
12
32
  const STYLE_ID = 'hcms-toggle-style'
33
+ const STYLE_ATTR = 'data-hcms-toggle-style'
34
+ // "A session of either kind is up", written by the open/close events. NOT
35
+ // body.hcms-open: that class is the sidebar's page shift and an inline session
36
+ // sets nothing on <body>, so a body-class label reads "Edit content" over an
37
+ // open inline editor.
38
+ 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
+
49
+ const SURFACE_VAR = 'var(--hcms-toggle-bg, var(--hcms-toggle-_surface))'
50
+
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
+ // The pill's surface is opaque and neutral, picked at inject time from the two
73
+ // values below by whichever has more contrast against the page's own ink. It is
74
+ // not a tint of currentColor: the button floats over a backdrop it cannot see,
75
+ // and a 10% tint over a dark hero measures 1.3:1. Opaque removes the backdrop
76
+ // from the question entirely. Measured floor for this pair is 4.36:1 across
77
+ // 1516 inks; a 4.5:1 floor needs #030303, which is black in all but name.
78
+ const SURFACE_LIGHT = '#fafafa'
79
+ const SURFACE_DARK = '#0a0a0a'
13
80
 
14
- const STYLE = `
15
- #hcms-toggle {
16
- position: fixed; right: 16px; bottom: 16px; z-index: 2147482900;
17
- display: inline-flex; align-items: center; gap: 7px; padding: 9px 14px;
18
- border: 1px solid #3a3f58; background: #14161f; color: #f2f3f7;
19
- font: 500 13px/1 system-ui, sans-serif; border-radius: 999px; cursor: pointer;
20
- box-shadow: 0 10px 28px -12px rgba(0, 0, 0, .55);
21
- }
22
- #hcms-toggle:hover { background: #1d2030; }
23
- #hcms-toggle .hcms-toggle__close { display: none; }
24
- body.hcms-open #hcms-toggle .hcms-toggle__open { display: none; }
25
- body.hcms-open #hcms-toggle .hcms-toggle__close { display: inline; }
26
- body.hcms-open:not(.hcms-overlay):not(.hcms-side-left) #hcms-toggle { right: calc(380px + 16px); }
27
- body.hcms-open.hcms-overlay #hcms-toggle { display: none; }
81
+ export const TOGGLE_STYLE = `
82
+ [${HOST_ATTR}] {
83
+ all: unset;
84
+ box-sizing: border-box;
85
+ display: var(--hcms-toggle-display, inline-flex);
86
+ font-family: 'Departure Mono', ui-monospace, Menlo, monospace;
87
+ --hcms-toggle-_surface: ${SURFACE_LIGHT};
88
+ }
89
+ [${HOST_ATTR}][data-hcms-surface="dark"] {
90
+ --hcms-toggle-_surface: ${SURFACE_DARK};
91
+ }
92
+ [${HOST_ATTR}] .hcms-toggle__main,
93
+ [${HOST_ATTR}] .hcms-toggle__arrow {
94
+ all: unset;
95
+ box-sizing: border-box;
96
+ display: inline-flex;
97
+ align-items: center;
98
+ gap: 7px;
99
+ padding: 0 14px;
100
+ min-height: 40px;
101
+ font-family: inherit;
102
+ font-weight: 400;
103
+ line-height: 1.5;
104
+ font-size: 14px;
105
+ color: var(--hcms-toggle-color, currentColor);
106
+ border: 2px solid;
107
+ border-color: var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8);
108
+ border-radius: 0;
109
+ cursor: pointer;
110
+ box-shadow: none;
111
+ }
112
+ [${HOST_ATTR}] .hcms-toggle__main:hover,
113
+ [${HOST_ATTR}] .hcms-toggle__arrow:hover {
114
+ border-color: var(--mirk-focus-color, #C7AE93);
115
+ box-shadow: none;
116
+ }
117
+ [${HOST_ATTR}] .hcms-toggle__main:active,
118
+ [${HOST_ATTR}] .hcms-toggle__arrow:active {
119
+ border-color: var(--mirk-bevel-br, #E2D4BF) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-tl, #F0E7D8) var(--mirk-bevel-br, #E2D4BF);
120
+ }
121
+ [${HOST_ATTR}] .hcms-toggle__main:focus-visible,
122
+ [${HOST_ATTR}] .hcms-toggle__arrow:focus-visible {
123
+ outline: 2px solid currentColor;
124
+ outline-offset: -5px;
125
+ }
126
+ [${HOST_ATTR}] .hcms-toggle__close { display: none; }
127
+ [${HOST_ATTR}][${SESSION_ATTR}="open"] .hcms-toggle__open { display: none; }
128
+ [${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
+ @media (pointer: coarse) {
179
+ [${HOST_ATTR}] .hcms-toggle__main,
180
+ [${HOST_ATTR}] .hcms-toggle__arrow { min-height: 44px; }
181
+ }
28
182
  `
29
183
 
30
184
  // Mirrors hyperclayjs core/isAdminOfCurrentResource.js: an explicit
@@ -42,34 +196,473 @@ export function detectEditMode({ search = '', cookie = '', forced = null } = {})
42
196
  return /(?:^|;\s*)isAdminOfCurrentResource=[^;]/.test(cookie)
43
197
  }
44
198
 
45
- export function injectToggle({ open, close, isOpen }, doc = document) {
46
- const existing = doc.getElementById(TOGGLE_ID)
199
+ // A computed `color` is not always an rgb() string: CSS Color 4 keeps the
200
+ // authored space, so Chrome returns oklch(), lab() and color() as written, and
201
+ // Tailwind v4's whole palette is oklch. rgb()/rgba() is still what the great
202
+ // majority of pages compute to, so parse that directly and let the canvas
203
+ // handle only the rest.
204
+ const colorContexts = new WeakMap()
205
+
206
+ function readCanvasColor(ctx, value) {
207
+ try {
208
+ // 'copy' replaces the pixel instead of blending, so alpha survives.
209
+ ctx.globalCompositeOperation = 'copy'
210
+ ctx.fillStyle = value
211
+ ctx.fillRect(0, 0, 1, 1)
212
+ const d = ctx.getImageData(0, 0, 1, 1).data
213
+ return { r: d[0], g: d[1], b: d[2], a: d[3] / 255 }
214
+ } catch (_) {
215
+ return null
216
+ }
217
+ }
218
+
219
+ // Per document, not per module: the context retains its canvas, which retains
220
+ // its ownerDocument, and hypercms can be driven into a preview iframe.
221
+ function colorContextFor(doc) {
222
+ if (colorContexts.has(doc)) return colorContexts.get(doc)
223
+ let ctx = null
224
+ try {
225
+ const canvas = doc.createElement('canvas')
226
+ canvas.width = canvas.height = 1
227
+ ctx = canvas.getContext ? canvas.getContext('2d', { willReadFrequently: true }) : null
228
+ // A browser that fuzzes canvas readback (Firefox's resistFingerprinting)
229
+ // answers the same colour for everything, which would send every page to
230
+ // one surface at about 1:1. Round-trip a known value once and refuse the
231
+ // whole path if it does not come back exactly.
232
+ if (ctx) {
233
+ const probe = readCanvasColor(ctx, 'rgb(1, 2, 3)')
234
+ if (!probe || probe.r !== 1 || probe.g !== 2 || probe.b !== 3 || probe.a !== 1) ctx = null
235
+ }
236
+ } catch (_) {
237
+ ctx = null
238
+ }
239
+ colorContexts.set(doc, ctx)
240
+ return ctx
241
+ }
242
+
243
+ function normalizeColor(value, doc) {
244
+ const raw = String(value == null ? '' : value).trim()
245
+ if (!raw) return null
246
+ const direct = parseRgb(raw)
247
+ if (direct) return direct
248
+ const win = doc && doc.defaultView
249
+ // CSS.supports is the parse check AND the capability probe: jsdom has neither
250
+ // it nor a 2d context, so this short-circuits without jsdom logging a "not
251
+ // implemented" error for getContext. It matters because a rejected fillStyle
252
+ // silently keeps its previous value, so an unguarded read would report the
253
+ // last ink this page measured.
254
+ if (!win || !win.CSS || typeof win.CSS.supports !== 'function') return null
255
+ if (!win.CSS.supports('color', raw)) return null
256
+ const ctx = colorContextFor(doc)
257
+ return ctx ? readCanvasColor(ctx, raw) : null
258
+ }
259
+
260
+ export function parseRgb(value) {
261
+ const match = /rgba?\(([^)]+)\)/.exec(String(value == null ? '' : value))
262
+ if (!match) return null
263
+ const parts = match[1].split(/[\s,/]+/).filter(Boolean)
264
+ if (parts.length < 3) return null
265
+ const channels = parts.slice(0, 3).map(Number)
266
+ if (channels.some(Number.isNaN)) return null
267
+ const rawAlpha = parts[3]
268
+ const alpha = rawAlpha === undefined
269
+ ? 1
270
+ : (rawAlpha.endsWith('%') ? Number(rawAlpha.slice(0, -1)) / 100 : Number(rawAlpha))
271
+ return {
272
+ r: channels[0], g: channels[1], b: channels[2],
273
+ a: Number.isNaN(alpha) ? 1 : alpha,
274
+ }
275
+ }
276
+
277
+ function relativeLuminance({ r, g, b }) {
278
+ const channel = (v) => {
279
+ const s = v / 255
280
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
281
+ }
282
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
283
+ }
284
+
285
+ function contrast(a, b) {
286
+ const la = relativeLuminance(a)
287
+ const lb = relativeLuminance(b)
288
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
289
+ }
290
+
291
+ const DARK_RGB = { r: 10, g: 10, b: 10 }
292
+ const LIGHT_RGB = { r: 250, g: 250, b: 250 }
293
+
294
+ // Source-over composite, because the label is painted ON the surface: a
295
+ // translucent ink's rendered colour depends on which surface it lands on, so
296
+ // each candidate has to be judged against its own composite.
297
+ function over(fg, bg) {
298
+ const a = fg.a == null ? 1 : fg.a
299
+ return {
300
+ r: fg.r * a + bg.r * (1 - a),
301
+ g: fg.g * a + bg.g * (1 - a),
302
+ b: fg.b * a + bg.b * (1 - a),
303
+ }
304
+ }
305
+
306
+ // Which of the two surfaces the page's ink reads better on. Ties and unknown
307
+ // inks go light, which is the quieter result on the overwhelmingly common
308
+ // dark-ink-on-light-ground page. The 4.36:1 floor holds for opaque ink only:
309
+ // nothing can make `color: rgb(0 0 0 / 40%)` readable, and this picks the
310
+ // better of two bad options rather than pretending otherwise.
311
+ export function pickSurface(ink) {
312
+ if (!ink) return 'light'
313
+ const onDark = contrast(over(ink, DARK_RGB), DARK_RGB)
314
+ const onLight = contrast(over(ink, LIGHT_RGB), LIGHT_RGB)
315
+ return onDark > onLight ? 'dark' : 'light'
316
+ }
317
+
318
+ function pin(el, declarations) {
319
+ for (const [property, value] of Object.entries(declarations)) {
320
+ el.style.setProperty(property, value, 'important')
321
+ }
322
+ }
323
+
324
+ function applySurface(host) {
325
+ const win = host.ownerDocument && host.ownerDocument.defaultView
326
+ const main = host.querySelector('.hcms-toggle__main')
327
+ if (!win || !main || typeof win.getComputedStyle !== 'function') return
328
+ const ink = normalizeColor(win.getComputedStyle(main).color, host.ownerDocument)
329
+ host.setAttribute('data-hcms-surface', pickSurface(ink))
330
+ }
331
+
332
+ // The ink is read from the live cascade, so it is only final once the page's
333
+ // own stylesheets have applied, and it changes again whenever the page switches
334
+ // theme. A dark-mode toggle that flips <html> or <body> would otherwise leave a
335
+ // near-white label on the near-white surface picked at load. Two elements, no
336
+ // subtree, rAF-collapsed, and it unhooks itself once the host is gone.
337
+ function scheduleSurface(host) {
338
+ applySurface(host)
339
+ const doc = host.ownerDocument
340
+ const win = doc && doc.defaultView
341
+ if (!win) return
342
+
343
+ let frame = 0
344
+ const recompute = () => {
345
+ if (frame) return
346
+ if (typeof win.requestAnimationFrame !== 'function') {
347
+ if (!host.isConnected) return stop()
348
+ return applySurface(host)
349
+ }
350
+ frame = win.requestAnimationFrame(() => {
351
+ frame = 0
352
+ if (!host.isConnected) return stop()
353
+ applySurface(host)
354
+ })
355
+ }
356
+
357
+ if (typeof win.requestAnimationFrame === 'function') recompute()
358
+ if (doc.readyState !== 'complete') win.addEventListener('load', recompute, { once: true })
359
+
360
+ const observer = typeof win.MutationObserver === 'function' ? new win.MutationObserver(recompute) : null
361
+ if (observer) {
362
+ // No attributeFilter. Theme togglers write whatever attribute they like:
363
+ // Bootstrap 5.3 uses data-bs-theme, Primer uses data-color-mode, plenty use
364
+ // a class or an inline style. An allowlist is a guess about other people's
365
+ // markup and it was already wrong twice. Two elements, no subtree, and the
366
+ // callback collapses into one animation frame, so watching every attribute
367
+ // costs nothing measurable.
368
+ observer.observe(doc.documentElement, { attributes: true })
369
+ if (doc.body) observer.observe(doc.body, { attributes: true })
370
+ }
371
+
372
+ const scheme = typeof win.matchMedia === 'function' ? win.matchMedia('(prefers-color-scheme: dark)') : null
373
+ if (scheme && typeof scheme.addEventListener === 'function') scheme.addEventListener('change', recompute)
374
+
375
+ // Self-disconnecting: any attribute write on <html> or <body> schedules a
376
+ // recompute, which stops the watcher once the host has left the document.
377
+ // Round 2 added an explicit sweep at injection time as well; it was dead
378
+ // code, because injectToggle runs once per page. The residual is a single
379
+ // observer surviving on a page that removes our host and then never touches
380
+ // an attribute on <html> or <body> again, which is clutter on a page that
381
+ // has already thrown the control away.
382
+ function stop() {
383
+ if (observer) observer.disconnect()
384
+ if (scheme && typeof scheme.removeEventListener === 'function') scheme.removeEventListener('change', recompute)
385
+ }
386
+ }
387
+
388
+ export function injectToggle({ open, close, isOpen, getTheme = () => null, views = VIEW_NAMES }, doc = document) {
389
+ const existing = doc.querySelector(`[${HOST_ATTR}]`)
47
390
  if (existing) return existing
48
- if (!doc.getElementById(STYLE_ID)) {
391
+
392
+ if (!doc.querySelector(`[${STYLE_ATTR}]`)) {
49
393
  const style = doc.createElement('style')
50
- style.id = STYLE_ID
394
+ style.setAttribute(STYLE_ATTR, '')
395
+ style.setAttribute('editor-ui', '')
396
+ if (!doc.getElementById(STYLE_ID)) style.id = STYLE_ID
397
+ style.setAttribute('no-save', '')
51
398
  style.setAttribute('snapshot-remove', '')
52
- style.textContent = STYLE
53
- doc.head.appendChild(style)
54
- }
55
- const btn = doc.createElement('button')
56
- btn.type = 'button'
57
- btn.id = TOGGLE_ID
58
- btn.setAttribute('no-save', '')
59
- btn.setAttribute('snapshot-remove', '')
60
- btn.setAttribute('save-ignore', '')
61
- btn.setAttribute('aria-label', 'Toggle content editor')
62
- btn.innerHTML = '<span class="hcms-toggle__open">Edit content</span><span class="hcms-toggle__close">Close editor</span>'
63
- btn.addEventListener('click', async () => {
399
+ style.setAttribute('save-ignore', '')
400
+ style.textContent = TOGGLE_STYLE
401
+ // Prepended, not appended: an author who overrides with a selector no
402
+ // stronger than ours then wins the tie on document order.
403
+ doc.head.insertBefore(style, doc.head.firstChild)
404
+ }
405
+
406
+ const host = doc.createElement('hypercms-toggle')
407
+ host.className = 'hcms-shell pixel-quiet'
408
+ // The id is the documented override handle, but it is the page's namespace:
409
+ // if the page already owns it, go without rather than shadow their element.
410
+ if (!doc.getElementById(TOGGLE_ID)) host.id = TOGGLE_ID
411
+ host.setAttribute(HOST_ATTR, '')
412
+ host.setAttribute('editor-ui', '')
413
+ host.setAttribute('no-save', '')
414
+ host.setAttribute('snapshot-remove', '')
415
+ host.setAttribute('save-ignore', '')
416
+ host.innerHTML =
417
+ '<button type="button" class="hcms-toggle__main mirk-button">' +
418
+ '<span class="hcms-toggle__open">Edit content</span>' +
419
+ '<span class="hcms-toggle__close">Close editor</span>' +
420
+ '</button>'
421
+
422
+ const win = doc.defaultView
423
+ 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
+
438
+ // The only declarations a page's `* { ... !important }` reset can take from
439
+ // us and leave the control broken rather than merely ugly. Each reads its
440
+ // public custom property first, so an author override still lands.
441
+ pin(host, {
442
+ position: 'fixed',
443
+ right: 'calc(var(--hcms-toggle-offset, 16px) + var(--hcms-toggle-shift, 0px))',
444
+ bottom: 'calc(var(--hcms-toggle-offset, 16px) + env(safe-area-inset-bottom, 0px))',
445
+ 'z-index': 'var(--hcms-toggle-z, 2147482900)',
446
+ })
447
+ 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
+
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
518
+ }
519
+
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)
526
+ }
527
+
528
+ async function mainAction() {
529
+ if (isOpen()) {
530
+ close()
531
+ return
532
+ }
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] } : {})
542
+ }
543
+
544
+ async function run(action) {
64
545
  try {
65
- if (isOpen()) close()
66
- else await open()
546
+ await action()
67
547
  } catch (err) {
68
548
  console.warn('hypercms: toggle failed to open the CMS', err)
69
549
  }
550
+ }
551
+
552
+ // 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
558
+ await run(mainAction)
70
559
  })
71
- doc.body.appendChild(btn)
72
- return btn
560
+
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
+ // Which label the main button shows, and nothing else decides it. hcms:close
611
+ // fires BEFORE the session is torn down, so isOpen() still answers true inside
612
+ // that handler: the event says what happened and re-asking would leave the
613
+ // button reading "Close editor" over a closed editor.
614
+ const setSessionState = (sessionOpen) => {
615
+ const theme = getTheme()
616
+ host.classList.toggle('light', theme === 'light')
617
+ host.classList.toggle('dark', theme === 'dark')
618
+ if (sessionOpen) host.setAttribute(SESSION_ATTR, 'open')
619
+ else host.removeAttribute(SESSION_ATTR)
620
+ }
621
+ setSessionState(isOpen())
622
+ doc.addEventListener('hcms:open', () => setSessionState(true))
623
+ doc.addEventListener('hcms:close', () => setSessionState(false))
624
+
625
+ doc.body.appendChild(host)
626
+ reensureStyles(doc)
627
+ scheduleSurface(host)
628
+ return host
629
+ }
630
+
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
73
666
  }
74
667
 
75
668
  // Inject at page load when in edit mode and the page has cms rules. DOM-ready