Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
4002414
Extend lazy properties to support superclasses, define `hooks` that way
LeaVerou Dec 8, 2025
e4c3e31
Refactor hooks, allow any casing for hook name
LeaVerou Dec 8, 2025
3f72e38
Support prepending `first_` to a hook to only execute it once per con…
LeaVerou Dec 8, 2025
12d95db
Add `@overload`
LeaVerou Dec 9, 2025
ebcfde6
Remove unnecessary line
LeaVerou Dec 9, 2025
ca0769f
Move CSS-specific utils back inside `/styles`
LeaVerou Dec 9, 2025
f7e0f63
Move unused utils to `util/unused`
LeaVerou Dec 9, 2025
c6d012a
(WIP) rewrite
LeaVerou Dec 10, 2025
85cc3d4
Update src/events/defineEvents.js
LeaVerou Dec 10, 2025
1068aa2
Update src/Element.js
LeaVerou Dec 10, 2025
b3b80e7
Apply suggestions from code review
LeaVerou Dec 10, 2025
3aaf893
Update src/util/unused/copy-properties.js
LeaVerou Dec 10, 2025
247c81a
Ensure `constructed` hook always runs before connectedCallback
LeaVerou Dec 10, 2025
5ed6bcf
Merge branch 'hooks-plugins' of https://github.com/nudeui/element int…
LeaVerou Dec 10, 2025
6ddfb02
Return known symbol if one exists
LeaVerou Dec 10, 2025
d80694f
Some fixes we are agreed on (#67)
DmitrySharabin Dec 10, 2025
23a8caa
Form behavior mixin
LeaVerou Dec 10, 2025
861fb7f
Refactor plugin code
LeaVerou Dec 10, 2025
4e80dc9
`setup` hook
LeaVerou Dec 10, 2025
f0cc167
Fix imports
LeaVerou Dec 10, 2025
ec8a767
Delete mounted.js
LeaVerou Dec 10, 2025
24d03e2
More robust `ElementInternals`
LeaVerou Dec 10, 2025
36ab8d2
Transparently call `attachInternals()` when `this[internals]` is acce…
LeaVerou Dec 10, 2025
17aad5e
`toggleState()` plugin
LeaVerou Dec 10, 2025
f5efc52
Plugin dependencies
LeaVerou Dec 10, 2025
966f374
Default export for all plugins
LeaVerou Dec 10, 2025
1b47ff5
Move `ElementInternals` stuff to plugin
LeaVerou Dec 10, 2025
751503a
Update symbols.js
LeaVerou Dec 10, 2025
a450f09
Make DX around symbols even nicer
LeaVerou Dec 10, 2025
25787b9
Export symbols
LeaVerou Dec 10, 2025
990546d
Update named-manual.js
LeaVerou Dec 10, 2025
f54b3c4
Rename `defineXXX` → `base`
LeaVerou Dec 10, 2025
8926f3c
Tweaks
LeaVerou Dec 10, 2025
ed53a96
[styles] README
LeaVerou Dec 10, 2025
941068f
Shadow DOM plugin
LeaVerou Dec 10, 2025
77590ba
Update has-slotted.js
LeaVerou Dec 10, 2025
4e7ddb1
Move plugin code to separate file
LeaVerou Dec 10, 2025
699661a
Remove unnecessary code
LeaVerou Dec 10, 2025
b5923ac
Split `Element` class from plugins
LeaVerou Dec 10, 2025
7715e2c
[form-behavior] Use new `dependencies` field for index
LeaVerou Dec 10, 2025
58dee4f
Hooks: Use a `Map` so that we don't get conflicts for reserved names
LeaVerou Dec 10, 2025
2d87ebb
Make sure plugins can be used without the class methods
LeaVerou Dec 10, 2025
8c86dea
Update README.md
LeaVerou Dec 10, 2025
08125f4
Fix bug
LeaVerou Dec 10, 2025
d4fd26c
Fix bug
LeaVerou Dec 10, 2025
3a2affa
Update src/events/base.js
LeaVerou Dec 11, 2025
4ffadc8
Update src/events/base.js
LeaVerou Dec 11, 2025
bff14c1
Update src/form-behavior/base.js
LeaVerou Dec 11, 2025
5bb2bfa
Update src/events/retarget.js
LeaVerou Dec 11, 2025
6970ac7
Rename variable
LeaVerou Dec 11, 2025
bccc2f5
Apply suggestions from code review
LeaVerou Dec 11, 2025
f380ea3
Fix infinite recursion
LeaVerou Dec 11, 2025
60080e4
Track hooks run in each context
DmitrySharabin Dec 11, 2025
1152d8a
Prevent bugs
DmitrySharabin Dec 11, 2025
01f1827
Add elements plugin stub
LeaVerou Dec 11, 2025
6258097
Merge branch 'hooks-plugins' of https://github.com/nudeui/element int…
LeaVerou Dec 11, 2025
6c3b27b
Fix bug
LeaVerou Dec 11, 2025
49ec984
[props] Make `props` and `ignoredAttributes` member properties lazy (…
DmitrySharabin Dec 11, 2025
e16fbba
Update src/form-behavior/like.js
LeaVerou Dec 12, 2025
da10473
Update src/events/propchange.js
LeaVerou Dec 12, 2025
711b46b
Fix bug
LeaVerou Dec 12, 2025
3583d29
Add defineLazyProperties utility function
LeaVerou Dec 12, 2025
5c184fc
Update src/util/delegate.js
LeaVerou Dec 12, 2025
fba8ee9
[states] Some fixes (#70)
DmitrySharabin Dec 12, 2025
d44faff
Rename `members` → `provides`
LeaVerou Dec 12, 2025
777c34f
[events] Fix the plugin order (#72)
DmitrySharabin Dec 12, 2025
830de33
[elements] Default export
LeaVerou Dec 12, 2025
34cc5cd
`mixins/hooks.js` → `hooks.js`
LeaVerou Dec 12, 2025
178f3b1
Remove unused mixin utility files
LeaVerou Dec 12, 2025
0e968e9
Support multiple symbol registries
LeaVerou Dec 12, 2025
dce2fb4
Set default value for knownSymbols in registry
LeaVerou Dec 12, 2025
b9628e1
Delete README.md
LeaVerou Dec 12, 2025
a23ec27
Decouple plugin code from symbols
LeaVerou Dec 12, 2025
4b63646
First part of slot rewrite
LeaVerou Dec 15, 2025
1559a70
Decouple event plugins from base
LeaVerou Dec 15, 2025
2817b6a
Decouple form behavior plugins from base
LeaVerou Dec 15, 2025
fc3fd4b
Add support for parent hooks
LeaVerou Dec 15, 2025
1c973b3
Update hooks.js
LeaVerou Dec 15, 2025
85fca37
Update src/events/index.js
LeaVerou Dec 15, 2025
302dc39
[shadow] Some fixes
DmitrySharabin Dec 15, 2025
0c398b5
Correctly run hooks
DmitrySharabin Dec 15, 2025
a862497
`super`
LeaVerou Dec 16, 2025
ec1ba1d
Fix bug
LeaVerou Dec 16, 2025
335e846
[styles] Add self to the list of supers
DmitrySharabin Dec 16, 2025
4f41be8
Update src/util.js
LeaVerou Dec 16, 2025
af314fb
Make the elements plugin work
DmitrySharabin Dec 16, 2025
267f801
Use the hook that runs earlier
DmitrySharabin Dec 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 60 additions & 109 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ for creating reactive web components that behave just like native HTML elements.

</header>

Elements can extend `NudeElement` to get the nicest, most declarative syntax,
or import individual mixins as helper functions and use them with any `HTMLElement` subclass.
Elements can extend `NudeElement` with their desired set of plugins to get exactly the functionality they need,
without any complexity they don’t need.
It is also possible to import individual plugins and connect them to an existing class, for transparent extension, but that is a little more involved.

**Note:** This is a work in progress, developed in the open.
Try it and please report issues and provide feedback!
Expand All @@ -22,9 +23,21 @@ Try it and please report issues and provide feedback!
- Accessible, form associated elements with a single line of code
- No build process required, just import and use

## Usage
## Architecture

Nude Element consists of two parts:
- The `NudeElement` class
- Plugins

While it is *technically* possible to use the plugins directly on any base class, it would involve a lot of manual plumbing.
You can take a look at the [`NudeElement` class](src/Element.js) to see how it works.

A plugin installed on a parent class will be inherited by all subclasses,
but subclasses can also define a static `plugins` property to add additional plugins.

### No hassle, less control: the `NudeElement` class
Plugins also include other plugins as dependencies.

## Usage

Defining your element as a subclass of `NudeElement` gives you the nicest, most declarative syntax.

Expand Down Expand Up @@ -90,126 +103,64 @@ class MySlider extends NudeElement {
}
```

### More hassle, more control: Composable mixins
### Defining your element

If Nude Element taking over your parent class seems too intrusive,
you can implement the same API via one-off composable helper functions aka mixins,
at the cost of handling some of the plumbing yourself.
As a design principle, Nude elements have everything out in the open: their public API is largely self-documenting and allows programmatic introspection.
There are certain static properties that relevant plugins expect on the element class to work their magic:

Each mixin modifies the base class in a certain way (e.g. adds properties & methods) and returns an init function,
to be called once for each element,
either at the end of its constructor or when it’s first connected.
This is what the example above would look like:
| Property | Description |
|----------|-------------|
| `props` | Attributes and properties that the element supports |
| `events` | Events emitted by the element |
| `slots` | Slots that the element supports |
| `styles` | Styles that the element imports |
| `cssStates` | States that the element supports (TODO) |
| `cssParts` | Parts that the element supports (TODO) |
| `cssProperties` | Custom properties that the element reads or exposes (TODO) |
| `formBehavior` | Parameters for form associated behavior |

```js
import {
defineProps,
defineEvents,
defineFormAssociated,
} from "nude-element";

class MySlider extends HTMLElement {
constructor () {
// ...

eventHooks.init.call(this);
formAssociatedHooks.init.call(this);
propHooks.init.call(this);
}
}
These can be either regular properties (e.g. `MyElement.props`) or known symbols for when that is not an option.
This makes it trivial to generate documentation for the element, or even to build generic tooling around it.

let propHooks = defineProps(MySlider, {
min: {
type: Number,
default: 0,
},
max: {
type: Number,
default: 1,
},
step: {
type: Number,
default () {
return Math.abs((this.max - this.min) / 100);
},
},
defaultValue: {
type: Number,
default () {
return (this.min + this.max) / 2;
},
reflect: {
from: "value",
},
},
value: {
type: Number,
defaultProp: "defaultValue",
reflect: false,
},
});

let eventHooks = defineEvents(MySlider, {
// Propagate event from shadow DOM element
change: {
from () {
return this._el.slider;
}
},

// Fire event when specific prop changes (even programmatically)
valuechange: {
propchange: "value",
},
});

let formAssociatedHooks = defineFormAssociated(MySlider, {
like: el => el._el.slider,
role: "slider",
valueProp: "value",
changeEvent: "valuechange",
});
```
### Known symbols

Each mixin will also look for a static `hooks` property on the element class and add its lifecycle hooks to it if it exists,
so you can make things a little easier by defining such a property:
To import any of the known symbols, use the `symbols` export, and then destructure `symbols.known`:

```js
import { defineProps } from "nude-element";
import Hooks from "nude-element/hooks";
import { symbols } from "nude-element";
// or
// import symbols from "nude-element/symbols";

class MyElement extends HTMLElement {
// Caution: if MyElement has subclasses, this will be shared among them!
static hooks = new Hooks();
const { props, events, slots, internals } = symbols.known;
```

constructor () {
super();
Note that any symbols you destructure that have not already been defined, will be created on the fly.

// Then you can call the hooks at the appropriate times:
this.constructor.hooks.run("init", this);
}
}
## Using Nude Element plugins on your own base class

defineProps(MyElement, {
// Props…
});
```
If Nude Element taking over your parent class seems too intrusive,
you can implement the same API via one-off composable plugins,
at the cost of handling some of the plumbing yourself.

Read more:
- [Using Props](src/props/)
- [Events](src/events/)
- [Form-associated elements](src/formAssociated/)
- [Mixins](src/mixins/)
To use the plugins directly on your own base class you need to:
- Include a static `hooks` instance and run its hooks at the appropriate times
- Use `addPlugin()` to install the plugins


## Known Hooks
### Known Hooks

These hooks are automatically managed when you use the `NudeElement` class.
If you choose to import mixins directly, you need to manage when to call them yourself.
If you choose to import plugins directly, you need to manage when to call them yourself.

- `prepare`: Runs once per class, as soon as a mixin is added
- `setup`: Runs once per class, before any element is fully constructed
- `start`: Runs on element constructor
- `constructed`: Runs after element constructor (async)
- `init`: Runs when element is connected for the first time
- `prepare`: Runs once per class, as soon as a plugin is added
- `constructor-static`: Runs once per class, before any element is fully constructed
- `constructor`: Runs on `NudeElement` element constructor
- `constructed`: Runs after element constructor is done, including any subclasses (async)
- `connected`: Runs when element is connected to the DOM
- `disconnected`: Runs when element is disconnected

### Read more

- [Props](src/props/)
- [Events](src/events/)
- [Form-associated elements](src/form-behavior/)
2 changes: 1 addition & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export { default as Props } from "./src/props/Props.js";
export { default as Prop } from "./src/props/Prop.js";
export { default as PropChangeEvent } from "./src/props/PropChangeEvent.js";
export { default as defineEvents } from "./src/events/defineEvents.js";
export { default as defineFormAssociated } from "./src/formAssociated.js/defineFormAssociated.js";
export { default as defineFormBehavior } from "./src/formBehavior.js/defineFormBehavior.js";
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
},
"exports": {
".": "./src/index.js",
"./fn": "./src/index-fn.js",
"./hooks": "./src/mixins/hooks.js"
},
"sideEffects": ["./src/index.js"],
"repository": {
"type": "git",
"url": "git+https://github.com/nudeui/element.git"
Expand Down
117 changes: 67 additions & 50 deletions src/Element.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,35 @@
/**
* Base class for all elements
*/
import defineProps from "./props/defineProps.js";
import defineEvents from "./events/defineEvents.js";
import defineFormAssociated from "./form-associated.js";
import defineMixin from "./mixins/define-mixin.js";

import { shadowStyles, globalStyles } from "./styles/index.js";
import Hooks from "./mixins/hooks.js";
import { defineLazyProperty } from "./util/lazy.js";
import { getSuper } from "./util/super.js";
import Hooks from "./hooks.js";
import { hasPlugin, addPlugin } from "./plugins.js";
import symbols from "./util/symbols.js";

const instanceInitialized = Symbol("instanceInitialized");
const classInitialized = Symbol("classInitialized");
const { initialized } = symbols.new;

const Self = class NudeElement extends HTMLElement {
export default class NudeElement extends HTMLElement {
constructor () {
super();

if (!this.constructor[classInitialized]) {
this.constructor.init();
}

this.constructor.hooks.run("start", this);

if (this.propChangedCallback && this.constructor.props) {
this.addEventListener("propchange", this.propChangedCallback);
}
this.constructor.setup(); // Last resort
this.constructor.hooks.run("constructor-static", this.constructor);
this.constructor.hooks.run("constructor", this);

// We use a microtask so that this executes after the subclass constructor has run as well
Promise.resolve().then(this.constructor.hooks.run("constructed", this));
Promise.resolve().then(() => {
if (!this.constructor.hooks.hasRun("constructed")) {
this.constructor.hooks.run("constructed", this);
}
});
}

connectedCallback () {
if (!this[instanceInitialized]) {
// Stuff that runs once per element
this.constructor.hooks.run("init", this);

this[instanceInitialized] = true;
if (!this.constructor.hooks.hasRun("constructed")) {
// If the element starts off connected, this will fire *before* the microtask
this.constructor.hooks.run("constructed", this);
}

this.constructor.hooks.run("connected", this);
Expand All @@ -45,42 +39,65 @@ const Self = class NudeElement extends HTMLElement {
this.constructor.hooks.run("disconnected", this);
}

static init () {
// Stuff that runs once per class
if (this[classInitialized]) {
return false;
}
static symbols = symbols.known;

static hooks = new Hooks();
static {
defineLazyProperty(this, "hooks", {
value: this.hooks,
get (hooks) {
let ret = new Hooks(hooks);
ret.parent = this.super?.hooks;
return ret;
},
configurable: true,
writable: true,
});
}

this.hooks = new Hooks(this.hooks);
/**
* Like super, but dynamic
*/
get super () {
return getSuper(this);
}

if (this.props) {
defineProps(this);
}
/**
* Like super, but dynamic
*/
static get super () {
return getSuper(this);
}

if (this.events) {
defineEvents(this);
}
/** Plugins to install */
static plugins = [];

if (this.formAssociated) {
defineFormAssociated(this);
}
static hasPlugin (plugin) {
return hasPlugin(this, plugin);
}

if (this.styles) {
defineMixin(this, shadowStyles);
}
static addPlugin (plugin) {
addPlugin(this, plugin);
}

if (this.globalStyle) {
this.globalStyles ??= this.globalStyle;
/**
* Code initializing the class that needs to be called as soon as possible after class definition
* And needs to be called separately per subclass
* @returns {void}
*/
static setup () {
if (Object.hasOwn(this, initialized)) {
return;
}

if (this.globalStyles) {
defineMixin(this, globalStyles);
this.super?.setup?.();

for (let plugin of this.plugins) {
this.addPlugin(plugin);
}

this.hooks.run("setup", this);

return (this[classInitialized] = true);
this[initialized] = true;
}
};

export default Self;
}
21 changes: 21 additions & 0 deletions src/common-plugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import props from "./props/base.js";
import events from "./events/index.js";
import formBehavior from "./form-behavior/index.js";
import shadowStyles from "./styles/shadow.js";
import globalStyles from "./styles/global.js";

export {
props,
events,
formBehavior,
shadowStyles,
globalStyles,
};

export default [
props,
events,
formBehavior,
shadowStyles,
globalStyles,
];
Loading