wavefunk-ui 0.1.5

Askama and htmx UI component base for Wave Funk Rust applications.
Documentation
document.addEventListener('click', event => {
  const dismiss = event.target.closest('[data-wf-dismiss="overlay"]');
  if (dismiss) {
    document.querySelectorAll('.wf-overlay.is-open, .wf-modal.is-open, .wf-drawer.is-open')
      .forEach(item => item.classList.remove('is-open'));
    return;
  }

  const copy = event.target.closest('[data-wf-copy]');
  if (copy) {
    event.preventDefault();
    wfCopyValue(copy);
    return;
  }

  const snippetTab = event.target.closest('[data-wf-snippet-tab]');
  if (snippetTab) {
    event.preventDefault();
    wfActivateSnippetTab(snippetTab);
    return;
  }

  const trigger = event.target.closest('[data-popover-toggle]');
  if (trigger) {
    const anchor = trigger.closest('.wf-pop-anchor');
    const popover = anchor && anchor.querySelector('.wf-popover');
    if (popover) {
      const wasOpen = popover.classList.contains('is-open');
      document.querySelectorAll('.wf-popover.is-open').forEach(item => item.classList.remove('is-open'));
      if (!wasOpen) popover.classList.add('is-open');
    }
    return;
  }

  if (!event.target.closest('.wf-popover')) {
    document.querySelectorAll('.wf-popover.is-open').forEach(item => item.classList.remove('is-open'));
  }
});

function wfCopyText(text) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    return navigator.clipboard.writeText(text).catch(() => wfFallbackCopyText(text));
  }

  return wfFallbackCopyText(text);
}

function wfFallbackCopyText(text) {
  const area = document.createElement('textarea');
  area.value = text;
  area.setAttribute('readonly', '');
  area.style.position = 'fixed';
  area.style.opacity = '0';
  document.body.appendChild(area);
  area.select();
  document.execCommand('copy');
  area.remove();
  return Promise.resolve();
}

function wfCopyValue(trigger) {
  const explicit = trigger.getAttribute('data-wf-copy-value');
  if (explicit) {
    wfCopyText(explicit.trim()).then(() => {
      trigger.setAttribute('data-wf-copy-state', 'ok');
      trigger.dispatchEvent(new CustomEvent('wfCopy', { bubbles: true, detail: { text: explicit } }));
    }).catch(() => {
      trigger.setAttribute('data-wf-copy-state', 'error');
    });
    return;
  }

  const selector = trigger.getAttribute('data-wf-copy');
  const source = selector ? document.querySelector(selector) : null;
  const text = source && 'value' in source ? source.value : source ? source.textContent : '';
  if (!text) return;

  wfCopyText(text.trim()).then(() => {
    trigger.setAttribute('data-wf-copy-state', 'ok');
    trigger.dispatchEvent(new CustomEvent('wfCopy', { bubbles: true, detail: { text } }));
  }).catch(() => {
    trigger.setAttribute('data-wf-copy-state', 'error');
  });
}

function wfActivateSnippetTab(trigger) {
  const root = trigger.closest('.wf-snippet-tabs');
  const selector = trigger.getAttribute('data-wf-snippet-tab');
  const panel = root && selector ? root.querySelector(selector) : null;
  if (!root || !panel) return;

  root.querySelectorAll('[data-wf-snippet-tab]').forEach(tab => {
    const selected = tab === trigger;
    tab.classList.toggle('is-active', selected);
    tab.setAttribute('aria-selected', selected ? 'true' : 'false');
    tab.tabIndex = selected ? 0 : -1;
  });

  root.querySelectorAll('.wf-snippet-panel').forEach(item => {
    const selected = item === panel;
    item.classList.toggle('is-active', selected);
    item.hidden = !selected;
  });
}

function wfPushToast(kind, msg) {
  const host = document.getElementById('toast-host');
  if (!host || !msg) return;

  const toast = document.createElement('div');
  toast.className = 'wf-toast' + (kind ? ' ' + kind : '');

  const dot = document.createElement('span');
  dot.className = 'wf-dot';

  const label = document.createElement('span');
  label.textContent = msg;

  toast.appendChild(dot);
  toast.appendChild(label);
  host.appendChild(toast);

  setTimeout(() => {
    toast.style.opacity = '0';
    toast.style.transition = 'opacity 200ms';
    setTimeout(() => toast.remove(), 220);
  }, 2600);
}

document.addEventListener('wfToast', event => {
  const { kind = '', msg = '' } = event.detail || {};
  wfPushToast(kind, msg);
}, true);

document.addEventListener('wfEcho', event => {
  const { kind = '', msg = '' } = event.detail || {};
  document.querySelectorAll('[data-wf-echo]').forEach(target => {
    target.textContent = msg;
    target.dataset.kind = kind;
    target.classList.remove('is-visible', 'is-ok', 'is-warn', 'is-err', 'is-info');
    if (msg) target.classList.add('is-visible');
    if (kind) target.classList.add('is-' + kind);
  });
}, true);

function wfFormatTimestamps() {
  document.querySelectorAll('[data-ts]').forEach(element => {
    const iso = element.getAttribute('data-ts');
    if (!iso) return;

    const diff = Date.now() - new Date(iso).getTime();
    const secs = Math.floor(diff / 1000);
    let text;

    if (secs < 60) text = 'just now';
    else if (secs < 3600) text = Math.floor(secs / 60) + 'm ago';
    else if (secs < 86400) text = Math.floor(secs / 3600) + 'h ago';
    else if (secs < 604800) text = Math.floor(secs / 86400) + 'd ago';
    else text = new Date(iso).toLocaleDateString();

    element.textContent = text;
  });
}

function wfUpdateUploadZone(zone, files) {
  const list = Array.from(files || []);
  const title = zone.querySelector('[data-upload-title], .wf-dropzone-title');
  const hint = zone.querySelector('[data-upload-hint], .wf-dropzone-hint');
  const names = list.map(file => file.name).join(', ');

  zone.classList.remove('is-dragover');
  if (names) zone.setAttribute('data-upload-files', names);
  if (title && names) title.textContent = list.length === 1 ? list[0].name : list.length + ' files selected';
  if (hint && names) hint.textContent = names;
}

function wfInitUploadZones(root = document) {
  root.querySelectorAll('[data-upload-zone]').forEach(zone => {
    if (zone.dataset.wfUploadReady) return;
    zone.dataset.wfUploadReady = 'true';

    const inputSelector = zone.getAttribute('data-upload-input');
    const input = inputSelector ? document.querySelector(inputSelector) : zone.querySelector('input[type="file"]');

    zone.addEventListener('dragover', event => {
      event.preventDefault();
      zone.classList.add('is-dragover');
    });
    zone.addEventListener('dragleave', () => zone.classList.remove('is-dragover'));
    zone.addEventListener('drop', event => {
      event.preventDefault();
      if (input && event.dataTransfer && event.dataTransfer.files.length) {
        input.files = event.dataTransfer.files;
        input.dispatchEvent(new Event('change', { bubbles: true }));
      }
      wfUpdateUploadZone(zone, event.dataTransfer && event.dataTransfer.files);
    });
    if (input) {
      input.addEventListener('change', () => wfUpdateUploadZone(zone, input.files));
    }
  });
}

document.addEventListener('submit', event => {
  const form = event.target.closest('form[data-wf-submit-spinner]');
  if (!form) return;

  form.classList.add('is-submitting');
  const selector = form.getAttribute('data-wf-submit-spinner');
  const spinner = selector ? document.querySelector(selector) : form.querySelector('[data-wf-submit-spinner-target]');
  if (spinner) {
    spinner.hidden = false;
    spinner.classList.add('is-visible');
  }
});

function wfInitDirtyGuards(root = document) {
  root.querySelectorAll('form[data-wf-dirty-guard]').forEach(form => {
    if (form.dataset.wfDirtyReady) return;
    form.dataset.wfDirtyReady = 'true';
    form.addEventListener('input', () => { form.dataset.wfDirty = 'true'; });
    form.addEventListener('change', () => { form.dataset.wfDirty = 'true'; });
    form.addEventListener('submit', () => { form.dataset.wfDirty = 'false'; });
  });
}

window.addEventListener('beforeunload', event => {
  const dirty = document.querySelector('form[data-wf-dirty-guard][data-wf-dirty="true"]');
  if (!dirty) return;
  event.preventDefault();
  event.returnValue = '';
});

function wfUpdateActiveNav() {
  document.querySelectorAll('[data-wf-active-nav]').forEach(nav => {
    nav.querySelectorAll('a[href]').forEach(link => {
      const href = new URL(link.getAttribute('href'), window.location.href);
      link.classList.toggle('is-active', href.pathname === window.location.pathname);
    });
  });
}

function wfRefreshPageChrome(root = document) {
  const title = root.querySelector('#page-title');
  if (title && title.textContent.trim()) document.title = title.textContent.trim();

  root.querySelectorAll('[data-wf-echo-message]').forEach(source => {
    document.dispatchEvent(new CustomEvent('wfEcho', {
      bubbles: true,
      detail: {
        kind: source.getAttribute('data-wf-echo-kind') || '',
        msg: source.getAttribute('data-wf-echo-message') || source.textContent.trim()
      }
    }));
  });
  wfUpdateActiveNav();
}

function wfInit(root = document) {
  wfFormatTimestamps();
  wfInitUploadZones(root);
  wfInitDirtyGuards(root);
  wfRefreshPageChrome(root);
}

document.addEventListener('DOMContentLoaded', () => wfInit(document));
document.addEventListener('htmx:afterSwap', event => wfInit(event.target || document));
document.addEventListener('htmx:afterSettle', wfFormatTimestamps);