Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTML Custom Elements: Ask Me Anything! #2

Open
shawnbot opened this issue Apr 20, 2016 · 16 comments
Open

HTML Custom Elements: Ask Me Anything! #2

shawnbot opened this issue Apr 20, 2016 · 16 comments

Comments

@shawnbot
Copy link
Owner

I've been using HTML custom elements for over a year now, and I kinda think they're the bees' knees. What would you like to know about them, and how can I help you put them to work?

@jeremiak
Copy link

jeremiak commented Apr 21, 2016

I am starting to share your enthusiasm for web components, but something that I get tripped up on is communication between components.

Two more concrete questions:

  1. Do you find you generally have some sort of orchestration object that sends messages to child components?
  2. When components talk to each other do you generally change HTML attributes, use JavaScript events, use JavaScript methods on the components, or something else?

@shawnbot
Copy link
Owner Author

@jeremiak: I think the best way to do it is with DOM events. You can dispatch CustomEvent instances from your element like so:

this.dispatchEvent(new CustomEvent('change'));

Custom events can bubble and be made cancelable just like Event instances, and you can attach arbitrary data via the detail option:

var event = new CustomEvent('change', {
  cancelable: true,
  detail: {value: this.value}
});
this.dispatchEvent(event);
// if a listener calls event.preventDefault()...
if (event.defaultPrevented) {
  // do something to roll back your change
}

So your components can dispatch their own events or cause other elements (either their children or some other element in the document, e.g. if your component has an aria-controls attribute that references an id) to dispatch events that other components might listen for.

Event delegation is a good pattern to use with custom elements that have arbitrary content, too: you can listen for events that bubble at the component level and filter by selector (e.target.matches('a')), and trust that even if some other JS modifies the component's children, your event listeners will still work.

@msecret
Copy link

msecret commented Apr 26, 2016

So the primary negative I hear about web components is they generally don't work without JavaScript. Is this incorrect information? What would it take to make a web component work without JavaScript? Would it be comparable to React where you render your components server-side?

@shawnbot
Copy link
Owner Author

shawnbot commented Apr 26, 2016

That's definitely correct, @msecret. Custom elements should offer progressive enhancement. And I think that for most uses I've had so far, that makes perfect sense because pretty much any dynamic and/or interactive element on a web page is going to require JavaScript at some point anyway. For example, an accordion element could default to open without JavaScript:

<usa-accordion expanded="false">
  <h1><button aria-controls="content">Check out this cool content</button></h1>
  <section id="content">
    Cool content here!
  </section>
</usa-accordion>

But the custom element could then collapse the content unless it has expanded="true":

xtag.register('usa-accordion', {
  lifecycle: {
    inserted: function() {
      this.expanded = this.getAttribute('expanded') === 'true';
    }
  },
  events: {
    'click:delegate([aria-controls])': function(e) {
      this.toggle();
    }
  },
  accessors: {
    expanded: {
      attribute: 'expanded',
      boolean: true,
      set: function(expanded) {
        var button = this.querySelector('[aria-controls]');
        button.setAttribute('aria-expanded', expanded);
        var id = button.getAttribute('aria-controls');
        document.getElementById(id).setAttribute('aria-hidden', !expanded);
      }
    }
  },
  methods: {
    toggle: function() {
      this.expanded = !this.expanded;
    }
  }
});

@joshbruce
Copy link

joshbruce commented Aug 27, 2016

@shawnbot, @msecret - This feels like XHTML days a bit; extending the HTML specification via XML. For styling purposes, I remember browsers being able to handle this pretty well. For assistive technologies, of course, I don't think it worked so well. And, it sounds like the "best-of-both-worlds" option can be found using the is attribute on a regular element following the HTML spec.

How fair is that assessment (trying to map my understanding)?

@joshbruce
Copy link

joshbruce commented Aug 27, 2016

@shawnbot - How do native HTML+user-agent components fall into this construct? Specifically with the idea of progressive enhancement.

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details - accordion capabilities, for example.

@lukasoppermann
Copy link

How would I add markup when extending an element? So if I want to extend the input element, but would need to add some boxes around, how would I do this?

@shawnbot
Copy link
Owner Author

@lukasoppermann First of all, I would treat the extra markup as purely progressive enhancement: if your component doesn't load (for whatever reason)—or even just until it does (when the network or your server is slow to load the JS, etc.)—then your input should appear and function normally.

If your component needs to wrap itself in another element, though, it could do something like this (in ES2015, v1 API):

class FancyInput extends HTMLInputElement {
  connectedCallback() {
    var container = document.createElement('div');
    container.className = 'fancy-input-container';
    this.parentNode.insertBefore(container, this);
    container.appendChild(this);
  }
}

customElements.define('fancy-input', FancyInput, {extends: 'input'});

If you're using the v0 API, then connectedCallback() becomes attachedCallback(), and the last line becomes:

// "extends" should be a static getter
Object.defineProperty(FancyInput, 'extends', {
  get() { return 'input'; }
});

document.registerElement('fancy-input', FancyInput);

The one caveat here is that I'm not 100% sure whether the connectedCallback() gets called when an element is moved within the document, as opposed to being removed then added in two discrete steps (which would call disconnectedCallback() then connectedCallback() in succession).

@lukasoppermann
Copy link

lukasoppermann commented Oct 12, 2016

Sadly this results in a Uncaught RangeError: Maximum call stack size exceeded. I think it is because then connectedCallback is called when doing container.appendChild(this);. Any ideas?

Additionally, would this work with shadow dom as well? No right, an input within the shadow dom will not be seen as normal. So I cannot add stuff into the shadow dom when extending the element?

@lukasoppermann
Copy link

Okay, so I got it working by setting a this.initialized attribute to false in the constructor and setting it to true the first time the connectedCallback is called. This setup part is wrapped in an if clause that checks for this.initialized. However, the shadowDom questions is still open. :D

@shawnbot
Copy link
Owner Author

@lukasoppermann Another way to handle that would be to check that its "new" parent is your expected container, a la:

const CONTAINER_CLASS = 'fancy-input-container';
class FancyInput extends HTMLInputElement {
  connectedCallback() {
    if (!this.parentNode.classList.contains(CONTAINER_CLASS)) {
      var container = document.createElement('div');
      container.className = CONTAINER_CLASS;
      this.parentNode.insertBefore(container, this);
      container.appendChild(this);
    }
  }
}

@shawnbot
Copy link
Owner Author

Also, sorry for not responding to your question, @joshbruce! My personal preference (and one that I've raised in uswds/uswds#1526) is to use the HTML standard elements whenever possible, and deploy polyfills for older browsers as needed. An accordion built entirely from <details> and <summary> elements sounds great to me, but an outer custom element to manage the entire collection would help to meet the qualifications for a WAI-ARIA accordion "widget", including better keyboard accessibility, and screen reader cues for older browsers. For instance:

const toggle = function(event) {
  update(event.target);
};

const update = function(details) {
  // set tabindex on the <summary>, etc.
};

// https://github.com/shawnbot/receptor
const receptor = require('receptor');
const keyup = receptor.delegate('details', receptor.keymap({
  // these functions aren't defined, but you can imagine what they might do...
  'ArrowUp': focusPreviousPanel,
  'ArrowDown': focusNextPanel,
  // etc.
}));

customElements.define('aria-accordion', class Accordion extends HTMLElement {
  get multiselectable() {
    return this.getAttribute('aria-multiselectable') === 'true';
  }
  set multiselectable(value) {
    this.setAttribute('aria-multiselectable', value === true);
  }
  connectedCallback() {
    this.addEventListener('toggle', toggle);
    this.addEventListener('keyup', keyup);
    // set ARIA attributes (for older browsers?)
    Array.from(this.querySelectorAll('details')).forEach(details => {
      details.setAttribute('role', 'tab');
    });
    Array.from(this.querySelectorAll('summary')).forEach(summary => {
      summary.setAttribute('role', 'tabpanel');
    });
  }
  disconnectedCallback() {
    this.removeEventListener('toggle', toggle);
    this.removeEventListener('keyup', keyup);
  }
});

@joshbruce
Copy link

joshbruce commented Nov 1, 2016

@shawnbot - This code fascinates me a bit...probably a bit too much, to be honest. :)

I'm not a JS guru (I know enough to be dangerous); so, pardon ignorance (this is getting more into the voodoo layers for me). Objective-C, Swift, and PHP are my native languages (though I'm learning Angular via TypeScript by osmosis).

The syntax you are using appears to be ECMA - not something like TypeScript. Curious if that is correct.

Also, receptor seems very interesting.

I believe you and I have talked delegate patterns before. I think we should do that again (I really would like to pick your brain some time)...but I'm not sure receptor is a "pure" (for lack of a better term) delegate. One of the advantages of delegation over broadcasting is a performance improvement - albeit minor in most practical applications (and many have worked around it).

When a notification is broadcast, the system historically loops over all live objects and says, "Knock-knock, just checking, do you care about this? Nope, okay - go about your business. Yep, okay great - that thing you care about happened - bye." It's my understanding some platform developers have worked around this - but made it less obvious about what's going on - by sometimes creating a faux object instance that holds an array of all the listeners. Whenever an instance registers as an event listener, it's added to an array of instances listening for those notifications (granted this can carry its own baggage).

What I characterize as a "pure" delegate pattern is more like the way Apple approaches delegations and protocols.

Instance A can accept a delegate. Instance B registers as a delegate to instance A by implementing a protocol and notifying instance A of its desire to do the heavy lifting. Instance A responds to events by "passing the buck" to the delegate. The delegate can, if implemented this way, pass the buck again. And so on. It's possible that the delegate property could be an array of instances conforming to the protocol, which would all get called (serially or in parallel).

Further, with regard to UI components specifically, the component may be developed to have default behavior if no delegate is present...a search bar updating the address bar, for example. Could have different behavior if a delegate is registered with the instance.

And, of course that got way longer than intended. Always a pleasure, brother.

Curious to hear your thoughts. /ht

Tagging in a couple folks from the crew here: @diego-ruiz-rei and @jbabbs

References:

Swift protocols and delegates
UIKit responder chain

No PHP references, because, well, it's doesn't respond to things in the application sense. :)

@areinot
Copy link

areinot commented Mar 31, 2017

How does one define inline event handlers along the lines of
<my-tag onclick="console.log('hey there!')"></my-tag>

I'm having trouble getting even onclick to come through but it would also be neat to define custom ones.

@shawnbot
Copy link
Owner Author

@areinot As far as I know, there's no prohibition on inline event handlers in the custom elements v1 spec, so they should work. Your example works in Chrome's native implementation of the v0 spec, anyway.

@moebiusmania
Copy link

hi, I really liked your explanation, very well done!

However, I was trying to define a Custom Element using a "functional" approach, following your example:

// ES5
var CustomElement = {
  prototype: Object.create(HTMLElement.prototype)
};

CustomElement.prototype.someMethod = function(arg) { /* ... */ };

// any accessors not passed to Object.create() can be defined like so.
// note that this is *exactly* what Object.create() is doing under the
// hood!
Object.defineProperties(CustomElement.prototype, {
  someValue: {
    get: function() { /* ... */ },
    set: function(value) { /* ... */ }
  }
});

I have tried to register it:

customElements.define('x-test', CustomElement);

but I get this error:

Uncaught TypeError: Failed to execute 'define' on 'CustomElementRegistry': The callback provided as parameter 2 is not a function.

I fear that the class approach is the only possible in v1, but since ES2015 classes are mainly syntactic sugar for Javascript's very own prototypes I thought I could use a function approach in the same way...

Do you have any information on this topic (defining v1 Custom Elements with functions rather than classes) ?

thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

7 participants