doeixd / watch

Watch is a bare-bones web component alternative. It adds event listeners to selectors + a bit extra. 😉
MIT License
1 stars 0 forks source link

Todo #1

Open doeixd opened 3 weeks ago

doeixd commented 3 weeks ago

make ergonomics of events better make the cleanup function get all the event listeners return a cleanup fn from the on function remove unnecessary stuff lazy events? global on fn, with didClickOutsideof + cleanup. find / findAll simplify / return proxy with beefed up on fn add roles helper, or maybe just have the on fn take a optional selector? Or reuse /expose createOnFunction actually, just make el a proxy with a call that is equivalent to upgraded find clean up / github ci add note about performance add scoping / skipping capabilities. maybe make parent a selector, and only let it be scoped under that. remove unneeded stuff add trigger to things returned by find to trigger events. show example of redux / elm like using watch on an app el

have watch function return an object proxy that will be either the instance / array of instances, and all the custom events will be methods on the returned element, and then everything else will pass through to the element.

maybe separate it like:

watch.many('component  name', keyfn, setupfn)

actually, i dont like this. maybe this could all happen in the setup fn. and if you want this functionality, you can add it. just have watch return whatever is the result of the setup fn? if you want many, you'll have to bring some outside state yourself. This could be done with the wrapper fn? Happy I made that.

What happens if the parent of a watch doesn't exist? when that element is removed, should we clean up? or what happens if it does exist, but isnt in the document?

submodule called: helpers that assists in things like mapArrays, or providing hook-like api or integration with nano stores / etc? Conditional setup / on functions?

how does skipping work? maybe the watch fn can return a skip fn / map that will allow you to add elements / conditions that will be skipped. This will require a hook into the createObserver matching, which can be provided to watchOptions. Actually, I think it'd be better if that was passed as the first argument instead. match will call the setup fn. Just make it very clear that the matcher fn will only get the parent nodes, you will have to call the match on the added child elements yourself if you want that behavior. maybe if they return deep then the children of the matched elements will be added to the list of elements to check? or maybe just give them a check fn to add more to check? with the default check() being all the children of the currently assesed el.

watch((match, el, deep, check) => {
  if (el?.getAttribute('data-is-cool') == 'yes') {
    match(el)
    check(el.querySelectorAll('[data-is-cool]')) 

  }

} , () => {})

github pages docs.

doeixd commented 2 weeks ago

actually, maybe just have a submodlue called buffed where the beefed up versions are.

maybe only handle events + state in the default module.

maybe: on.cleanup(() => {}) on('custom:event', (e, data) => {}) on.attr('hello', () => {})

doeixd commented 2 weeks ago

submodule that handles mounting / unmounting when routes match.

integration with my new templating library

doeixd commented 6 days ago

Certainly! Here are a few examples of how the library might look in use after implementing some of the proposed ideas and todos:

  1. Basic Component with Enhanced Event Handling:
import { watch } from '@doeixd/watch';

watch('.counter', ({ on, state, el }) => {
  state.count = 0;

  on('click', '.increment', () => {
    state.count++;
    el.querySelector('.count').textContent = state.count;
  });

  on('click', '.decrement', () => {
    state.count--;
    el.querySelector('.count').textContent = state.count;
  });

  on.attr('data-reset', (newValue) => {
    if (newValue === 'true') {
      state.count = 0;
      el.querySelector('.count').textContent = state.count;
    }
  });

  on.cleanup(() => {
    console.log('Counter component cleaned up');
  });
});
  1. Route-based Component Mounting:
import { RouterWatch } from '@doeixd/watch/router';

const router = new RouterWatch();

router.on('/home', () => {
  watch('.home-component', ({ on, state }) => {
    // Home component logic
  });
});

router.on('/products/:id', ({ params }) => {
  watch('.product-details', ({ on, state, el }) => {
    state.productId = params.id;

    on('click', '.add-to-cart', () => {
      // Add to cart logic
    });

    el.trigger('product:loaded', { id: state.productId });
  });
});

router.start();
  1. Global Event Handling with Outside Click:
import { on } from '@doeixd/watch/global';

on.global('click', (event) => {
  console.log('Clicked somewhere in the document');
});

on.outsideOf('.modal', (event) => {
  // Close modal logic
});

const cleanup = on.key('Escape', () => {
  // Handle Escape key press
});

// Later, when no longer needed:
cleanup();
  1. Enhanced Element Selection with Chaining:
import { find } from '@doeixd/watch/buffed';

find('.user-profile')
  .on('click', '.edit-button', (e) => {
    // Handle edit button click
  })
  .on.attr('data-status', (newStatus) => {
    // React to status change
  })
  .style({ color: 'blue' })
  .trigger('profile:updated');
  1. State Management Pattern:
import { watch } from '@doeixd/watch';
import { createStore } from '@doeixd/watch/store';

const store = createStore({
  count: 0,
  users: [],
});

watch('#app', ({ on, el }) => {
  const render = () => {
    el.innerHTML = `
      <div>Count: ${store.state.count}</div>
      <button id="increment">Increment</button>
      <ul>
        ${store.state.users.map(user => `<li>${user.name}</li>`).join('')}
      </ul>
    `;
  };

  store.subscribe(render);
  render();

  on('click', '#increment', () => {
    store.dispatch({ type: 'INCREMENT' });
  });

  on('custom:addUser', (e, userData) => {
    store.dispatch({ type: 'ADD_USER', payload: userData });
  });
});
  1. Conditional Setup with Skipping:
import { watch } from '@doeixd/watch';

watch((match, el, deep, check) => {
  if (el.hasAttribute('data-interactive')) {
    match(el);
    check(el.querySelectorAll('[data-interactive]'));
  }
}, ({ on, state, el }) => {
  on('click', () => {
    el.classList.toggle('active');
  });

  on.lazy('mousemove', () => {
    // This event listener is only attached when the mouse first moves over the element
    console.log('Mouse moved!');
  });
});

These examples showcase various potential features:

These demonstrations illustrate how the library could evolve to provide a more powerful, flexible, and ergonomic API for DOM manipulation and event handling while maintaining its core simplicity.

doeixd commented 6 days ago

Certainly! I'll demonstrate how to create a more modular and reusable system using higher-order functions to wrap the setup function. This approach will allow for easy composition of behaviors like lazy hydration, error boundaries, conditional execution, and data fetching. Here's an expanded example:

import { watch, on, roles } from '@doeixd/watch';

// Higher-order function for lazy hydration
const lazyHydrate = (setupFn) => (args) => {
  const { el } = args;
  const observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) {
      setupFn(args);
      observer.disconnect();
    }
  });
  observer.observe(el);
};

// Higher-order function for error boundaries
const withErrorBoundary = (setupFn) => (args) => {
  try {
    return setupFn(args);
  } catch (error) {
    console.error('Error in component:', error);
    args.el.innerHTML = '<div class="error">An error occurred</div>';
  }
};

// Higher-order function for conditional execution
const whenCondition = (condition) => (setupFn) => (args) => {
  if (condition(args)) {
    return setupFn(args);
  }
};

// Higher-order function for route matching
const onRoute = (route) => (setupFn) => (args) => {
  const checkRoute = () => {
    if (window.location.pathname === route) {
      setupFn(args);
    }
  };
  window.addEventListener('popstate', checkRoute);
  checkRoute();
};

// Higher-order function for data fetching
const withData = (fetchFn) => (setupFn) => async (args) => {
  args.el.innerHTML = '<div class="loading">Loading...</div>';
  try {
    const data = await fetchFn();
    return setupFn({ ...args, data });
  } catch (error) {
    args.el.innerHTML = '<div class="error">Failed to load data</div>';
  }
};

// Example component setup functions
const counterSetup = ({ el, on, state }) => {
  state.count = 0;
  const display = el.querySelector('.count');

  on('.increment', 'click', () => {
    state.count++;
    display.textContent = state.count;
  });

  on('.decrement', 'click', () => {
    state.count--;
    display.textContent = state.count;
  });
};

const userProfileSetup = ({ el, data }) => {
  const { name, email, avatar } = data;
  el.innerHTML = `
    <img src="${avatar}" alt="${name}">
    <h2>${name}</h2>
    <p>${email}</p>
  `;
};

// Composing behaviors
const lazyCounter = lazyHydrate(withErrorBoundary(counterSetup));
const conditionalUserProfile = whenCondition(
  ({ el }) => el.dataset.userType === 'premium'
)(
  withErrorBoundary(
    withData(() => fetch('/api/user').then(r => r.json()))(userProfileSetup)
  )
);

// Using the composed components
watch('.lazy-counter', lazyCounter);
watch('.user-profile', conditionalUserProfile);

// Complex example: Blog post with comments
const blogPostSetup = ({ el, data, on }) => {
  const { post, comments } = data;
  const { main, section } = roles;

  const postContent = main('.post-content');
  const commentsSection = section('.comments-section');

  postContent.innerHTML = `
    <h1>${post.title}</h1>
    <p>${post.content}</p>
  `;

  const renderComments = () => {
    commentsSection.innerHTML = comments.map(comment => `
      <div class="comment">
        <h3>${comment.author}</h3>
        <p>${comment.content}</p>
      </div>
    `).join('');
  };

  renderComments();

  on('.add-comment', 'submit', async (e) => {
    e.preventDefault();
    const form = e.target;
    const newComment = {
      author: form.author.value,
      content: form.content.value
    };
    comments.push(newComment);
    renderComments();
    form.reset();
  });
};

const enhancedBlogPost = onRoute('/blog/:id')(
  lazyHydrate(
    withErrorBoundary(
      withData(() => {
        const id = window.location.pathname.split('/').pop();
        return Promise.all([
          fetch(`/api/post/${id}`).then(r => r.json()),
          fetch(`/api/comments/${id}`).then(r => r.json())
        ]).then(([post, comments]) => ({ post, comments }));
      })(blogPostSetup)
    )
  )
);

watch('.blog-post', enhancedBlogPost);

// Reusable modal component
const createModal = (content) => {
  const modalSetup = ({ el, on }) => {
    el.innerHTML = `
      <div class="modal-overlay">
        <div class="modal-content">
          ${content}
          <button class="close-modal">Close</button>
        </div>
      </div>
    `;

    on('.close-modal', 'click', () => {
      el.remove();
    });

    on('.modal-overlay', 'click', (e) => {
      if (e.target === el.querySelector('.modal-overlay')) {
        el.remove();
      }
    });
  };

  return withErrorBoundary(modalSetup);
};

// Usage of reusable modal
const showTermsModal = () => {
  const modalContent = '<h2>Terms and Conditions</h2><p>Lorem ipsum...</p>';
  const termsModal = createModal(modalContent);
  const modalElement = document.createElement('div');
  document.body.appendChild(modalElement);
  watch(modalElement, termsModal);
};

// Global navigation component with active route highlighting
const navigationSetup = ({ el, on }) => {
  const links = el.querySelectorAll('a');

  const updateActiveLink = () => {
    links.forEach(link => {
      if (link.getAttribute('href') === window.location.pathname) {
        link.classList.add('active');
      } else {
        link.classList.remove('active');
      }
    });
  };

  on('a', 'click', (e) => {
    e.preventDefault();
    const href = e.target.getAttribute('href');
    history.pushState(null, '', href);
    updateActiveLink();
  });

  window.addEventListener('popstate', updateActiveLink);
  updateActiveLink();
};

watch('nav', withErrorBoundary(navigationSetup));

This example demonstrates:

  1. Higher-order functions for common behaviors:

    • lazyHydrate for intersection-based lazy loading
    • withErrorBoundary for error handling
    • whenCondition for conditional execution
    • onRoute for route-based rendering
    • withData for data fetching and loading states
  2. Composition of these behaviors to create complex components:

    • A lazy-loaded counter
    • A conditional user profile with data fetching
    • A blog post component with comments, lazy loading, and route matching
  3. Reusable component creation:

    • A function to create modal components with consistent behavior
  4. Global navigation component with active route highlighting

  5. Use of roles helper for semantic element selection

  6. Async data fetching and error handling

  7. Event delegation and dynamic content updates

This approach allows for highly modular and reusable code. You can easily compose different behaviors and create complex components by combining these higher-order functions. This pattern promotes separation of concerns and makes it easier to add new functionality or modify existing behavior without changing the core component logic.

For example, if you wanted to add analytics tracking to any component, you could create a new higher-order function:

const withAnalytics = (eventName) => (setupFn) => (args) => {
  const result = setupFn(args);
  // Send analytics event
  analytics.track(eventName, { componentId: args.el.id });
  return result;
};

// Usage
const analyticsBlogPost = withAnalytics('blogPostViewed')(enhancedBlogPost);
watch('.blog-post', analyticsBlogPost);

This modular approach allows for great flexibility and scalability in building complex web applications while keeping the core library simple and focused on its primary responsibilities.