The crazies part is that when we actually research it, a default button is about 20% faster than the the flat nonsense we've settled on (https://www.theregister.com/offbeat/2017/09/05/its-official-...) and nearly a decade letter we still prefer looks over usability.
I think that emphasizes the point though. You need more code to do it correctly.
I now have a more critical eye and look into potential UI components code to decide if it really does need the custom code or if styling alone can get the job done.
Shoelace upending everything to Web Awesome has given me an incentive to revisit this rather then blindly find/replacing all the sl- prefixes to wa- given how much larger it makes my bundle.
Imagine everything man-made suddenly disappears but not the knowledge individuals carry. How long until we have an iPhone (or a plastic shirt button)? Would it even be any faster than the first time around?
The implementation assumes that onpointerup is mutually exclusive to the other two, but it fires in addition to mouse/touch events. Only onpointerup is needed, if you include onmouseup and ontouchstart then the button action will fire twice.
However, you also need an onpointerdown handler to verify that the pointer press started inside the button. Without it, the button would activate if you started holding down the mouse button outside the button area, and then releasing inside the button area.
The day after the lawsuit was filed, a company specializing in accessibility testing mysteriously contacted the client, offering a solution. Client had not even gotten notice of the litigation yet.
The net result of this was several tens of thousands of dollars spent actually removing Aria tags and using standard modern HTML on their aging website, to barely meet some threshold that appeared to be compliant.
The company who did the "work", and I mean, it was barely any work, maybe 100 LoC, stands by it and says the client won't get sued again, as long as they pay for ongoing compliance testing. So it's all a fucking racket.
I pointed out to the client that I didn't think that this half-assed effort was remotely sufficient to actually improve accessibility, but they had an interesting response. Which was this:
In 3 years, all this compliance shit will be out the window, because AI screen readers and agents are going to make the whole point moot.
I can't really disagree with that.
If you click any link on this page (to the author’s site) and you dare to use an alternative browser on iOS, it shows a full page modal that can only be navigated around by clicking an “escape” button which tries to execute a Siri shortcut. Apparently in-app browsers are a threat to user freedom, but Orion is caught in the dragnet. Perhaps an example of the paternalist approach to development on the web gone wrong.
class SaganButton extends HTMLButtonElement { … }
Anyone know the reasoning they’re blocking this?
For whatever reason, developers and some users expect an app to look the same across all platforms, while also looking distinct from other apps—otherwise, the app looks indistinguishable from a low effort one. This involves creating a design system and departing from each operating system's native widgets.
Look at the progress of Flash/AIR AS3 as an embedded runtime prior to its annihilation. By 2010 or so, it had the capability to leverage the GPU on pretty much any device, directly uploading bitmaps and shaders. It had garbage collection as good as or better than any existing [modern] JS engine, had strong types and compile time errors. It was better and cleaner than the modern fractured Typescript-and-canvas web app gaming paradigm in every respect, except for one: It was closed source.
I'm not a fan of AS3's demise, nor of the current state of affairs. We are stuck a decade ago because of it.
But I can also imagine a world where Adobe has a stranglehold in 2026 on most cross-platform game development, and where little arose to compete with it outside their ecosystem. And they've become such a hideous company blinded to their users, even without the advantage they used to have of dominating browser plugins, that I can see now maybe it was for the best that they're not the guardians of the way we do things anymore.
The anarchic process of creating standards is messy, and it results in a huge amount of wasted effort by developers. And it's often a lot less fun.
On the other hand, the job is to make shit using whatever is available.
https://winworldpc.com/screenshot/c5a0e280-a1c3-974a-24c2-90...
We might lose electricity for a while. There's not a lot of utility for electricity in pre-industrial society. Like, given enough copper, I could make a wind turbine, but I can't casually make a useful lightbulb. Maybe a ceiling fan, but it would almost certainly be easier to run that off of mechanical power directly via a series of gears and belts. Electrochemistry would be a neat party trick, but I don't think my shoddily built wind turbine would generate enough juice to process aluminum.
Firearms would probably continue to exist. I could make a musket, and its utility for hunting and defense would make it immediately worth it. Black powder's not terribly difficult to manufacture from base ingredients.
Are we including domesticated crops as "man-made"? Because that would complicate matters. A lot of knowledge could be lost in the time it takes to rebreed the kinds of grains that allow for stable settlement.
We wouldn't need to re-invent writing, since that's just knowledge, and that would give a pretty big leg up in not losing a bunch of knowledge every time someone dies.
On the whole, if we keep selectively bred crops, I'd say we'd be bumped back to about the Middle Ages at the most. If we're losing the crops, then it would come down to whether we could preserve our more advanced knowledge long enough for agriculture to redevelop.
Without having access to the advanced techniques, I think it's unlikely we'd even reach the industrial revolution again.
You can literally run a "open this codebase and improve accessibility where you can" and get mostly perfectly good changes. Models and harnesses can be tuned to prioritize it by default, but usually the developer only needs to nudge it a bit to get good accessibility.
I prompted Claude to "make my app accessible and usable with a screen reader" and it pretty much did a perfect job making it usable. *
* I'm not a a11y expert so "perfect job" might be an overstatement, but it made the app completely navigable by me using a screen reader.
If that's the case, why didn't they spend a few bucks on Claude to do the accessibility changes?
The website works just fine for me on Firefox+iOS, it seems like whatever matching the author is doing just doesn't include detection for Orion.
There are UI components that are not available in native browser controls (infuriatingly: some are only available in some browsers on some platforms), but even then you're better off writing a shim to replicate normal browser behaviour than ruining the experience for everyone because Firefox on Android doesn't have a colour picker.
The one exception is Safari which has been slowly getting more and more special over time when it comes to web standards but is still relevent. If Safari found their way to supporting it then you shouldn't need to completely re-implement a button or combobox and instead just improve the native versions.
Since the whole compliance racket is totally disconnected from actual accessibility outcomes, why would AI have any impact here?
There’s a standard and a law and money to be made.
Missing part is dynamically updating the datalist in an efficient way
I didn't read any satire in the article at all, it just laid out all the built-in behaviors that a proper button has, and how much work it is to reimplement all of them. Something declarative and CSS-like would have been ideal for customizing elements, but instead we got the half-assed Custom Elements API and the completely different DX atrocity that is Web Components.
I can't really fault Custom Elements too much though, it's an imperfect API for an imperfect DOM and it's better than waiting forever for perfection. But I don't extend the same generosity to the Web Components spec.
Ultimately the browser was recognized as its own platform and had to support all the customizations and accessibility concerns. User stylesheets are a thing, but few used that for much more than hiding annoying elements, and today the most likely user customizable thing is being able to switch between light and dark modes.
We basically had to recreate all of the actions of focus/unfocus/etc. Everything a normal button could do. Now that I have done more stuff I a know for a fact I made my life way harder than it should have been and could have subclassed. But it was a full button recreation. Styling messaging and all. You have to watch for things like what if someone clicks outside of your button but lets go inside the button. What does that mean? All sorts of weird side effects. What if your button has focus and someone starts typing? What if someone clicks outside of your button but holds the button down as they slide across your button? What does that look like?
Would I ever do it again. Probably not. Seriously use the built in ones.
Generated tests can help, and if we go into that direction we can now certainly afford to introduce more proven code (Lean/Roq/Frama-C…), but that will still not be wild reality proof until it faces the whole user base and their widely different environments.
And large load of code is still large load of code.
Just like it is more efficient to have a food system than to have everyone feed themselves from their backyard (if they have one), maybe someday people will realize that it will be more efficient to build things once and re-use.
Similarly, every argument for “AI makes it cheaper so we can do it now” falls apart under “AI also makes it cheaper to not do it”.
"You only have to want it and believe in it, then it will succeed."
- Count Ferdinand von Zeppelin
"The airship [is] the future of commercial air transport in general."
- Dr. Hugo Eckener
"The age of the airship is no longer a dream of the future; it is a reality of the present. Giant dirigibles will soon cross every ocean, making obsolete the slow surface ships of today."
- Popular Mechanics, December 1929
Have a type and: submit a form; reset a form; or not do anything with the related form.
No one uses buttons to submit a form in web applications. You use buttons to start/stop/change interaction flow.Native browser controls are not workable in a modern web application. It is not that developers are lazy it is that you get requirements from businesses that no one would pay for implementing using native controls because it would cost too much to do it right, where right means „how customers want it and how they want to use it” not „technically right like some native browser control nerds feel world should work”.
Others invented plenty of UI paradigms before Apple: swiping, skeomorphism, etc.
If your device was running Windows sure. On Linux, Flash absolutely sucked with 100% CPU usage to render the most basic still image and on Android it was kind of similar.
And it was a security nightmare...
Instead, I've had people from Microsoft itself recommending me to "just write it in HTML, there's no standard" and "accessibility sucks with native".
When you have the market leader telling you to write HTML for their OS, it's a carte blanche to do it everywhere.
It's not just that I think, using their native components also ensures accessibility and consistent UX throughout different apps, which is a huge benefit to e.g. screen reader users. Everyone else - especially web interface builders, especially if they eschew just using native elements - reinvents the wheel and considers accessibility as an afterthought.
At least we have laws now that mandate accessible websites for corporates, in addition to government sites.
Two, it was not responsive. It was written for fixed sizes with keyboards and mice. Not portrait displays with touch screens, nor AR/VR displays with pointers/hands, and for resolutions of the day, not reflowing / resizing to fit the user's device
You mean like (a) assume this perfect AI that already exists never makes a mistake because AI doesn't do that, (b) accept all the security risks like prompt injection, (c) accept the fact that you don't deserve to enjoy the same privacy over your life as others do?
But there is. You'll be so happy to learn about datalist today,
https://www.w3schools.com/tags/tryit.asp?filename=tryhtml5_d...
Google was very, very, very late to the project and of course immediately trampled all over it like they did with all the web standards.
Apple isn't holding back progress on that. They are all in on it, though they do tend to be more cautious than the "break fast and lose things" Google.
Can we not just do `:host { display: contents }` and use the same approach?
We've had things like https://doc.qt.io/qt-6/qtwidgets-module.html since the late last millenium. Back in the day, there was Delphi, now there is Lazarus, with even nicer Data-Bound widgets. Look at some tutorial for those, that's like magic, and also from before 2000!
Does anyone know why there have been 3 lost decades in native HTML widgets? Any ideas how to fix this?
Knowing when to apply novelty and when to apply the familiar is the game.
I'm not saying use custom html elements because we can. But if somehow a html button is a valid technical problem, it can be reasonably done well for the scope of that app.
That's when I learned about the halting problem.
Something like procreate would be horrible with mouse/keyboard. UX is not merely a function of size.
As for potential property collisions, I think the common wisdom is to just ensure the property name contains a dash, since the html5 spec goes out of its way to avoid using them. Doesn't solve brittle inheritance hierarchies in general, but it does at least stay out of the way of built-in behavior.
It seems like it'd be equally trivial to demonstrate that said reader doesn't work on some combination of hardware and software.
That’s hardly a “tech hope” or “visionary”. Dirigibles continue to be interesting and fascinating to new generations, even if just as a concept; the language used to write a JavaScript runtime is uninteresting and unimportant in the scheme of things.
> The community would have frowned upon the idea, saying the effor would never be worth it. And now here we are.
Here we are where? Is the Rust release out yet? Didn’t it have tens of thousands of `unsafe` blocks? What’s so great about it? At this point in time, even implying the transition was a success is the same as calling the Hindenburg or the Titanic a success. You’ve seen the thing exists but have no idea how it’ll perform in the real world under real scrutiny and real scenarios. Perhaps that wouldn’t matter had much of Bun was just an internal project used by just one company, but it isn’t.
At this point, I think HTML+JS is able to be a better choice than SwiftUI for most things: Yes, some stuff will still need to go to a native layer, but that's true for HTML as well. ;P
For example, they've recently introduced the Interest Invoker API for tooltips on hover. Tooltips are ubiquitous, but they still haven't settled on what the trigger is for non-mouse users. Long press for touch is far less discoverable than mouse hover, for example.
Maybe it's a good thing they didn't rush this design three decades ago, when virtually all users were on desktop.
Somewhat tangentially, the official response to a request for WebAuthn without JavaScript[1] was that the big websites don’t care and thus neither do the browsers.
As a user, comparitive "performance" is about the code people actually write, and even more on the code that I'm most likely to interact with. I don't actually care whether or not the code could be faster if it was written better, because it always could, and it never is. When people say "Electron is slow and bloated" they don't mean that exhaustively written Electron apps could never" be performant, they mean that apps that use Electron tend to be slow and bloated. The way to change that reputation is not to argue that Electron could* be fast if people held it right, but to make it easier and more natural for Electron apps to be faster than they currently are.
Maybe VR glasses will be big soon. Do we trigger on 'blink'? 'look harder'? 'eyeball wiggle'?
Maybe voice interfaces? Trigger on 'say it louder'? 'stuttering'? 'hesitant'?
Maybe guestures, facial expressions, thought patterns? 'think hard about that button to trigger tooltip'? 'furl your eyebrows'?
As I've said, decades have passed with no progress. If progress in other fields is a reason for waiting, it'll be stagnant forever and eventually just dead.
Is it a better interface and developer experience? For sure. Is it gonna replace the previous API? Doubtful.
I have to admit I'm still ignoring it though.
The title is a tongue-in-cheek parody of a quote from Carl Sagan(External link).
And it ties in very well with the thought exercise we are doing today.
When we start learning about web accessibility the first thing we hear one of two things:
The first rule of ARIA is to not use ARIA.
And:
If there is a native element, use that instead of recreating the element.
But people never explain why this advice is given, and why we should not recreate things from scratch.
I’m going to try to explain why recreating some components from scratch is usually a Sisyphean task(External link) with the example of (mostly) recreating a button from scratch.
Native elements have a set of expectations on their behaviour. They follow some laws of UX. (See what I did there? lol)
Users on your application expect to interact with the element in specific ways and expect that the element will behave in specific ways.
For references, we will use the following links as a base of what a button is, how it behave, and how users expect to interact with it.
Those references give us the following list or requirements.
Along with the items are the WCAG Success Criteria (SC) that represent that requirement.
button. (SC 4.1.2(External link))name and value, and form attributes when participating in forms. (SC 4.1.2(External link))disabled. (SC 4.1.2(External link))If that didn’t scare you already of trying to re-implement a button from scratch, buckle up, because we will work through that list and I hope that will discourage you from recreating elements.
Our custom element nucleus will be a new tag. We do so because custom tags(External link) behave just like a <span> by default. That gives us an “empty state” to start working on.
We then add a role of button to it. Now it will be exposed to the accessibility tree as a button.
NOTE: styling the element will be left as an exercise to the reader for two reasons:
It will make things unnecessarily longer for this blog post.
Custom elements can be styled however you like, with no general restrictions.
A button from scratch
So far so good…
Our button should also have an accessible label, that can be either provided by the developer or as a last case fallback derived from the element’s content.
The problem here is specifically icon buttons. If we update our markup to something like this:
<sagan-button role="button">đź’©</sagan-button>
It will be read by a screen reader as something like1:
Button, pile of poo
There are a few issues here:
To remediate this we could add a label to the button, and mark the emoji as presentational and/or hide it, so it is not announced by screen readers.
The code will look as follows:
<sagan-button role="button" aria-label="Data Sharing Options">
<span role="presentation" aria-hidden="true">đź’©</span>
</sagan-button>
The component now looks like a button, is announced like a button, but cannot be interacted with yet.
The first part is to make the element keyboard focusable, to do so, we need to add the tabindex attribute.
This will make the element become part of the tab order of the page(External link), meaning when a user press Tab they will be able to navigate and reach the element.
<sagan-button
role="button"
aria-label="Data Sharing Options"
tabindex="0"
>
<span role="presentation" aria-hidden="true">đź’©</span>
</sagan-button>
The value for the attribute here is 0 so the element will follow the existing order of the page, any positive value would make it jump in front of other elements and the focus of the page move all over, so it is not recommended(External link).
We then need to add some way to activate the component. Click, touch, and pointer interactions are ways to interact with the element using those modalities.
They cover, click for mouse buttons, touch for touch screens, and pointer for everything else like stylus.
NOTE: For maintaining compatibility, browsers do map both touch and pointer events (to some extent) to click events but here we are implementing all of them independently for the sake of argument.
Some keyboard events may also be mapped by default, but it gets more complicated than that...
We then add the events. Starting with the mouseup for handling mouse events:
<sagan-button
role="button"
aria-label="Data Sharing Options"
tabindex="0"
onomouseup="console.log('mouse')"
>
<span role="presentation" aria-hidden="true">đź’©</span>
</sagan-button>
Let’s also add two more events:
ontouchend for handling when the user lifts their finger from the element.onpointerup for handling other pointer devices, like styluses.Now our component looks like this:
<sagan-button
role="button"
aria-label="Data Sharing Options"
tabindex="0"
onmouseup="console.log('mouse')"
ontouchend="console.log('touch')"
onpointerup="console.log('pointer')"
>
<span role="presentation" aria-hidden="true">đź’©</span>
</sagan-button>
The code for the custom element is getting a little bit verbose on the HTML side of things.
We will give it a little bit more powers by using JavaScript, registering the component(External link), and attaching a Shadow DOM(External link).
NOTE: To make event handling easier, it is done using the handleEvent method of our component.
This article explains the event handling pattern in detail.
The code for the element looks like this:
class SaganButton extends HTMLElement {
// A reference to the element internals,
// this allows us to do some thigns with the element we cannot do otherwise.
/** @type {ElementInternals} */
#internals;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.#internals = this.attachInternals();
// Add back the things we had before.
this.#internals.role = 'button';
this.#internals.ariaLabel = 'Data Sharing Options';
this.tabindex = 0;
}
/**
* @param {Event} evt
*/
handleEvent(evt) {
// We here filter by event type
switch (evt.type) {
// For all of those we want to do some action.
// So here we use a fallthrough here.
case 'mouseup':
case 'touchend':
case 'pointerup':
this.#doButtonAction(evt);
break;
}
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<slot></slot>
`;
// We add the event listeners
// when the element is added to the DOM.
this.addEventListener('mouseup', this);
this.addEventListener('touchend', this);
this.addEventListener('pointerup', this);
}
disconnectedCallback() {
// And remove the listeners
// when the element is removed from the DOM.
this.removeEventListener('mouseup', this);
this.removeEventListener('touchend', this);
this.removeEventListener('pointerup', this);
}
/**
* @param {Event} evt
*/
#doButtonAction(evt) {
// TODO: Implement the actual button action.
}
}
Okay, we are back to where we were before adding JS into the mix, but now with more complexity! 🎉
The button can be activated with clicks, touches, and pointers, but not using the keyboard. We now need to add a keyup and keydown event listener so the component can be interacted with using the keyboard.
NOTE: this is the bare minimum for a semi-working keyboard accessible button.
There are more nuance on the events and expectations. Adrian Roselli covers this topic in more details.
class SaganButton extends HTMLElement {
connectedCallback() {
// ...
this.addEventListener('keyup', this);
this.removeEventListener('keydown', this);
}
disconnectedCallback() {
// ...
this.removeEventListener('keyup', this);
this.removeEventListener('keydown', this);
}
/**
* @param {Event} evt
*/
handleEvent(evt) {
switch (evt.type) {
// ...
case 'keyup':
this.#handleSpaceActivation(evt);
break;
case 'keydown':
this.#handleEnterActivation(evt);
break;
}
}
/**
* @param {KeyboardEvent} evt
*/
#handleSpaceActivation(evt) {
// Don't do anything if the space bar is not pressed.
if (evt.key !== ' ') {
return;
}
if (document.activeElement !== this) {
return;
}
this.#doButtonAction(evt);
}
/**
* @param {KeyboardEvent} evt
*/
#handleEnterActivation(evt) {
if (evt.key !== 'Enter') {
return;
}
this.#doButtonAction(evt);
}
}
disabledNow we are getting into attribute territory, this means implementing behaviors to match the native <button> element.
From the MDN docs on the <button> element(External link), one of the attributes is the disabled attribute. Let’s use it as a starting point for the pattern to handle attributes we will use.
The attribute pattern is as follows:
observedAttributes array.The pattern allows for both attributes and properties to be kept in sync, as well as validating and normalizing value as needed, as well as falling back to default values when needed.
It is the beginning of a reactive system, and can be used as the basis to implement dynamic updates to the DOM. But I digress…
For the disabled attribute, we first need to set the ariaDisabled property of the elementInternals. Then add a new state to the element so it can be styled by CSS.
class SaganButton extends HTMLElement {
// Specify the attributes to observe.
static observedAttributes = [
'disabled'
];
// Add a setter...
/**
* @param {boolean} newValue
*/
set disabled(newValue) {
this.toggleAttribute('disabled', newValue);
// Note: `aria-disabled` is a string of "true" or "false", not a boolean.
this.#internals.ariaDisabled = newValue ? 'true' : 'false';
if (newValue) {
this.#internals.states.add('--disabled');
} else {
this.#internals.states.delete('--disabled');
}
}
// Then a getter...
get disabled() {
return this.hasAttribute('disabled');
}
// Watch for changes on the attribute.
/**
* @param {string} name
* @param {string | null} oldValue
* @param {string | null} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) {
return;
}
switch (name) {
case 'disabled':
this.disabled = newValue !== null;
break;
}
}
// ...
}
To add styles to the disabled state the CSS is similar to the one below:
sagan-button:state(--disabled) { /* Styles for disabled state */ }
Buttons started out as elements related to forms, so there are a lot of attributes for interacting with, you guested, forms!
Again from MDN docs on the <button> element, here are the attributes related to forms:
type: The button type, it can be one of 3 options (submit, reset, button). The options trigger the associated form submission, form reset, or don’t do anything.form: the associated form, this is useful for the case where the button is not in the sub tree of the form element.formaction: a URL to submit the form to, this is so you can have different buttons submitting the form to different URLs.formmethod: Same as the above, different buttons can send different methods. HTML baby!formenctype: This also allows for different ways of sending the form data over the wire2.formnovalidate: Says the form should skip validation when this button is used.formtarget: If the form should open a new tab on submission or not.name and value: if present those are added to the form on submission.Aside from the attributes, there are also some properties related to the Validation API(External link):
validity: The validity state of the button, it only makes sense for inputs, for buttons it will usually be the same.validationMessage: The error message to show to the user if there is an error for this button.willValidate: If this button will be a part of the constraint validation for the form or not.checkValidity/reportValidty: Methods to validate the element.setCustomValidity: Sets a custom error message to this element.Ah, yes, we also need to set the formAssociated static property to the element so everything works, without it, we would have a lot of errors.
Well… Let’s get to writing all that…
class SaganButton extends HTMLElement {
// To make it interact with forms...
static formAssociated = true;
static observedAttributes = [
'type',
'form',
'formaction',
'formmethod',
'formenctype',
'formnovalidate',
'formtarget',
'name',
'value'
// ...
];
/**
* @param {string | null} newValue
*/
set type(newValue) {
// The default for button types is "submit",
// So we validate and default to it if the value does not match.
if (newValue && ['submit', 'reset', 'button'].includes(newValue?.toLowerCase())) {
this.setAttribute('type', newValue);
} else {
this.setAttribute('type', 'submit');
}
}
get type() {
return this.getAttribute('type') ?? 'submit';
}
/**
* @param {string | null} newValue
*/
set form(newValue) {
if (newValue === null) {
this.removeAttribute('form');
return;
}
// Here we need to check if the form is a valid element.
// If it is, we then set the attribute.
const form = document.getElementById(newValue);
if (form) {
this.setAttribute('form', newValue);
}
}
/**
* @returns {HTMLFormElement | null}
*/
get form() {
// Here we get the value from the attribute as the first option.
const formId = this.getAttribute('form');
if (formId) {
return document.getElementById(formId);
}
// Then fallback to whatever the browser has set.
return this.#internals.form;
}
/**
* @param {string | null} newValue
*/
set formAction(newValue) {
if (newValue === null) {
this.removeAttribute('formaction');
return;
}
this.setAttribute('formaction', newValue);
}
get formAction() {
return this.getAttribute('formaction');
}
/**
* @param {string | null} newValue
*/
set formMethod(newValue) {
// The default method is "get"
if (newValue && ['post', 'get', 'dialog'].includes(newValue.toLowerCase())) {
this.setAttribute('formmethod', newValue);
} else {
this.setAttribute('formmethod', 'get');
}
}
get formMethod() {
return this.getAttribute('formmethod') ?? 'get';
}
/**
* @param {string | null} newValue
*/
set formEnctype(newValue) {
// The default value is "form url encoded".
if (
newValue &&
[
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain'
].includes(newValue.toLowerCase())
) {
this.setAttribute('formenctype', newValue);
} else {
this.setAttribute('formenctype', 'application/x-www-form-urlencoded');
}
}
get formEnctype() {
return this.getAttribute('formenctype') ?? 'application/x-www-form-urlencoded';
}
/**
* @param {boolean} newValue
*/
set formNoValidate(newValue) {
this.toggleAttribute('formnovalidate', newValue);
}
get formNoValidate() {
return this.hasAttribute('formnovalidate');
}
/**
* @param {string | null} newValue
*/
set formTarget(newValue) {
// The default value is "_self".
if (newValue && ['_self', '_blank', '_parent', '_top'].includes(newValue.toLowerCase())) {
this.setAttribute('formtarget', newValue);
} else {
this.setAttribute('formtarget', '_self');
}
}
get formTarget() {
return this.getAttribute('formtarget') ?? '_self';
}
/**
* @param {string | null} newValue
*/
set name(newValue) {
if (newValue === null) {
this.removeAttribute('name');
return;
}
this.setAttribute('name');
}
get name() {
return this.getAttribute('name');
}
/**
* @param {string | null} newValue
*/
set value(newValue) {
if (newValue === null) {
this.removeAttribute('value');
return;
}
this.setAttribute('value', newValue);
// We need to set the button's value.
this.#internals.setFormValue(newValue);
// And the validation state.
this.#internals.setValidity({});
// And the valid/invalid states.
this.#internals.states.add('--valid');
this.#internals.states.delete('--invalid');
}
get value() {
return this.getAttribute('value');
}
get willValidate() {
if (this.type === 'button' || this.type === 'reset') {
return false;
}
if (this.closest('datalist')) {
return false;
}
if (this.disabled) {
return false;
}
if (!this.#internals.validity.customError) {
return false;
}
return this.#internals.willValidate;
}
get validationMessage() {
return this.willValidate ? this.#internals.validationMessage : '';
}
get validity() {
return this.#internals.validity;
}
checkValidity() {
return this.#internals.checkValidity();
}
reportValidity() {
return this.#internals.reportValidity();
}
/**
* @param {string} message
*/
setCustomValidity(message) {
if (message) {
this.#internals.setValidity({ customError: true }, message);
this.#internals.states.add('--invalid');
this.#internals.states.delete('--valid');
} else {
this.#internals.setValidity({});
this.#internals.states.add('--valid');
this.#internals.states.delete('--invalid');
}
}
/**
* @param {string} name
* @param {string | null} oldValue
* @param {string | null} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
// ...
switch (name) {
// ...
case 'type':
this.type = newValue;
break;
case 'form':
this.form = newValue;
break;
case 'formaction':
this.formAction = newValue;
break;
case 'formmethod':
this.formMethod = newValue;
break;
case 'formenctype':
this.formEnctype = newValue;
break;
case 'formnovalidate':
this.formNoValidate = newValue !== null;
break;
case 'formtarget':
this.formTarget = newValue;
break;
case 'name':
this.name = newValue;
break;
case 'value':
this.value = newValue;
break;
}
}
}
After a wall of text, we write the logic for what to actually do once the button is activated. We defined a #doButtonAction method before but didn’t implement it.
Now is the time to implement that method:
class SaganButton extends HTMLElement {
// ...
#doButtonAction() {
// First check if the button is disabled,
// On that case nothing should happen.
if (this.disabled) {
return;
}
// Then check the button type.
if (this.type === 'button') {
return;
}
// Here we reset the form if the button is a reset one.
if (this.type === 'reset') {
this.form?.reset();
return;
}
// Finally, if it is a submit button,
// AND IF we wired everything correctly,
// The form will be handled by the browser.
this.form?.requestSubmit(this);
}
// ...
}
We could also handle all the validations and form submission by ourselves, including the button and form validations. But this is already a painful and long enough post as it is.
So to avoid more self inflicted pain, in the end, we let the browser handle the form submission for us.
There are a couple of things that happen when the button is activated that are not covered:
active state.preventDefault the default action that we just implemented should not run.If we simply register event listeners to the button, as we have so far with addEventListener it would make our default event listener not work, as it may run out of order with other event listeners added by the user using our component.
We want to run an event listener before and another after every other listener. To do so we need to wrap the addEventListener, as well as all the on* attributes(External link) we care about.
Here is the code for wrapping the addEventListener method:
class SaganButton extends HTMLElement {
/** @type {Element['addEventListener']} */
#originalAddEventListener;
constructor() {
// ...
// Add event listeners for to set the active state.
this.addEventListener('pointerdown', this.#setActiveState);
this.addEventListener('touchstart', this.#setActiveState);
this.addEventListener('mousedown', this.#setActiveState);
this.addEventListener('keydown', this.#setActiveState);
// Keep a reference to the original method.
this.#originalAddEventListener = this.addEventListener;
// Wrap the original method.
this.addEventListener = this.#wrappedAddEventListener;
}
/**
* @template {keyof ElementEventMap} K
* @param {K} type
* @param {EventListenerOrEventListenerObject} listener
* @param {boolean | EventListenerOptions} [options]
*/
#wrappedAddEventListener(type, listener, options) {
const defaultEvents = ['click', 'pointerup', 'touchend', 'keyup', 'keydown'];
// If one of the default events,
// Remove the default event listener, and re-add it as the last one.
if (defaultEvents.includes(type)) {
this.removeEventListener(type, this);
this.#originalAddEventListener.call(this, type, listener, options);
this.addEventListener(type, this);
} else {
this.#originalAddEventListener.call(this, type, listener, options);
}
}
#setActiveState() {
if (this.disabled) {
return;
}
this.#internals.states.add('--active');
}
/**
* @param {Event} evt
*/
#doButtonAction(evt) {
// After the disabled check...
this.#internals.states.delete('--active');
if (evt.defaultPrevented) {
return;
}
// ...
}
}
For the attribute event listeners, there are two caveats to note:
For the property case, it would be as simple as calling the #wrappedAddEventListener.
The problem is with the attribute, as it can accept a number of things that should be parsed as JavaScript code(External link).
Here is one example for the onmosueup attribute and property:
class SaganButton extends HTMLElement {
static observedAttributes = [
// ...
'onmouseup'
];
// Keep a list of event listeners for attributes.
/** @type {Record<string, EventListener>} */
#attributeEventListeners = {};
/**
* @param {EventListener | null} newValue
*/
set onmouseup(newValue) {
if (!newValue) {
// Check if there is already a saved listener.
const savedListener = this.#attributeEventListeners['mouseup'];
if (savedListener) {
// Then remove it, and delete the reference.
this.removeEventListener('mouseup', savedListener);
delete this.#attributeEventListeners['mouseup'];
}
} else {
// Add a new listener and save the reference.
this.addEventListener('mouseup', newValue);
this.#attributeEventListeners['mouseup'] = newValue;
}
}
get onmouseup() {
return this.#attributeEventListeners['mouseup'] ?? null;
}
/**
* @param {string} name
* @param {string | null} oldValue
* @param {string | null} newValue
*/
attributeChangedCallback(name, oldValue, newValue) {
// ...
switch (name) {
// ...
case 'onmouseup':
this.onmouseup = this.#parseAttributeListener(newValue);
break;
}
}
/**
* @param {string | null} value
*/
#parseAttributeListener(value) {
// If no value is passed it returns null.
if (!value) {
return null;
}
try {
// NOTE: This is very bad practice!
// It creates an immediatly invoked function body out of the value given.
// When it is called, it will execute whatever is passed as the value.
return new Function('evt', `(${value})(evt);`);
} catch (err) {
// If there is an error with the function, returns null.
console.error(err);
return null;
}
}
}
There are APIs like the Popover API(External link), or Invoker Commands API(External link), or even the experimental Interest Invoker API(External link). All those APIs bring more features to HTML in a way that is easy for developers to use and handles all the required logic by the browser.
I will skip those because they require extra code or relate to other elements around the button like dialogs. Also this post is just north of 4000 words, so yeah…
USE SEMANTIC HTML!
That’s is. Don’t reinvent the wheel if you don’t absolutely need to.
It is too much work, and a lot of extra burden for you to maintain.
The example code for this button component is almost 500 lines of JS (see it below), where a native HTML button is 0 lines of JS.
All that work to achieve only some of the functionality! That is an insane amount of work for almost no return, so do yourself a favour and just use the semantic native options.
Even if you use only part of the functionality, using semantic HTML is less code to maintain and makes your codebase easier to reason about.
On the plus side, if you ever need some other functionality, it is already there, provided by the platform, no extra code.
Styling elements would have been a big issue on ye olde IE days. Nowadays it is mostly okay.
This MDN guide on styling forms(External link) gives a good breakdown of what is easy and what is not. Even for the harder ones it requires more CSS massaging, but it is still technically feasible.
Also, have you heard about custom selects(External link)?
Another point is that hand rolling a component is a lot of work and most of the time breaks user expectations, talk to the designer and ask the question: is this the best user experience or could we use another pattern?
Lastly, inaccessible pages are liable to legal actions. Better keep those lawyers happy and the fines away, am I right?
Well… Sometimes we can’t choose the tools we use, but we still can make the best out of them. For the code you write, use more semantic HTML and test things out.
Unfortunately LLMs generate inaccessible code by default, but not all is lost, this article proposes solutions to the problem(External link).
All the glorious almost 500 lines of it!
The exact reading by the screen reader is not the point here, the main problem is that emojis are read out loud by a name that is usually not intuitive. Back to reference 1
In modern development, where everything is sent to the server as JSON, this is a forgotten piece of how basic HTML can do a lot! Back to reference 2