/** * Page Access ACL picker (admin-next custom field). * * Mirrors admin-classic's `acl_picker` with `data_type: access`: a list of * rows, each pairing an access action (e.g. `admin.login`) with an * Allowed / Denied choice. * * Value shape (what grav-core reads from `header.access`): * { "admin.login": true, "site.login": false } * true = Allowed, false = Denied. Absent keys are unset. * * The action dropdown is a type-ahead popover with a searchable, expandable * tree (the action names are hierarchical: admin > admin.pages > * admin.pages.create), modelled on admin-next's page-parent picker. Options * are baked into `field.options` server-side as [{ value, label }] where value * is the dotted action name and label is the short action label. */ const TAG = window.__GRAV_FIELD_TAG; class AclAccessField extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this._field = null; this._value = null; this._rows = null; // [{ key, allowed }] this._lastEmitted = null; this._openRow = -1; // index of the row whose popover is open this._filter = ''; this._expanded = new Set(); // expanded tree nodes (by action name) this._onDocDown = this._onDocDown.bind(this); } set field(v) { this._field = v; this._render(); } get field() { return this._field; } set value(v) { const serialized = JSON.stringify(v ?? {}); if (serialized === this._lastEmitted) return; this._value = v; this._rows = this._rowsFromValue(v); this._render(); } get value() { return this._value; } connectedCallback() { if (!this._rows) this._rows = this._rowsFromValue(this._value); document.addEventListener('mousedown', this._onDocDown, true); this._render(); } disconnectedCallback() { document.removeEventListener('mousedown', this._onDocDown, true); } // ─── State ────────────────────────────────────────────────────────── _rowsFromValue(v) { const rows = []; if (v && typeof v === 'object' && !Array.isArray(v)) { for (const [key, val] of Object.entries(v)) { rows.push({ key, allowed: val !== false }); } } // Show a single blank starter row only when nothing is set yet, so the // controls are visible. Once there's an entry, the "+" button adds the // next blank row — we don't auto-trail one. if (rows.length === 0) rows.push({ key: '', allowed: true }); return rows; } _options() { const opts = this._field?.options; return Array.isArray(opts) ? opts : []; } _optionLabel(value) { const o = this._options().find((x) => String(x.value) === String(value)); return o ? String(o.label ?? o.value) : ''; } _commit() { const out = {}; for (const row of this._rows) { if (row.key) out[row.key] = !!row.allowed; } this._value = out; this._lastEmitted = JSON.stringify(out); this.dispatchEvent(new CustomEvent('change', { detail: out, bubbles: true })); } _ensureTrailingBlank() { const last = this._rows[this._rows.length - 1]; if (!last || last.key !== '') this._rows.push({ key: '', allowed: true }); } // ─── Render ───────────────────────────────────────────────────────── _render() { if (!this.shadowRoot || !this.isConnected) return; if (!this._rows) this._rows = this._rowsFromValue(this._value); const rowsHtml = this._rows.map((row, i) => `
`).join(''); this.shadowRoot.innerHTML = `
${rowsHtml}
`; this.shadowRoot.querySelectorAll('.row').forEach((el) => { const i = Number(el.dataset.i); el.querySelector('[data-act="open"]')?.addEventListener('click', () => this._toggleOpen(i)); el.querySelector('[data-act="allow"]')?.addEventListener('click', () => this._onToggle(i, true)); el.querySelector('[data-act="deny"]')?.addEventListener('click', () => this._onToggle(i, false)); el.querySelector('[data-act="del"]')?.addEventListener('click', () => this._onDelete(i)); el.querySelector('[data-act="add"]')?.addEventListener('click', () => this._onAdd()); }); if (this._openRow >= 0 && this._openRow < this._rows.length) { this._mountPopover(this._openRow); } } // ─── Type-ahead popover ───────────────────────────────────────────── _toggleOpen(i) { this._openRow = this._openRow === i ? -1 : i; this._filter = ''; this._render(); } _mountPopover(i) { const wrap = this.shadowRoot.querySelector(`.row[data-i="${i}"] .select-wrap`); if (!wrap) return; const pop = document.createElement('div'); pop.className = 'popover'; pop.innerHTML = `
`; wrap.appendChild(pop); this._popoverEl = pop; this._resultsEl = pop.querySelector('.results'); this._searchInput = pop.querySelector('input'); const clearBtn = pop.querySelector('.clear'); this._searchInput.addEventListener('input', () => { this._filter = this._searchInput.value; clearBtn.hidden = !this._filter; this._renderResults(i); }); this._searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this._openRow = -1; this._render(); } }); clearBtn.addEventListener('click', () => { this._filter = ''; this._searchInput.value = ''; clearBtn.hidden = true; this._renderResults(i); this._searchInput.focus(); }); this._renderResults(i); requestAnimationFrame(() => this._searchInput?.focus()); } _buildTree() { const nodes = new Map(); for (const o of this._options()) { const v = String(o.value); nodes.set(v, { value: v, label: String(o.label ?? v), children: [] }); } const roots = []; for (const o of this._options()) { const v = String(o.value); const node = nodes.get(v); const pi = v.lastIndexOf('.'); const parent = pi >= 0 ? nodes.get(v.slice(0, pi)) : null; if (parent) parent.children.push(node); else roots.push(node); } return roots; } _renderResults(rowIndex) { if (!this._resultsEl) return; const selected = this._rows[rowIndex]?.key || ''; const q = this._filter.trim().toLowerCase(); let html = ''; if (q) { // Flat, depth-indented list of every matching action. const matches = this._options().filter((o) => String(o.label).toLowerCase().includes(q) || String(o.value).toLowerCase().includes(q)); html = matches.map((o) => this._nodeRow(String(o.value), String(o.label ?? o.value), depthOf(String(o.value)), false, false, String(o.value) === selected)).join(''); if (!matches.length) html = `
No matching access
`; } else { const walk = (node, depth) => { const hasKids = node.children.length > 0; const isExp = this._expanded.has(node.value); let out = this._nodeRow(node.value, node.label, depth, hasKids, isExp, node.value === selected); if (hasKids && isExp) out += node.children.map((c) => walk(c, depth + 1)).join(''); return out; }; html = this._buildTree().map((n) => walk(n, 0)).join(''); } this._resultsEl.innerHTML = html; this._resultsEl.querySelectorAll('[data-value]').forEach((el) => { el.querySelector('.exp')?.addEventListener('click', (e) => { e.stopPropagation(); const v = el.dataset.value; if (this._expanded.has(v)) this._expanded.delete(v); else this._expanded.add(v); this._renderResults(rowIndex); }); el.addEventListener('click', () => this._select(rowIndex, el.dataset.value)); }); } _nodeRow(value, label, depth, hasKids, isExpanded, selected) { const chevron = hasKids ? `${isExpanded ? CHEVRON_DOWN : CHEVRON_RIGHT}` : ``; return `
${chevron} ${esc(label)}${esc(value)} ${selected ? `${CHECK}` : ''}
`; } _select(i, value) { if (!this._rows[i]) return; this._rows[i].key = value; this._openRow = -1; this._commit(); this._render(); } _onDocDown(e) { if (this._openRow < 0) return; if (this._popoverEl && e.composedPath().includes(this._popoverEl)) return; // Clicking the same trigger is handled by its own click toggler. const trigger = this.shadowRoot.querySelector(`.row[data-i="${this._openRow}"] [data-act="open"]`); if (trigger && e.composedPath().includes(trigger)) return; this._openRow = -1; this._render(); } // ─── Row actions ──────────────────────────────────────────────────── _onToggle(i, allowed) { if (!this._rows[i]) return; this._rows[i].allowed = allowed; this._commit(); this._render(); } _onDelete(i) { this._rows.splice(i, 1); // Keep one blank starter row when the list is emptied entirely. if (!this._rows.length) this._rows.push({ key: '', allowed: true }); if (this._openRow === i) this._openRow = -1; this._commit(); this._render(); } _onAdd() { // Add a blank row to type into — but never stack multiple blanks. this._ensureTrailingBlank(); this._render(); } } function depthOf(value) { return value.split('.').length - 1; } // ─── Inline SVG icons (Lucide-style, currentColor) ────────────────────── const UPDOWN = ''; const CHEVRON_DOWN = ''; const CHEVRON_RIGHT = ''; const SEARCH = ''; const CLOSE = ''; const CHECK = ''; const BAN = ''; const PLUS = ''; const TRASH = ''; function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } const STYLE = ` :host { display: block; font-family: inherit; } .wrap { display: flex; flex-direction: column; gap: 8px; } .row { display: flex; align-items: center; gap: 8px; } .select-wrap { position: relative; flex: 1; min-width: 0; } .combo-trigger { display: flex; align-items: center; gap: 8px; width: 100%; height: 40px; padding: 0 10px 0 12px; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; background: var(--muted, #f8fafc); color: var(--foreground, #0f172a); font-size: 14px; font-family: inherit; cursor: pointer; text-align: start; box-shadow: 0 1px 2px rgba(0,0,0,.04); } .combo-trigger:hover { background: var(--accent, #f1f5f9); } .combo-trigger .lbl { font-weight: 500; white-space: nowrap; } .combo-trigger .muted { color: var(--muted-foreground, #64748b); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .combo-trigger .ph { color: var(--muted-foreground, #94a3b8); } .combo-trigger .updown { margin-inline-start: auto; flex: none; color: var(--muted-foreground, #64748b); } .popover { position: absolute; top: calc(100% + 4px); inset-inline-start: 0; width: max(100%, 340px); max-width: 92vw; z-index: 60; background: var(--popover, var(--background, #fff)); color: var(--foreground, #0f172a); border: 1px solid var(--border, #e2e8f0); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.18); overflow: hidden; } .search { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border, #e2e8f0); color: var(--muted-foreground, #64748b); } .search input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--foreground, #0f172a); font-size: 14px; font-family: inherit; outline: none; } .search .clear { border: 0; background: transparent; color: var(--muted-foreground, #64748b); cursor: pointer; display: inline-flex; padding: 2px; } .search .clear:hover { color: var(--foreground, #0f172a); } .results { max-height: 288px; overflow-y: auto; padding: 4px; } .node { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 8px; cursor: pointer; color: var(--foreground, #0f172a); } .node:hover { background: var(--accent, #f1f5f9); } .node.sel { background: color-mix(in srgb, var(--primary, #6366f1) 14%, transparent); color: var(--primary, #6366f1); } .node .exp { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 4px; color: var(--muted-foreground, #64748b); flex: none; } .node .exp:not(.spacer):hover { background: color-mix(in srgb, var(--foreground, #000) 8%, transparent); } .node-lbl { display: flex; align-items: baseline; gap: 8px; min-width: 0; } .node-lbl .lbl { font-size: 14px; white-space: nowrap; } .node-lbl .muted { font-size: 12px; color: var(--muted-foreground, #64748b); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .node .tick { margin-inline-start: auto; flex: none; display: inline-flex; color: var(--primary, #6366f1); } .empty-msg { padding: 14px; text-align: center; font-size: 13px; color: var(--muted-foreground, #64748b); } .toggle { display: inline-flex; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,.04); } .seg { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 14px; border: 0; cursor: pointer; background: var(--background, #fff); color: var(--muted-foreground, #64748b); font-size: 14px; font-family: inherit; font-weight: 500; } .seg + .seg { border-inline-start: 1px solid var(--border, #e2e8f0); } .seg.allow.on { background: #16a34a; color: #fff; } .seg.deny.on { background: var(--muted, #f1f5f9); color: var(--foreground, #0f172a); } .seg svg { flex: none; } .icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border: 0; border-radius: 8px; cursor: pointer; background: transparent; color: var(--muted-foreground, #64748b); } .icon-btn:hover { background: var(--accent, #f1f5f9); color: var(--foreground, #0f172a); } .icon-btn.add { background: var(--primary, #6366f1); color: #fff; } .icon-btn.add:hover { filter: brightness(1.05); } .icon-btn.del:hover { color: #dc2626; } `; customElements.define(TAG, AclAccessField);