← back to Commercialrealestate
public/corner-nav.js
348 lines
/* corner-nav.js — shared two-hamburger navigation for every CRCP page.
Drop-in: <script src="/corner-nav.js" defer></script>
• TOP-LEFT ☰ → the whole site, grouped into thoughtful, collapsed accordion sections.
• TOP-RIGHT ▤ → "On this page" jump links (from the page's own headings) + the full site menu.
All sections start COLLAPSED; the group holding the current page is marked (gold dot) and its
link highlighted, so you always know where you are without the menu unfolding on its own.
Self-contained (injects its own CSS, reads nothing from the host page), idempotent, a11y-friendly. */
(function () {
if (window.__cornerNav) return; window.__cornerNav = true;
var CUR = (location.pathname.split('/').pop() || 'index.html').toLowerCase();
// ── site map — every page, grouped so each group is one collapsible section ──────────
var GROUPS = [
{ title: 'Properties & Listings', items: [
['index.html', '🏙️ CRE Explorer'], ['mls.html', '📋 Listings'], ['just-listed.html', '🆕 Just Listed'],
['sales.html', '💰 Sales & Comps'], ['condos.html', '🏢 Condos'], ['rent-rolls.html', '📑 Rent Rolls'],
['direct-listings.html', '🔎 Direct Listings'], ['map.html', '🗺️ Map'] ] },
{ title: 'Deals & Flow', items: [
['deals.html', '◆ Deal Intelligence'], ['deals-flow.html', '🔀 Deal Flow'] ] },
{ title: 'Brokers & Agents', items: [
['crcp.html', '🎛️ Control Panel'], ['broker-grid.html', '▦ Broker Grid'],
['brokers.html', '🕸️ Broker Map'], ['loan-officers.html', '🏦 Loan Officers'],
['contractors.html', '🔨 Contractors'] ] },
{ title: 'Licensing & Social', items: [
['licensed.html', '📇 Licensed Directory'], ['licensed-agents.html', '🧾 Licensed Agents'],
['linkedin.html', '💼 LinkedIn'] ] },
{ title: 'Lending & Capital', items: [
['lending.html', '🏛️ Lending'], ['fha-loans.html', '◆ FHA Loans'], ['fha-leads.html', '◆ FHA Leads'],
['lenders.html', '🏦 Lenders'], ['credit-unions.html', '🏦 Credit Unions'],
['sba-lenders.html', '🏦 SBA Lenders'], ['mortgage-market.html', '📈 Mortgage Market'] ] },
{ title: 'Data & Admin', items: [
['graphics.html', '📊 Charts & Graphics'], ['gov-agents.html', '🏛️ Gov Agents'], ['admin.html', '⚙️ Admin'] ] }
];
// ── styles (scoped under .cnav*, safe over any dark page) ─────────────────────────────
var css = '' +
/* TK-10883 — GHOST/outline pill (light-mode default): transparent fill + slate hairline so the
menu control no longer out-contrasts the wordmark and grabs the eye-line first. */
'.cnav-burger{position:fixed;top:10px;z-index:9998;display:flex;align-items:center;gap:8px;' +
'background:transparent;color:#334155;border:1.5px solid #cbd5e1;' +
/* padding-x (16px) must exceed border-radius (10px) so the label clears the rounded corner */
'border-radius:10px;padding:8px 16px;font-size:15px;line-height:1;cursor:pointer;' +
'backdrop-filter:blur(3px);}' +
/* dark theme — keep the ghost pill legible on the dark top-bar (transparent fill, lighter hairline) */
'html[data-theme="dark"] .cnav-burger{color:#cbd5e1;border-color:rgba(203,213,225,.45);}' +
'.cnav-burger:hover{border-color:#C8A24B;color:#C8A24B;}' +
'.cnav-burger.left{left:10px;} .cnav-burger.right{right:10px;}' +
'.cnav-burger .lbl{font-size:12px;letter-spacing:.3px;} @media(max-width:640px){.cnav-burger .lbl{display:none;}}' +
/* Reserve corner space in CSS (timing-independent — holds even if reserve() JS never runs, or */
/* runs before/after a page re-render). The header title can NEVER underlap the fixed burgers. */
/* --cnav-rsv-l / -r are refined by reserve() from the burgers' real measured width; these are */
/* the safe fallbacks (burger left:10 + ~120 wide label pill + 16 gap, and right ~140 + gap). */
'body.cnav-on header{padding-left:var(--cnav-rsv-l,142px)!important;padding-right:var(--cnav-rsv-r,158px)!important;}' +
'@media(max-width:640px){body.cnav-on header{padding-left:var(--cnav-rsv-l,52px)!important;padding-right:var(--cnav-rsv-r,52px)!important;}}' +
'.cnav-ov{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:9998;opacity:0;pointer-events:none;transition:opacity .18s;}' +
'.cnav-ov.on{opacity:1;pointer-events:auto;}' +
'.cnav-drawer{position:fixed;top:0;bottom:0;width:var(--cnav-w,min(330px,88vw));background:#11161f;color:#e6e8ec;z-index:9999;' +
'box-shadow:0 0 40px rgba(0,0,0,.5);display:flex;flex-direction:column;transition:transform .2s ease;' +
'font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;}' +
'.cnav-drawer.left{left:0;border-right:1px solid #232a38;transform:translateX(-104%);}' +
'.cnav-drawer.right{right:0;border-left:1px solid #232a38;transform:translateX(104%);}' +
'.cnav-drawer.on{transform:translateX(0);}' +
/* drag the inner vertical edge to widen the drawer (right→left edge, left→right edge); width persists */
'.cnav-rsz{position:absolute;top:0;bottom:0;width:9px;cursor:ew-resize;z-index:3;}' +
'.cnav-drawer.right .cnav-rsz{left:0;} .cnav-drawer.left .cnav-rsz{right:0;}' +
'.cnav-rsz:hover,.cnav-rsz.on{background:rgba(200,162,75,.55);}' +
'.cnav-hd{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid #232a38;' +
'font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:#C8A24B;}' +
'.cnav-hd .sub{display:block;color:#8A93A2;font-size:10.5px;letter-spacing:.3px;text-transform:none;margin-top:2px;}' +
'.cnav-hd button{background:none;border:0;color:#8A93A2;font-size:20px;cursor:pointer;line-height:1;}' +
'.cnav-hd button:hover{color:#e6e8ec;}' +
'.cnav-body{overflow-y:auto;padding:6px 0 24px;flex:1;}' +
'.cnav-sec{border-bottom:1px solid #1b212c;}' +
'.cnav-sec>h4{margin:0;padding:12px 16px;font-size:12.5px;font-weight:700;letter-spacing:.3px;color:#c7cdd6;' +
'cursor:pointer;user-select:none;display:flex;align-items:center;justify-content:space-between;gap:8px;}' +
'.cnav-sec>h4:hover{color:#fff;background:rgba(255,255,255,.02);}' +
'.cnav-sec>h4 .lt{display:flex;align-items:center;gap:8px;min-width:0;}' +
'.cnav-sec>h4 .rt{display:flex;align-items:center;gap:9px;color:#8A93A2;}' +
'.cnav-sec>h4 .cnt{font-size:11px;font-variant-numeric:tabular-nums;background:#1b212c;border:1px solid #232a38;border-radius:9px;padding:0 7px;line-height:17px;}' +
'.cnav-sec>h4 .car{color:#C8A24B;transition:transform .15s;font-size:11px;}' +
'.cnav-sec.collapsed>h4 .car{transform:rotate(-90deg);}' +
'.cnav-sec.collapsed .cnav-list{display:none;}' +
'.cnav-sec.cur-sec>h4{color:#fff;} .cnav-sec.cur-sec .cnt{border-color:#C8A24B;color:#C8A24B;}' +
'.cnav-sec .curdot{width:7px;height:7px;border-radius:50%;background:#C8A24B;box-shadow:0 0 6px rgba(200,162,75,.8);flex:0 0 7px;}' +
'.cnav-list{padding:2px 0 8px;}' +
'.cnav-list a{display:block;padding:8px 16px 8px 22px;color:#c7cdd6;text-decoration:none;font-size:13.5px;border-left:2px solid transparent;}' +
'.cnav-list a:hover{background:rgba(200,162,75,.08);color:#fff;}' +
'.cnav-list a.cur{color:#C8A24B;border-left-color:#C8A24B;background:rgba(200,162,75,.10);font-weight:600;}' +
'.cnav-empty{padding:14px 16px;color:#8A93A2;font-size:13px;}' +
'.cnav-search{position:relative;margin:10px 14px 6px;}' +
'.cnav-search input{width:100%;box-sizing:border-box;background:#0c1017;border:1px solid #232a38;border-radius:8px;padding:8px 10px 8px 30px;color:#e6e8ec;font-size:13px;}' +
'.cnav-search input:focus{outline:0;border-color:#C8A24B;}' +
'.cnav-search .ic{position:absolute;left:10px;top:8px;color:#8A93A2;font-size:12px;pointer-events:none;}' +
'.cnav-sec.hide{display:none;} .cnav-list a.hide{display:none;}' +
'.cnav-noresult{padding:12px 16px;color:#8A93A2;font-size:13px;display:none;}';
var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
// ── overlay ───────────────────────────────────────────────────────────────────────
var ov = el('div', 'cnav-ov'); document.body.appendChild(ov);
var lastOpener = null;
function closeAll() {
var wasOpen = !!document.querySelector('.cnav-drawer.on');
document.querySelectorAll('.cnav-drawer.on').forEach(function (d) { d.classList.remove('on'); });
ov.classList.remove('on');
document.querySelectorAll('.cnav-burger').forEach(function (b) { b.setAttribute('aria-expanded', 'false'); });
if (wasOpen && lastOpener) { try { lastOpener.focus(); } catch (e) {} }
lastOpener = null;
}
ov.addEventListener('click', closeAll);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(); });
// ── a collapsible accordion section (always starts COLLAPSED) ────────────────────────
function section(title, opts) {
opts = opts || {};
var sec = el('div', 'cnav-sec collapsed' + (opts.current ? ' cur-sec' : ''));
var dot = opts.current ? '<span class="curdot" title="you are here"></span>' : '';
var cnt = (opts.count != null) ? '<span class="cnt">' + opts.count + '</span>' : '';
var expanded = !opts.current; // starts collapsed unless it's the current section
var hId = 'cnav-h-' + Math.random().toString(36).slice(2, 8);
var h = el('h4', null, '<span class="lt">' + dot + '<span>' + title + '</span></span>' +
'<span class="rt">' + cnt + '<span class="car">▾</span></span>');
h.setAttribute('role', 'button');
h.setAttribute('tabindex', '0');
h.setAttribute('aria-expanded', expanded ? 'true' : 'false');
h.id = hId;
var list = el('div', 'cnav-list');
list.setAttribute('role', 'list');
function toggleSection() {
sec.classList.toggle('collapsed');
var isExpanded = !sec.classList.contains('collapsed');
h.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
}
h.addEventListener('click', toggleSection);
h.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSection(); }
});
sec.appendChild(h); sec.appendChild(list); sec._list = list;
return sec;
}
// Return all keyboard-focusable elements inside a container (tab order).
function focusables(container) {
return Array.prototype.slice.call(
container.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),[tabindex="0"]')
).filter(function (el) { return el.offsetParent !== null || el === document.activeElement; });
}
function drawer(side, title, sub) {
var d = el('div', 'cnav-drawer ' + side);
d.setAttribute('role', 'dialog');
d.setAttribute('aria-modal', 'true');
d.setAttribute('aria-label', title);
var hd = el('div', 'cnav-hd', '<span>' + title + (sub ? '<span class="sub">' + sub + '</span>' : '') + '</span>');
var x = el('button', null, '✕'); x.setAttribute('aria-label', 'Close'); x.addEventListener('click', closeAll);
hd.appendChild(x);
var body = el('div', 'cnav-body');
d.appendChild(hd); d.appendChild(body); document.body.appendChild(d); d._body = body;
// ── resizable: drag the inner vertical edge to widen the drawer; width persists per side ──
var rsz = el('div', 'cnav-rsz'); rsz.setAttribute('title', 'Drag to widen · double-click to reset'); d.appendChild(rsz);
var RKEY = 'cnav_w_' + side;
var savedW = parseInt(localStorage.getItem(RKEY));
if (!isNaN(savedW) && savedW > 0) d.style.setProperty('--cnav-w', savedW + 'px');
(function () {
var drag = false, startX = 0, startW = 0;
var curW = function () { return d.getBoundingClientRect().width; };
rsz.addEventListener('mousedown', function (e) {
drag = true; startX = e.clientX; startW = curW();
document.body.style.userSelect = 'none'; rsz.classList.add('on'); e.preventDefault();
});
window.addEventListener('mousemove', function (e) {
if (!drag) return;
var delta = side === 'right' ? (startX - e.clientX) : (e.clientX - startX); // right drawer widens by dragging left
var w = Math.max(260, Math.min(Math.round(window.innerWidth * 0.95), Math.round(startW + delta)));
d.style.setProperty('--cnav-w', w + 'px');
});
window.addEventListener('mouseup', function () {
if (!drag) return; drag = false; document.body.style.userSelect = ''; rsz.classList.remove('on');
try { localStorage.setItem(RKEY, String(Math.round(curW()))); } catch (e) {}
});
rsz.addEventListener('dblclick', function () { d.style.removeProperty('--cnav-w'); try { localStorage.removeItem(RKEY); } catch (e) {} });
})();
// Focus trap: keep Tab/Shift+Tab inside the open drawer; arrow keys navigate links.
d.addEventListener('keydown', function (e) {
if (!d.classList.contains('on')) return;
var focs = focusables(d);
if (!focs.length) return;
var idx = focs.indexOf(document.activeElement);
if (e.key === 'Tab') {
if (e.shiftKey) {
e.preventDefault();
focs[idx <= 0 ? focs.length - 1 : idx - 1].focus();
} else {
e.preventDefault();
focs[idx >= focs.length - 1 ? 0 : idx + 1].focus();
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
var links = focs.filter(function (f) { return f.tagName === 'A' || (f.tagName === 'BUTTON' && f !== x); });
var li = links.indexOf(document.activeElement);
if (links.length) links[li >= links.length - 1 ? 0 : li + 1].focus();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
var links2 = focs.filter(function (f) { return f.tagName === 'A' || (f.tagName === 'BUTTON' && f !== x); });
var li2 = links2.indexOf(document.activeElement);
if (links2.length) links2[li2 <= 0 ? links2.length - 1 : li2 - 1].focus();
}
});
return d;
}
function navSection(body, title, opts) {
var sec = section(title, opts);
(opts && opts.groups ? opts.groups : GROUPS).forEach(function (g) {
g.items.forEach(function (it) {
var a = el('a', it[0].toLowerCase() === CUR ? 'cur' : '', it[1]);
a.href = '/' + it[0]; a.addEventListener('click', closeAll); sec._list.appendChild(a);
});
});
body.appendChild(sec); return sec;
}
// LEFT — full site navigation, one collapsed section per group
var left = drawer('left', 'Navigate', 'the whole site');
GROUPS.forEach(function (g) {
var hasCur = g.items.some(function (it) { return it[0].toLowerCase() === CUR; });
var sec = section(g.title, { count: g.items.length, current: hasCur });
g.items.forEach(function (it) {
var a = el('a', it[0].toLowerCase() === CUR ? 'cur' : '', it[1]);
a.href = '/' + it[0]; a.addEventListener('click', closeAll); sec._list.appendChild(a);
});
left._body.appendChild(sec);
});
// type-to-filter across all site links
var searchWrap = el('div', 'cnav-search', '<span class="ic">🔎</span>');
var searchIn = document.createElement('input');
searchIn.type = 'text'; searchIn.placeholder = 'Filter pages…'; searchIn.setAttribute('aria-label', 'Filter pages');
searchWrap.appendChild(searchIn);
left._body.insertBefore(searchWrap, left._body.firstChild);
var noRes = el('div', 'cnav-noresult', 'No pages match.'); left._body.appendChild(noRes);
function filterLeft(q) {
q = (q || '').trim().toLowerCase(); var anyVisible = false;
left._body.querySelectorAll('.cnav-sec').forEach(function (sec) {
var shown = 0;
sec.querySelectorAll('.cnav-list a').forEach(function (a) {
var m = !q || a.textContent.toLowerCase().indexOf(q) >= 0; a.classList.toggle('hide', !m); if (m) shown++;
});
sec.classList.toggle('hide', !!q && shown === 0);
sec.classList.toggle('collapsed', !q); // expand matches while filtering, re-collapse when cleared
if (shown > 0) anyVisible = true;
});
noRes.style.display = (q && !anyVisible) ? 'block' : 'none';
}
searchIn.addEventListener('input', function () { filterLeft(searchIn.value); });
// RIGHT — "On this page" jump links + the full site menu (all collapsed)
var right = drawer('right', 'On this page', 'jump to a section');
function slug(s) { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 40) || 'sec'; }
function buildOnThisPage() {
right._body.innerHTML = '';
var jump = section('Sections on this page', { current: true });
var nodes = Array.prototype.slice.call(document.querySelectorAll('main h2, section > h2, h2.sec, .panel > h3, section > h3'));
var seen = {}, count = 0;
nodes.forEach(function (n) {
if (n.closest('.cnav-drawer')) return;
if (!n.offsetParent && n.offsetHeight === 0) return;
var txt = (n.textContent || '').replace(/\s+/g, ' ').trim();
txt = txt.replace(/^[▸▾▿▶◀⌄˅▼>\s]+/, '');
if (!txt) return; txt = txt.slice(0, 46);
if (!n.id) { var s = slug(txt), u = s, i = 2; while (document.getElementById(u)) { u = s + '-' + (i++); } n.id = u; }
if (seen[n.id]) return; seen[n.id] = 1;
var a = el('a', '', txt); a.href = '#' + n.id; a.addEventListener('click', closeAll);
jump._list.appendChild(a); count++;
});
if (count) { jump.querySelector('.rt').insertAdjacentHTML('afterbegin', '<span class="cnt">' + count + '</span>'); right._body.appendChild(jump); }
else { right._body.appendChild(el('div', 'cnav-empty', 'No jump-to sections on this page. Use “Go to another page” below or the ☰ menu (top-left).')); }
navSection(right._body, 'Go to another page', { count: GROUPS.reduce(function (n, g) { return n + g.items.length; }, 0) });
}
// ── the two hamburgers ──────────────────────────────────────────────────────────────
function burger(side, glyph, label, onOpen) {
var b = el('button', 'cnav-burger ' + side, glyph + ' <span class="lbl">' + label + '</span>');
b.setAttribute('aria-label', label); b.setAttribute('aria-expanded', 'false');
var target = side === 'left' ? left : right;
b.addEventListener('click', function (e) {
e.stopPropagation();
var open = target.classList.contains('on'); closeAll();
if (!open) {
if (onOpen) onOpen();
target.classList.add('on'); ov.classList.add('on'); b.setAttribute('aria-expanded', 'true'); lastOpener = b;
setTimeout(function () {
if (side === 'left') { if (searchIn) searchIn.focus(); var cur = target.querySelector('a.cur'); if (cur) cur.scrollIntoView({ block: 'nearest' }); }
else { var f = target.querySelector('.cnav-list a'); if (f) f.focus(); }
}, 60);
}
});
document.body.appendChild(b); return b;
}
burger('left', '☰', 'Menu', null);
burger('right', '▤', 'Sections', buildOnThisPage);
// Reserve corner space on the top bar so its title / remaining controls clear the burgers.
// Two layers: (1) the body.cnav-on CSS rule above reserves a safe fallback the instant this
// script runs (timing-independent — no dependence on when reserve() fires); (2) reserve() then
// refines the reservation from the burgers' REAL measured geometry so the gap is comfortable and
// scales with label width / font zoom instead of relying on a magic 104px that could go too tight.
function reserve() {
var bar = document.querySelector('header'); if (!bar) return;
document.body.classList.add('cnav-on'); // engage the CSS reservation rule
var GAP = 22; // comfortable clearance between burger + title
var lb = document.querySelector('.cnav-burger.left');
var rb = document.querySelector('.cnav-burger.right');
// measured need = burger's outer edge + gap (falls back to the CSS default if not yet laid out)
var needL = lb ? Math.ceil(lb.getBoundingClientRect().right + GAP) : 0;
var needR = rb ? Math.ceil(window.innerWidth - rb.getBoundingClientRect().left + GAP) : 0;
if (needL > 0) document.documentElement.style.setProperty('--cnav-rsv-l', needL + 'px');
if (needR > 0) document.documentElement.style.setProperty('--cnav-rsv-r', needR + 'px');
}
// Fold old horizontal top-tabs into the hamburgers: hide inter-page nav LINKS in the header
// (every destination lives in the ☰ menu) but LEAVE page controls (sliders, buttons, selects).
function hideTopTabs() {
document.querySelectorAll('header a[href], .topnav a[href], nav.nav a[href], .nav a[href]').forEach(function (a) {
if (a.closest('.cnav-drawer')) return;
var href = a.getAttribute('href') || '';
if (/\.html([?#].*)?$/i.test(href) || href === '/' || href.charAt(0) === '#') a.style.display = 'none';
});
['.topnav', 'nav.nav', 'header .nav'].forEach(function (sel) {
document.querySelectorAll(sel).forEach(function (w) {
var hasVisibleControl = Array.prototype.some.call(w.children, function (c) {
if (c.tagName === 'A') return c.style.display !== 'none';
return c.offsetParent !== null || /INPUT|BUTTON|SELECT|SPAN|LABEL/.test(c.tagName);
});
if (!hasVisibleControl) w.style.display = 'none';
});
});
}
function apply() { reserve(); hideTopTabs(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply); else apply();
// Re-measure once fonts/layout settle (label pill width can change after webfont load) and on
// resize / orientation change, so the reservation always tracks the burgers' real width.
window.addEventListener('load', reserve);
var rTo; window.addEventListener('resize', function () { clearTimeout(rTo); rTo = setTimeout(reserve, 120); });
})();