domma-cms 0.48.0 → 0.49.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.md +13 -10
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/menu-tree.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/templates/menu-editor.html +26 -0
- package/admin/js/views/menu-editor.js +27 -12
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/menu-float.js +1 -0
- package/public/js/site.js +1 -1
- package/server/services/markdown.js +4 -1
- package/server/services/menuRender.js +63 -3
- package/server/services/menus.js +77 -1
- package/server/templates/page.html +5 -0
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Item filtering (visibility, `hidden`), depth capping and badge-count
|
|
11
11
|
* resolution happen before this - callers hand in the items they want drawn.
|
|
12
12
|
*/
|
|
13
|
-
import {colourToCss, floatToCss} from './menus.js';
|
|
13
|
+
import {colourToCss, floatToCss, floatBehaviour, spacerSize} from './menus.js';
|
|
14
14
|
|
|
15
15
|
/** Attribute-safe escaping - mirrors escapeAttr in markdown.js. */
|
|
16
16
|
function esc(str) {
|
|
@@ -35,6 +35,11 @@ export function renderMenuItemsAsUl(items) {
|
|
|
35
35
|
if (!items || !items.length) return '';
|
|
36
36
|
const lis = items.map(it => {
|
|
37
37
|
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
38
|
+
// Spacers are blank room, not content - hidden from assistive tech so
|
|
39
|
+
// they don't read as an empty list item.
|
|
40
|
+
if (it && it.type === 'spacer') {
|
|
41
|
+
return `<li class="dm-menu-spacer dm-menu-spacer--${esc(spacerSize(it))}" aria-hidden="true"></li>`;
|
|
42
|
+
}
|
|
38
43
|
const href = esc(it.url || '#');
|
|
39
44
|
const text = esc(it.text || '');
|
|
40
45
|
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
@@ -85,6 +90,61 @@ export function buildMenuNav(menu, items, opts = {}) {
|
|
|
85
90
|
? ` style="${esc(floatCss)}" data-float-anchor="${esc(menu.float?.anchor || 'TL')}"`
|
|
86
91
|
: '';
|
|
87
92
|
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
// Panel behaviour - only ever applies where the menu actually floats, so a
|
|
94
|
+
// shortcode rendered inline (noFloat) never grows a drag handle.
|
|
95
|
+
const beh = floatCss ? floatBehaviour(menu) : floatBehaviour(null);
|
|
96
|
+
const behAttrs = [
|
|
97
|
+
beh.collapsible && ' data-float-collapsible="1"',
|
|
98
|
+
beh.movable && ' data-float-movable="1"',
|
|
99
|
+
beh.resizable && ' data-float-resizable="1"'
|
|
100
|
+
].filter(Boolean).join('');
|
|
101
|
+
// The author's default state is stamped as a class, not left to JS, so a
|
|
102
|
+
// panel that starts collapsed never flashes open on first paint.
|
|
103
|
+
const collapsedClass = beh.collapsed ? ' is-collapsed' : '';
|
|
104
|
+
|
|
105
|
+
const flexClass = hasFlexSpacer(items) ? ' dm-menu--has-flex-spacer' : '';
|
|
106
|
+
|
|
107
|
+
return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${variantClass}${floatClass}${collapsedClass}${flexClass}${klass}"`
|
|
108
|
+
+ ` data-menu="${esc(menu.slug)}"${floatAttrs}${behAttrs}>`
|
|
109
|
+
+ buildFloatChrome(menu, beh)
|
|
110
|
+
+ renderMenuItemsAsUl(items)
|
|
111
|
+
+ `</nav>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Does this level contain a spacer that soaks up the leftover room? */
|
|
115
|
+
function hasFlexSpacer(items) {
|
|
116
|
+
return (items || []).some(it => it && it.type === 'spacer' && spacerSize(it) === 'flex');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The bar across the top of a floating panel: drag grip, title, and the
|
|
121
|
+
* hamburger that collapses the panel down to just that button.
|
|
122
|
+
*
|
|
123
|
+
* Returns '' when the menu is neither collapsible nor movable - a plain
|
|
124
|
+
* floating panel keeps the bare `<ul>` markup it has always had.
|
|
125
|
+
*
|
|
126
|
+
* @param {object} menu
|
|
127
|
+
* @param {object} beh Result of floatBehaviour(menu)
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function buildFloatChrome(menu, beh) {
|
|
131
|
+
if (!beh.chrome) return '';
|
|
132
|
+
|
|
133
|
+
const grip = beh.movable
|
|
134
|
+
? '<span class="dm-menu-float-grip" aria-hidden="true"></span>'
|
|
135
|
+
: '';
|
|
136
|
+
const title = beh.title
|
|
137
|
+
? `<span class="dm-menu-float-title">${esc(beh.title)}</span>`
|
|
138
|
+
: '';
|
|
139
|
+
// No aria-expanded here: sanitize-html strips aria attributes from the
|
|
140
|
+
// `[menu]` shortcode path, so menu-float.js stamps the ARIA state on init
|
|
141
|
+
// and keeps it in step from then on.
|
|
142
|
+
const toggle = beh.collapsible
|
|
143
|
+
? `<button type="button" class="dm-menu-float-toggle" data-float-toggle`
|
|
144
|
+
+ ` aria-label="${esc(beh.title ? `Toggle ${beh.title}` : 'Toggle menu')}">`
|
|
145
|
+
+ `<span class="dm-menu-float-icon" data-icon="bars"></span></button>`
|
|
146
|
+
: '';
|
|
147
|
+
|
|
148
|
+
return `<div class="dm-menu-float-bar"${beh.movable ? ' data-float-handle' : ''}>`
|
|
149
|
+
+ `${grip}${title}${toggle}</div>`;
|
|
90
150
|
}
|
package/server/services/menus.js
CHANGED
|
@@ -184,6 +184,48 @@ export function floatToCss(menu) {
|
|
|
184
184
|
return decls.join(';');
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Floating panel behaviour - collapse to a hamburger, drag to move, resize.
|
|
189
|
+
//
|
|
190
|
+
// These are behaviour flags rather than CSS: `floatToCss` still owns where the
|
|
191
|
+
// panel starts, and `menu-float.js` owns what the visitor can then do to it.
|
|
192
|
+
// The server needs them at render time to emit the panel chrome (title bar and
|
|
193
|
+
// toggle button), so they resolve here and are mirrored in menu-decor.mjs.
|
|
194
|
+
//
|
|
195
|
+
// A visitor's own drag/size/collapse state lives in their browser, keyed by
|
|
196
|
+
// menu slug - the config below is only ever the starting point.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
export const FLOAT_FLAGS = ['collapsible', 'collapsed', 'movable', 'resizable'];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve a floating menu's behaviour flags, plus whether it needs the chrome
|
|
202
|
+
* bar drawn at all. Returns all-false for a menu that is not floating, so a
|
|
203
|
+
* `[menu]` shortcode rendered inline never grows a drag handle.
|
|
204
|
+
*
|
|
205
|
+
* `title` is what the bar shows: the author's override, else the menu name.
|
|
206
|
+
*
|
|
207
|
+
* @param {object} menu A menu record (uses .position, .float, .name)
|
|
208
|
+
* @returns {{collapsible: boolean, collapsed: boolean, movable: boolean,
|
|
209
|
+
* resizable: boolean, chrome: boolean, title: string}}
|
|
210
|
+
*/
|
|
211
|
+
export function floatBehaviour(menu) {
|
|
212
|
+
const off = {collapsible: false, collapsed: false, movable: false, resizable: false, chrome: false, title: ''};
|
|
213
|
+
if (!menu || menu.position !== 'floating') return off;
|
|
214
|
+
|
|
215
|
+
const f = (menu.float && typeof menu.float === 'object' && !Array.isArray(menu.float)) ? menu.float : {};
|
|
216
|
+
const flags = {
|
|
217
|
+
collapsible: !!f.collapsible,
|
|
218
|
+
collapsed: !!f.collapsible && !!f.collapsed,
|
|
219
|
+
movable: !!f.movable,
|
|
220
|
+
resizable: !!f.resizable
|
|
221
|
+
};
|
|
222
|
+
// The bar carries the toggle and doubles as the drag handle; a menu that
|
|
223
|
+
// can only be resized needs no bar - the UA resize grip is enough.
|
|
224
|
+
const chrome = flags.collapsible || flags.movable;
|
|
225
|
+
const title = typeof f.title === 'string' && f.title !== '' ? f.title : (menu.name || '');
|
|
226
|
+
return {...flags, chrome, title: chrome ? title : ''};
|
|
227
|
+
}
|
|
228
|
+
|
|
187
229
|
// ---------------------------------------------------------------------------
|
|
188
230
|
// Orientation - how the menu flows where it renders. `horizontal` is the
|
|
189
231
|
// historic behaviour and stays the default for every menu without the field.
|
|
@@ -193,6 +235,23 @@ export function floatToCss(menu) {
|
|
|
193
235
|
// `side` only means anything for a vertical menu in the navbar slot - it picks
|
|
194
236
|
// the viewport edge the rail docks to.
|
|
195
237
|
// ---------------------------------------------------------------------------
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Spacer items - `{type: 'spacer'}`, the sibling of `{type: 'separator'}`.
|
|
240
|
+
//
|
|
241
|
+
// A separator draws a line; a spacer draws nothing and just takes up room. The
|
|
242
|
+
// fixed sizes map to the theme's spacing tokens so a spacer scales with the
|
|
243
|
+
// rest of the UI. `flex` is the interesting one: it soaks up all the leftover
|
|
244
|
+
// room, which is how you push the items after it to the far end of a navbar or
|
|
245
|
+
// down to the foot of a vertical rail.
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
export const SPACER_SIZES = new Set(['xs', 'sm', 'md', 'lg', 'xl', 'flex']);
|
|
248
|
+
export const SPACER_DEFAULT_SIZE = 'md';
|
|
249
|
+
|
|
250
|
+
/** A spacer's size, defaulted and validated. */
|
|
251
|
+
export function spacerSize(item) {
|
|
252
|
+
return SPACER_SIZES.has(item?.size) ? item.size : SPACER_DEFAULT_SIZE;
|
|
253
|
+
}
|
|
254
|
+
|
|
196
255
|
export const ORIENTATIONS = new Set(['horizontal', 'vertical']);
|
|
197
256
|
export const SIDES = new Set(['left', 'right']);
|
|
198
257
|
|
|
@@ -260,7 +319,7 @@ export async function resolveMenuDecorations(items, opts = {}) {
|
|
|
260
319
|
|
|
261
320
|
async function walk(list) {
|
|
262
321
|
return Promise.all((list || []).map(async (it) => {
|
|
263
|
-
if (!it || it.type === 'separator') return it;
|
|
322
|
+
if (!it || it.type === 'separator' || it.type === 'spacer') return it;
|
|
264
323
|
const next = {...it};
|
|
265
324
|
if (it.badge && it.badge.countFrom) {
|
|
266
325
|
try {
|
|
@@ -591,6 +650,15 @@ export function validateMenu(menu) {
|
|
|
591
650
|
if (menu.float.accent != null && menu.float.accent !== '' && !isValidColour(menu.float.accent)) {
|
|
592
651
|
errors.push('float.accent must be a preset key or #rrggbb');
|
|
593
652
|
}
|
|
653
|
+
for (const key of FLOAT_FLAGS) {
|
|
654
|
+
const v = menu.float[key];
|
|
655
|
+
if (v != null && typeof v !== 'boolean') {
|
|
656
|
+
errors.push(`float.${key} must be true or false`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
if (menu.float.title != null && typeof menu.float.title !== 'string') {
|
|
660
|
+
errors.push('float.title must be a string');
|
|
661
|
+
}
|
|
594
662
|
}
|
|
595
663
|
}
|
|
596
664
|
if (!Array.isArray(menu.items)) {
|
|
@@ -601,6 +669,14 @@ export function validateMenu(menu) {
|
|
|
601
669
|
// Renderers translate them into <hr> / structural breaks.
|
|
602
670
|
if (item && item.type === 'separator') return;
|
|
603
671
|
|
|
672
|
+
// Spacers are blank room, likewise with nothing to point at.
|
|
673
|
+
if (item && item.type === 'spacer') {
|
|
674
|
+
if (item.size != null && !SPACER_SIZES.has(item.size)) {
|
|
675
|
+
errors.push(`Item ${path}: spacer size must be one of: ${[...SPACER_SIZES].join(', ')}`);
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
604
680
|
if (!item.text || typeof item.text !== 'string') {
|
|
605
681
|
errors.push(`Item ${path}: text is required`);
|
|
606
682
|
}
|
|
@@ -119,6 +119,11 @@
|
|
|
119
119
|
<script src="/public/js/site.js?v=20260829-motion-toggle" type="module"></script>
|
|
120
120
|
<script src="/public/js/collection-context.js?v=20260819-export" type="module"></script>
|
|
121
121
|
|
|
122
|
+
<!-- Floating menu panels: collapse to a hamburger, drag to move, resize.
|
|
123
|
+
Loaded here rather than from site.js so server-rendered overlays and
|
|
124
|
+
[menu] shortcodes are wired even on pages with no floating navbar. -->
|
|
125
|
+
<script src="/public/js/menu-float.js?v=20260829-float-panel" type="module"></script>
|
|
126
|
+
|
|
122
127
|
<!-- Interactive Collection Browser (used by [collection] shortcodes with
|
|
123
128
|
searchable / filterable / sortable / paginate attributes) -->
|
|
124
129
|
<script src="/public/js/collection-browser.js?v=20260523-browser"></script>
|