Full Code of LeaVerou/nudeui for AI

main dbaec1ec5dd1 cached
57 files
102.8 KB
31.0k tokens
151 symbols
1 requests
Download .txt
Repository: LeaVerou/nudeui
Branch: main
Commit: dbaec1ec5dd1
Files: 57
Total size: 102.8 KB

Directory structure:
gitextract_w7vts8kx/

├── .eleventy.cjs
├── .gitattributes
├── .gitignore
├── LICENSE
├── README.md
├── _headers
├── _includes/
│   └── page.njk
├── _redirects
├── assets/
│   └── global.js
├── elements/
│   ├── button-group/
│   │   ├── README.md
│   │   ├── button-group.js
│   │   ├── style.css
│   │   └── style.postcss
│   ├── cycle-toggle/
│   │   ├── README.md
│   │   ├── cycle-toggle.js
│   │   └── style.css
│   ├── data-bind/
│   │   ├── Observer.js
│   │   ├── README.md
│   │   ├── Recipe.js
│   │   ├── data-bind.js
│   │   ├── properties.js
│   │   └── util.js
│   ├── drop-down/
│   │   ├── README.md
│   │   ├── drop-down.js
│   │   └── style.css
│   ├── html-demo/
│   │   ├── README.md
│   │   ├── html-demo.css
│   │   └── html-demo.js
│   ├── img-input/
│   │   ├── README.md
│   │   ├── img-input.js
│   │   ├── style.css
│   │   └── test.html
│   ├── index.css
│   ├── index.js
│   ├── meter-discrete/
│   │   ├── README.md
│   │   ├── meter-discrete.js
│   │   └── style.css
│   ├── nd-calendar/
│   │   ├── README.md
│   │   ├── nd-calendar.js
│   │   ├── style.css
│   │   └── style.postcss
│   ├── nd-rating/
│   │   ├── README.md
│   │   └── nd-rating.js
│   ├── nd-slider/
│   │   ├── README.md
│   │   ├── nd-slider.css
│   │   └── nd-slider.js
│   ├── nd-switch/
│   │   ├── README.md
│   │   └── nd-switch.css
│   └── with-presets/
│       ├── README.md
│       ├── style.css
│       └── with-presets.js
├── package.json
├── postcss.config.cjs
├── style/
│   ├── forms.css
│   ├── tables.css
│   └── tokens.css
└── style.css

================================================
FILE CONTENTS
================================================

================================================
FILE: .eleventy.cjs
================================================
let markdownIt = require("markdown-it");
let markdownItAnchor = require("markdown-it-anchor");
let markdownItAttrs = require("markdown-it-attrs");

module.exports = config => {
	let data = {
		"layout": "page.njk",
		"permalink": "{{ page.filePathStem | replace('README', '') }}/index.html",
		eleventyComputed: {
			defaultTitle: data => {
				if (data.id) {
					return data.css_only? `.${data.id}` : `<${data.id}>`;
				}

				return "Nude UI: A collection of accessible, customizable, ultra-light web components";
			}
		}
	};

	for (let p in data) {
		config.addGlobalData(p, data[p]);
	}

	config.setDataDeepMerge(true);

	config.setLibrary("md", markdownIt({
			html: true,
		})
		.disable("code")
		.use(markdownItAttrs)
		.use(markdownItAnchor, {
			permalink: markdownItAnchor.permalink.headerLink(),
			level: 2,
		})
	);

	config.addFilter(
		"relative",
		page => {
			let path = page.url.replace(/[^/]+$/, "");
			let ret = require("path").relative(path, "/");

			return ret || ".";
		}
	);

	return {
		markdownTemplateEngine: "njk",
		templateFormats: ["md", "njk"],
		dir: {
			output: "."
		},
	};
};


================================================
FILE: .gitattributes
================================================
# Auto detect text files and perform LF normalization
* text=auto


================================================
FILE: .gitignore
================================================
index.html
nd-calendar/style.css
button-group/style.css

================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2021 Lea Verou

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
<header>

# Nude UI

A collection of accessible, customizable, ultra-light web components

- Using built-in controls whenever possible, web components when JS and/or extra elements are needed
- Highly customizable
- Tiny (most are ~1KB minified & compressed)

A work in progress. Try them out and [provide feedback](https://github.com/leaverou/nudeui) or move along and check back later.

</header>



<section id="components" class="language-html">

## Components

| Name | Tag | Description | Type(s) | Status |
|------|-----|-------------|-------------------|--------|
| [Switch](elements/nd-switch) | `<nd-switch>` | On/off toggle switch | CSS-only | Mature |
| [Button Group](elements/button-group) | `<button-group>` | Group of buttons for selecting one or more values out of a set of options | JS | Mature |
| [Cycle Toggle](elements/cycle-toggle) | `<cycle-toggle>` | Compact way to select one option from a group, click selects the next option | JS | Mature |
| [Discrete meter](elements/meter-discrete) | `<meter-discrete>` | Meter with discrete values shown as icons | JS | Mature |
| [Rating](elements/nd-rating) | `<nd-rating>` | Like discrete meter, but editable via hovering and clicking | JS | Mature |
| [HTML Demo](elements/html-demo) | `<html-demo>` | Display demos of HTML content alongside their source code | JS | Mature |
| [Image input](elements/img-input) | `<img-input>` | Input an image via URL, file upload, drag-and-drop, or pasting | JS | In incubation |
| [Freeform text with presets](elements/with-presets) | `<with-presets>` | A combination of a text input and a select element | JS | In incubation |
| [Calendar](elements/nd-calendar) | `<nd-calendar>` | Show dates on a calendar | JS | In incubation |
| [Data bind](elements/data-bind) | `<data-bind>` | Declaratively bind data from a source element to a target element | JS | In incubation |

</section>

## Wanna use them all?

This includes all components marked as mature:

```js
import "https://nudeui.com/elements/index.js";
```

Components still being incubated will need to be included individually.

## Failed experiments

Do not use. These have serious flaws and are likely incomplete.
They are included here only in case someone else wants to look into fixing their issues,
as well as a warning for other wanderers going down the same path.

- [Drop down](elements/drop-down)



================================================
FILE: _headers
================================================
/*
	Access-Control-Allow-Origin: *


================================================
FILE: _includes/page.njk
================================================
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>{{ title or defaultTitle }}</title>
	<link rel="icon" href="{{ page | relative }}/logo.svg">
	<link rel="stylesheet" href="{{ page | relative }}/style.css" />

	{% if css_only %}
	<link rel="stylesheet" href="{{ id }}.css">
	{% elseif id %}
	<script src="{{ id }}.js" type="module"></script>
	{% endif %}
	{{ includes | safe }}
</head>
<body>

<nav>
	<a href="#installation">Installation</a>
	<a href="https://github.com/LeaVerou/nudeui/tree/main/elements/{{ id }}">GitHub</a>
</nav>

{{ content | safe }}

{% if id %}
<section id="installation">

<h2>Installation</h2>

{% if css_only %}

<p>This is a CSS-only component. You can just import it straight into your CSS file:

<pre class="language-css"><code>@import url('https://nudeui.com/elements/{{ id }}/{{ id }}.css');</code></pre>

<p>Then use <code>class="{{ id }}"</code> on the types of elements described above.</p>

{% else %}

<p>Just include the component's JS file and you're good:

<pre class="language-html"><code>&lt;script src="https://nudeui.com/elements/{{ id }}/{{ id }}.js" type="module">&lt;/script>
</code></pre>

<p>In case you want to link to local files: CSS is fetched automatically, and assumed to be in the same directory as the JS file.
{% endif %}

</section>
{% endif %}

<script src="{{ page | relative }}/assets/global.js" type="module"></script>

</body>
</html>

================================================
FILE: _redirects
================================================
/button-group/* /elements/button-group/:splat 301
/meter-discrete/* /elements/meter-discrete/:splat 301
/with-presets/* /elements/with-presets/:splat 301
/cycle-toggle/* /elements/cycle-toggle/:splat 301
/nd-:tag/* /elements/nd-:tag/:splat 301

================================================
FILE: assets/global.js
================================================
// Website scripts
import "../elements/index.js";
import "https://prismjs.com/prism.js";
import HTMLDemo from "../elements/html-demo/html-demo.js";

if (!document.documentElement.matches(".no-home-link")) {
	let h1 = document.querySelector("h1");

	if (h1 && !h1.parentNode.querySelector(".home")) {
		h1.insertAdjacentHTML("beforebegin", `<a href="/index.html" class="home">Nude UI</a>`);
	}
}

HTMLDemo.wrapAll();

================================================
FILE: elements/button-group/README.md
================================================
---
id: button-group
title: <button-group>
includes: '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.81/dist/themes/light.css" /><script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.81/dist/shoelace.js"></script>'
---

<header>

# `<button-group>`

Group of exclusive push buttons

</header>

## Features

- Uses existing button styling present in the page
- Uses [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) to work like a built-in form element
- Accessible
- Ultra light (3KB **unminified** and **uncompressed**!)


## Examples

Basic, no selected option:

```html
<button-group>
	<button>Design</button>
	<button>Preview</button>
</button-group>
```

Providing values:

```html
<button-group id="temporal" oninput="out.textContent = this.value">
	<button value="">None</button>
	<button value="d">Dates</button>
	<button value="t">Times</button>
	<button value="dt">Dates & Times</button>
</button-group>
<output id="out"></output>
```

Pre-selected state via `aria-pressed`:

```html
<button-group>
	<button>Design</button>
	<button aria-pressed="true">Preview</button>
</button-group>
```

Multiple:

```html
<button-group multiple oninput="button_multiple_value.textContent = this.value">
	<button value="b"><span style="font-weight: bold">B</span></button>
	<button value="i"><span style="font-style: italic">I</span></button>
	<button value="u"><span style="text-decoration: underline">U</span></button>
</button-group>
<output id="button_multiple_value"></output>
```

Participates in form submission (requires [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) support):

```html
<form action="about:blank" target="_blank">
	<button-group name="favorite_letter">
		<button>A</button>
		<button aria-pressed="true">B</button>
		<button>C</button>
		<button>D</button>
		<button>E</button>
		<button>F</button>
		<button>G</button>
	</button-group>
	<button type=submit>Submit</button>
</form>
```

Vertical

```html
<button-group name="type" vertical>
	<button value="garlic" aria-pressed="true">Garlic</button>
	<button value="msg">MSG</button>
	<button value="salt">Salt</button>
</button-group>
```

Separate

```html
<button-group name="type" separate>
	<button>Salt</button>
	<button>Pepper</button>
	<button>Garlic</button>
	<button>Cumin</button>
	<button>Coriander</button>
	<button>Dill</button>
	<button>Parsley</button>
	<button>Turmeric</button>
</button-group>
```

Dynamically setting `element.value`:

```html
<button-group id="group1">
	<button>A</button>
	<button aria-pressed="true">B</button>
	<button>C</button>
</button-group>
<button onclick="group1.value = 'C'">Select C</button>
```

Dynamically adding `aria-pressed` attribute:

```html
<button-group id="group2">
	<button>A</button>
	<button aria-pressed="true">B</button>
	<button>C</button>
</button-group>
<button onclick="group2.children[2].setAttribute('aria-pressed', 'true')">Select C</button>
```

Dynamically adding options:

```html
<button-group id="group3">
	<button>1</button>
	<button>2</button>
	<button aria-pressed="true">3</button>
</button-group>
<button onclick="window.counter ||= 3; group3.insertAdjacentHTML('beforeend', `<button aria-pressed=true>${++counter}</button>`)">Add option</button>
```

[WIP](https://twitter.com/LeonieWatson/status/1547544701036888065):
`<button-group>` has an implicit ARIA Role of `region`, so adding an `aria-label` should make it work as a landmark out of the box
(requires [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) support):

```html
<button-group aria-label="View switcher">
	<button>Design</button>
	<button aria-pressed="true">Preview</button>
</button-group>
```

Regular labels should work too (requires [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) support):

```html
<label for="view-switcher">View:</label>
<button-group id="view-switcher">
	<button>Design</button>
	<button aria-pressed="true">Preview</button>
</button-group>
```

You don't even need to use an actual `<button>`, [custom elements](https://shoelace.style/components/button?id=css-parts)
should work too
(presentation needs work, but functionality is there):

```html
<style>
sl-button[aria-pressed="true"]::part(base) {
	background: var(--sl-color-primary-100);
	border-color: var(--sl-color-primary-300);
}
</style>
<button-group>
	<sl-button>1</sl-button>
	<sl-button aria-pressed="true">2</sl-button>
	<sl-button>3</sl-button>
</button-group>
```

================================================
FILE: elements/button-group/button-group.js
================================================
export default class ButtonGroup extends HTMLElement {
	#internals
	#observer

	constructor () {
		super();

		this.attachShadow({ mode: "open" });
		this.shadowRoot.innerHTML = `<style>@import "${new URL("style.css", import.meta.url)}";</style><slot></slot>`;

		this.#internals = this.attachInternals?.();

		if (this.#internals) {
			// https://twitter.com/LeonieWatson/status/1545788775644667904
			this.#internals.role = "region";
		}

		this.addEventListener("click", evt => {
			let previousValue = this.value + "";

			let button = evt.target;

			while (button && button.parentNode !== this) {
				button = button.parentNode;
			}

			if (button) {
				this.#buttonChanged(button);

				if (previousValue !== this.value + "") {
					let evt = new InputEvent("input", {bubbles: true});
					this.dispatchEvent(evt);
				}
			}
		});

		this.#observer = new MutationObserver(mutations => {
			mutations = mutations.filter(m => {
				if (m.target === this) {
					return true;
				}
				else if (m.target.parentNode === this) {
					if (m.type === "childList") {
						return true;
					}
					else if (m.oldValue !== m.target.getAttribute("aria-pressed")) {
						return true;
					}
				}

				return false;
			});

			if (mutations.length > 0) {
				this.#buttonChanged();
			}
		});
	}

	#buttonChanged (button) {
		if (this.multiple) {
			this.#value ||= [];

			if (button) {
				let pressed = button.getAttribute("aria-pressed") === "true";
				let value = getValue(button);

				if (pressed) {
					this.#value = this.#value.filter(v => v !== value);
				}
				else {
					this.#value.push(value);
				}
			}

			this.value = this.#value;
		}
		else {
			this.value = getValue(button ?? this.pressedButton);
		}
	}

	get name () {
		return this.getAttribute("name");
	}

	set name (value) {
		this.setAttribute("name", value);
	}

	get multiple () {
		return this.hasAttribute("multiple");
	}

	set multiple (value) {
		if (value) {
			this.setAttribute("multiple", "");
		}
		else {
			this.removeAttribute("multiple");
		}
	}

	#value;

	get value () {
		return this.#value;
	}

	set value (value) {
		this.#value = value;

		this.#internals?.setFormValue(value);

		for (let button of this.children) {
			if (!button.hasAttribute("type")) {
				button.type = "button";
			}

			let buttonValue = getValue(button);
			let pressed = this.multiple ? this.#value.includes(buttonValue) : this.#value === buttonValue;

			let ariaPressed = pressed.toString();

			if (ariaPressed !== button.getAttribute("aria-pressed")) {
				button.setAttribute("aria-pressed", ariaPressed);
			}
		}
	}

	get pressedButtons () {
		return [...this.querySelectorAll(`:scope > [aria-pressed="true"]`)];
	}

	get pressedButton () {
		return this.pressedButtons.at(-1);
	}

	get labels() {
		return this.#internals?.labels;
	}

	connectedCallback () {
		this.#buttonChanged();

		this.#observer.observe(this, {
			attributeFilter: ["aria-pressed"],
			attributeOldValue: true,
			childList: true,
			subtree: true,
		});
	}

	disconnectedCallback () {
		this.#observer.disconnect();
	}

	static get formAssociated() {
		return true;
	}
}

function getValue(button) {
	if (!button) {
		return null;
	}

	if (button.hasAttribute("value")) {
		return button.value;
	}
	else {
		return button.textContent.trim();
	}
}

customElements.define("button-group", ButtonGroup);


================================================
FILE: elements/button-group/style.css
================================================
:host {
	display: inline-flex

	/* 0 specificity default pressed styles */
}

:host ::slotted(:where([aria-pressed="true"])) {
		background: hsl(220 10% 90% / .9);
		box-shadow: 0 .1em .2em hsl(0 0% 0% / .2) inset, 0 0 0 2em hsl(220 10% 50% / .15) inset;
	}

:host([separate]) {
	flex-wrap: wrap
}

:host([separate]) ::slotted(*) {
		flex: 1;
		margin: .2em;
	}

:host(:not([separate])) {
	margin: .5em
}

:host(:not([separate])) ::slotted(*) {
		margin: 0;
	}

:host(:not([vertical]):not([separate])) {
	justify-content: center;
	align-items: stretch
}

:host(:not([vertical]):not([separate])) slot::slotted(*) {
		flex: 1;
	}

:host(:not([vertical]):not([separate])) ::slotted(:not(:last-of-type)) {
		border-top-right-radius: 0 !important;
		border-bottom-right-radius: 0 !important;
		border-inline-end: none !important;
	}

:host(:not([vertical]):not([separate])) ::slotted(:not(:first-of-type)) {
		border-top-left-radius: 0 !important;
		border-bottom-left-radius: 0 !important;
	}

:host([vertical]:not([separate])) {
	flex-direction: column;
	align-items: stretch
}

:host([vertical]:not([separate])) ::slotted(*) {
		text-align: inline-start;
	}

:host([vertical]:not([separate])) ::slotted(:not(:last-of-type)) {
		border-bottom-left-radius: 0 !important;
		border-bottom-right-radius: 0 !important;
		border-block-end: none !important;
	}

:host([vertical]:not([separate])) ::slotted(:not(:first-of-type)) {
		border-top-left-radius: 0 !important;
		border-top-right-radius: 0 !important;
	}

================================================
FILE: elements/button-group/style.postcss
================================================
:host {
	display: inline-flex;

	/* 0 specificity default pressed styles */
	& ::slotted(:where([aria-pressed="true"])) {
		background: hsl(220 10% 90% / .9);
		box-shadow: 0 .1em .2em hsl(0 0% 0% / .2) inset, 0 0 0 2em hsl(220 10% 50% / .15) inset;
	}
}

:host([separate]) {
	flex-wrap: wrap;

	& ::slotted(*) {
		flex: 1;
		margin: .2em;
	}
}

:host(:not([separate])) {
	margin: .5em;

	& ::slotted(*) {
		margin: 0;
	}
}

:host(:not([vertical]):not([separate])) {
	justify-content: center;
	align-items: stretch;

	& slot::slotted(*) {
		flex: 1;
	}

	& ::slotted(:not(:last-of-type)) {
		border-top-right-radius: 0 !important;
		border-bottom-right-radius: 0 !important;
		border-inline-end: none !important;
	}

	& ::slotted(:not(:first-of-type)) {
		border-top-left-radius: 0 !important;
		border-bottom-left-radius: 0 !important;
	}
}

:host([vertical]:not([separate])) {
	flex-direction: column;
	align-items: stretch;

	& ::slotted(*) {
		text-align: inline-start;
	}

	& ::slotted(:not(:last-of-type)) {
		border-bottom-left-radius: 0 !important;
		border-bottom-right-radius: 0 !important;
		border-block-end: none !important;
	}

	& ::slotted(:not(:first-of-type)) {
		border-top-left-radius: 0 !important;
		border-top-right-radius: 0 !important;
	}
}

================================================
FILE: elements/cycle-toggle/README.md
================================================
---
title: <cycle-toggle>
id: cycle-toggle
---

<header>

# `<cycle-toggle>`

Click to cycle through a variety of options

</header>



## Features

- Uses [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) to work like a built-in form element
- Accessible (?)
- Tiny (3K **uncompressed** and **unminified**!)


## Examples

Basic, no selected option:

```html
<label for="mood">Mood:</label>
<cycle-toggle id="mood">
	<span>😔</span>
	<span>😕</span>
	<span>😐</span>
	<span>🙂</span>
	<span>😀</span>
</cycle-toggle>
```

Pre-selected option:

```html
<label for="mood2">Mood:</label>
<cycle-toggle id="mood2">
	<span>😔</span>
	<span>😕</span>
	<span>😐</span>
	<span aria-selected="true">🙂</span>
	<span>😀</span>
</cycle-toggle>
```

With values (any child element works):

```html
<label for="mood3">Mood:</label>
<cycle-toggle id="mood3">
	<data value="sad">😔</data>
	<data value="neutral">😐</data>
	<data value="happy" aria-selected="true">🙂</data>
	<data value="elated">😀</data>
</cycle-toggle>
```

With styles:

```html
<cycle-toggle>
	<data value="" style="opacity: .4">👍🏼</data>
	<data value="1">👍🏼</data>
</cycle-toggle>
```

Readonly:

```html
<cycle-toggle id="readonly_toggle" readonly>
	<span>😔</span>
	<span>😕</span>
	<span>😐</span>
	<span aria-selected="true">🙂</span>
	<span>😀</span>
</cycle-toggle>
<button onclick="readonly_toggle.readonly = !readonly_toggle.readonly">Toggle readonly</button>
```

Set `element.value`:

```html
<cycle-toggle id="toggle_rate">
	<data value="1">👍🏼</data>
	<data value="-1">👎🏼</data>
</cycle-toggle>
<button onclick="toggle_rate.value = 1">Select 👍🏼</button>
<button onclick="toggle_rate.value = -1">Select 👎🏼</button>
```

Dynamic `aria-selected`:

```html
<cycle-toggle id="dynamic_selected">
	<span>😔</span>
	<span>😕</span>
	<span>😐</span>
	<span>🙂</span>
	<span>😀</span>
</cycle-toggle>
<button onclick="dynamic_selected.children[3].setAttribute('aria-selected', 'true')">Select 🙂</button>
```



================================================
FILE: elements/cycle-toggle/cycle-toggle.js
================================================
if (!HTMLSlotElement.prototype.assign) {
	// Include Imperative Slot Assignment polyfill
	await import("https://unpkg.com/dom-slot-assign");
}

export default class CycleToggle extends HTMLElement {
	#internals
	#observer
	#selectedSlot

	constructor () {
		super();

		this.attachShadow({
			mode: "open",
			slotAssignment: "manual",
			delegatesFocus: true,
		});
		this.shadowRoot.innerHTML = `<style>@import "${new URL("style.css", import.meta.url)}";</style><button><slot name="selected"></slot></button>`;
		this.#selectedSlot = this.shadowRoot.querySelector("slot");

		this.#internals = this.attachInternals?.();

		this.addEventListener("click", evt => {
			if (!this.hasAttribute("readonly")) {
				this.cycle();
			}
		});
	}

	get name () {
		return this.getAttribute("name");
	}

	set name (value) {
		this.setAttribute("name", value);
	}

	get readonly () {
		return this.hasAttribute("readonly");
	}

	set readonly (value) {
		if (value) {
			this.setAttribute("readonly", "");
		}
		else {
			this.removeAttribute("readonly");
		}
	}

	#value;

	get value () {
		return this.#value;
	}

	set value (value) {
		value = value + "";
		this.#value = value;

		// TODO should we reject unrecognized values or be lossless?
		this.#internals?.setFormValue(value);

		for (let option of this.children) {
			this.#setSelected(option, getValue(option) === value);
		}
	}

	get selectedOptions () {
		return [...this.querySelectorAll(`:scope > [aria-selected="true"]`)];
	}

	get selectedOption () {
		return this.selectedOptions?.at(-1) || this.firstElementChild;
	}

	get labels() {
		return this.#internals?.labels;
	}

	// Select the next option, or the first if there is no next option.
	cycle () {
		this.#unobserve();
		let selectedOption = this.selectedOption;
		this.#setSelected(this.selectedOption, false);

		let nextOption = selectedOption.nextElementSibling || this.firstElementChild;
		this.#setSelected(nextOption, true);
		this.dispatchEvent(new InputEvent("input"));
		this.#observe();
	}

	#setSelected (option, selected = false) {
		if (!option) {
			return;
		}

		if (selected) {
			if (option.getAttribute("aria-selected") !== "true") {
				option.setAttribute("aria-selected", "true");
			}

			this.#value = getValue(this.selectedOption);
		}
		else {
			option.removeAttribute("aria-selected");

			if (this.#value === getValue(option)) {
				this.#value = getValue(this.selectedOption);
			}
		}

		this.#selectedSlot.assign(this.selectedOption);
	}

	connectedCallback () {
		this.value = getValue(this.selectedOption);

		this.#observe();
	}

	#observe () {
		this.#observer = this.#observer || new MutationObserver(mutations => {
			this.value = getValue(this.selectedOption);
		});

		this.#observer.observe(this, {
			attributeFilter: ["aria-selected", "value"],
			attributeOldValue: true,
			childList: true,
			subtree: true,
		});
	}

	#unobserve() {
		if (this.#observer) {
			this.#observer.takeRecords();
			this.#observer.disconnect();
		}
	}

	disconnectedCallback () {
		this.#unobserve();
	}

	static get formAssociated() {
		return true;
	}
}

function getValue(element) {
	if (!element) {
		return null;
	}

	if (element.hasAttribute("value")) {
		return element.getAttribute("value");
	}
	else {
		return element.textContent.trim();
	}
}

customElements.define("cycle-toggle", CycleToggle);

================================================
FILE: elements/cycle-toggle/style.css
================================================
:host {
	cursor: pointer;
	user-select: none;
}

button {
	all: unset;
	outline: revert;
}

================================================
FILE: elements/data-bind/Observer.js
================================================

import {
	interceptPropertyWrites,
	flushMutationObserver,
} from "./util.js";
import Recipe from "./Recipe.js";

let self = class Observer {
	constructor (element, recipes) {
		this.element = element;
		this.recipes = recipes.map(property => new Recipe(property));
		this.recipe = new Recipe(...this.recipes);
	}

	observe (fn) {
		if (this.callback) {
			this.unobserve();
		}

		this.callback = fn;

		if (this.recipe.mutation) {
			this.mutationObserver ??= new MutationObserver(records => this.changed({type: "mutation", records}));
			this.mutationObserver.observe(this.element, this.recipe.mutation);
		}

		if (this.recipe.parentMutation) {
			let parent = this.element.parentElement;
			this.parentMutationObserver ??= new MutationObserver(records => {
				if (parent !== this.element.parentElement) {
					// Parent changed

				}
				this.changed({type: "mutation", records});
			});
			this.parentMutationObserver.observe(parent, this.recipe.parentMutation);
		}

		if (this.recipe.events) {
			for (let event of this.recipe.events) {
				this.element.addEventListener(event, this.changed);
			}
		}

		if (this.recipe.resize) {
			this.resizeObserver ??= new ResizeObserver(entries => this.changed({type: "resize", entries}));
			this.resizeObserver.observe(this.element);
		}

		// Observe direct property writes
		this.descriptors = this.recipe.properties.map(property =>
			interceptPropertyWrites(
				this.element,
				property,
				(value, oldValue) => this.changed({type: "set", property, value, oldValue}),
			)
		);
	}

	unobserve () {
		flushMutationObserver(this.mutationObserver, records => this.changed({type: "mutation", records}));
		flushMutationObserver(this.parentMutationObserver, records => this.changed({type: "mutation", records}));

		this.resizeObserver?.disconnect();

		if (this.recipe.events) {
			for (let event of this.recipe.events) {
				this.element.removeEventListener(event, this.changed);
			}
		}

		if (this.descriptors?.length) {
			for (let {property, oldDescriptor} of this.descriptors) {
				uninterceptPropertyWrites(this.element, property, oldDescriptor);
			}
		}
	}

	changed (change) {
		this.callback?.(change);
	}
}

export default self;

================================================
FILE: elements/data-bind/README.md
================================================
---
id: data-bind
---

<header>

# `<data-bind>`

An element for propagating data changes between elements.

</header>



## Features

- TBD


## Examples

### Basic

Display slider value:

```html
<data-bind>
	<input type="range" data-bind-source></textarea>
	<span data-bind="value"></span>
</data-bind>
```

Show character count:

```html
<data-bind>
	<textarea data-bind-source></textarea>
	<span data-bind="value.length"></span>
</data-bind>
```





================================================
FILE: elements/data-bind/Recipe.js
================================================
import properties from "./properties.js";

const self = class ObserveRecipe {
	events = [];
	attributes = [];
	properties = [];
	text = false;
	deep = false;
	children = false;
	size = false;

	/**
	 * @type {Recipe}
	 */
	parent = null;

	constructor (...specs) {
		this.add(...specs);
	}

	add (...recipes) {
		for (let recipe of recipes) {
			if (typeof recipe === "string") {
				recipe = getRecipe(recipe);
			}
console.log(recipe)
			if (recipe.property) {
				this.properties.push(recipe.property);
			}

			if (this.attributes !== true) {
				if (recipe.attributes === true) {
					this.attributes = true;
				}
				else {
					if (recipe.attribute) {
						this.attributes.push(recipe.attribute);
					}

					if (recipe.attributes?.length > 0) {
						this.attributes.push(...recipe.attributes);
					}
				}
			}

			this.text ||= recipe.text;
			this.deep ||= recipe.deep;
			this.children ||= recipe.children;

			let events = recipe.events ?? recipe.event;

			if (events) {
				events = Array.isArray(events) ? events : [events];
			}
			if (recipe.event) {
				this.events.push(recipe.event);
				this.events.push(...events);
			}

			if (recipe.size) {
				this.size ||= recipe.size;
			}

			if (recipe.parent) {
				if (this.parent) {
					this.parent.add(recipe.parent);
				}
				else {
					this.parent = new Recipe(recipe.parent);
				}
			}
		}

		this.mutation = this.#getMutation();
	}

	#getMutation () {
		let mutation = {};

		if (this.children) {
			mutation.childList = true;
		}

		if (this.text) {
			mutation.characterData = true;
		}

		if (this.deep) {
			mutation.subtree = true;
		}

		if (this.attributes === true || this.attributes?.length > 0) {
			mutation.attributes = true;

			if (this.attributes?.length > 0) {
				mutation.attributeFilter = this.attributes;
			}
		}

		return Object.keys(mutation).length === 0 ? null : mutation;
	}
}

function getRecipe (propertyOrAttribute) {
	if (propertyOrAttribute.startsWith("@")) {
		// Only attribute
		return { attribute: propertyOrAttribute.slice(1) };
	}

	let property = propertyOrAttribute.replace(/^\./, "");

	if (properties[property]) {
		return {
			property,
			...properties[property]
		};
	}

	// Search in also fields as well
	for (let key in properties) {
		if (properties[key].also?.includes(property)) {
			return {
				property,
				...properties[key]
			};
		}
	}

	// Still nothing, assume it's an attribute or arbitrary data property
	attribute = property.toLowerCase();

	if (attribute === property) {
		// Property is all-lowercase, if an attribute exists it will be the same
		return { property, attribute };
	}

	// Property is camelCase, there are two possibilities
	// 1. The attribute is all-lowercase
	// 2. The attribute is kebab-case
	return {
		property,
		attributes: [
			attribute,
			property.replace(/[A-Z]/g, "-$&").toLowerCase(),
		]
	}
}

export default self;

================================================
FILE: elements/data-bind/data-bind.js
================================================
import Observer from "./Observer.js";

const tagName = "data-bind";

let self = class DataBindlement extends HTMLElement {
	_slots = {};

	constructor () {
		super();
	}

	connectedCallback () {
		this.configure();
		// this.update();
	}

	configure () {
		if (this.hasAttribute("source")) {
			this.source = this.getAttribute("source");

			if (["window", "document"].includes(this.source)) {
				this.sourceElement = window[this.source];
			}
			else if (["body", "head"].includes(this.source)) {
				this.sourceElement = document[this.source];
			}
			else {
				let scope = this;
				while (!this.sourceElement && scope) {
					this.sourceElement = scope.querySelector(this.source);
					scope = scope.parentElement;
				}
			}
		}
		else {
			this.sourceElement = this.querySelector(":scope > [data-bind-source]");
		}

		this.destElements = [...this.querySelectorAll(":scope > :not([data-bind-source])")];

		if (!this.sourceElement || this.destElements.length === 0) {
			return;
		}

		let paths = this.destElements
			.filter(element => element.dataset.bind !== null) // Only elements with data-bind attribute
			.map(element => element.dataset.bind ?? "textContent"); // If data-bind is empty, use textContent (or should it be innerHTML?)
		let properties = paths.map(path => path.split(".")[0]);

		this.observer = new Observer(this.sourceElement, properties);

		this.observer.observe(change => {
			this.update(change);
		})
	}

	update ({ force, property } = {}) {
		// debugger;
		// this.destElements.forEach(element => {
		// 	if (!property || element.matches(`[data-bind="${property}"], [data-bind^="${property}."]`)) {
		// 		this.updateElement(element);
		// 	}

		// });
	}

	updateElement (element) {
		if (!element.dataset.bind) {
			return;
		}

		let path = element.dataset.bind.split(".");
		let property = path[0];

		if (element.dataset.bind === property) {
			// Single property
			element.textContent = this.sourceElement[property];
		}
		else if (element.dataset.bind?.startsWith(`${property}.`)) {

			let obj = this.sourceElement;
			let i = 0;

			while (obj !== null && obj !== undefined && i < path.length - 1) {
				obj = obj?.[path[i++]];
			}

			element.textContent = obj[path[i]];
		}
	}

	static observedAttributes = ["source"];

	attributeChangedCallback (name, oldValue, newValue) {
		if (oldValue !== newValue) {
			this[name] = newValue;
		}
	}
}

customElements.define(tagName, self);

export default self;



================================================
FILE: elements/data-bind/properties.js
================================================
let properties = {
	textContent: {
		children: true,
		text: true,
		deep: true,
	},
	innerHTML: {
		children: true,
		text: true,
		deep: true,
		attributes: true,

		also: [
			"outerHTML",
		],
	},
	value: {
		event: "input",

		also: [
			"checked",
			"valueAsNumber",
			"valueAsDate",
		],
	},
	defaultValue: {
		attribute: "value",
		text: true, // for <textarea>
	},
	defaultChecked: {
		attribute: "checked",
	},
	className: {
		attribute: "class",
	},
	classList: {
		attribute: "class",
	},
	offsetWidth: {
		size: true,

		also: [
			"offsetHeight",
			"clientWidth",
			"clientHeight",
		],
	},
	parentNode: {
		parent: {
			children: true,
		},

		also: [
			"parentElement",
			"nextElementSibling",
			"previousElementSibling",
		],
	},
	nextSibling: {
		parent: {
			children: true,
			text: true,
		},

		also: [
			"previousSibling",
		],
	},
	childNodes: {
		children: true,
		text: true,

		also: [
			"firstChild",
			"lastChild",
		],
	},
	children: {
		children: true,

		also: [
			"firstElementChild",
			"lastElementChild",
			"childElementCount",
		],
	},
	scrollTop: {
		event: "scroll",

		also: [
			"scrollLeft",
		],
	},
	attributes: {
		attributes: true,
	},
};

export default properties;

================================================
FILE: elements/data-bind/util.js
================================================
/**
 * Get a property descriptor from an object or its prototype chain.
 * @param {*} object
 * @param {*} key
 * @returns
 */
export function getPropertyDescriptor (object, key) {
	while (object) {
		descriptor = Object.getOwnPropertyDescriptor(object, key);

		if (descriptor) {
			return {object, descriptor};
		}

		object = Object.getPrototypeOf(object);
	}
}

export function interceptPropertyWrites (obj, property, callback) {
	let {descriptor: d, object} = getPropertyDescriptor(obj, property);

	let value = (d && "value" in d) ? d.value : obj[property];

	let descriptor = {
		get: d.get ?? function() {
			return value;
		},
		set (newValue) {
			let oldValue = obj[property];
			if (d?.set) {
				d.set.call(this, newValue);
			}
			else {
				obj[property] = value = newValue;
			}
			callback({ type: "set", property, value, oldValue });
		},
		configurable: true,
		enumerable: d.enumerable ?? true,
	}

	Object.defineProperty(obj, property, descriptor);

	return { descriptor, originalDescriptor: d, inherited: object !== obj };
}

export function uninterceptPropertyWrites (obj, property, descriptor) {
	if (!descriptor.get) {
		// Data property
		let currentValue = obj[property];
		descriptor = {...descriptor, value: currentValue};
	}

	Object.defineProperty(obj, property, descriptor);
}

export function flushMutationObserver (mutationObserver, callback) {
	if (!mutationObserver) {
		return;
	}

	let records = mutationObserver.takeRecords();
	if (records.length > 0) {
		callback(records);
	}
	mutationObserver.disconnect();
}

================================================
FILE: elements/drop-down/README.md
================================================
---
title: <drop-down>
id: drop-down
---
<header>

# `<drop-down>`

Drop-down menu that performs actions when items are clicked

</header>



<section class="failed">

## Failed experiment

This is a failed experiment. Do not use.
It is only posted here, in the hopes that someone else may be able to fix its issues.

### Issues

- It is impossible to reliably detect when the `<select>` is closed,
so when the menu is closed with no selection, `aria-pressed` lingers until the button is unfocused.

</section>

## Features

- Uses a regular `<select>` menu, ensuring it works well on a variety of devices
- Accessible (?)
- Tiny (3K **uncompressed** and **unminified**!)


## Examples

Basic:

```html
<drop-down>
	<button>Click me</button>
	<select>
		<option>One</option>
		<option>Two</option>
		<option onclick="alert('hi')">Three</option>
	</select>
</drop-down>
```

With customized menu label:

```html
<drop-down>
	<button>+</button>
	<select aria-label="Create new…">
		<option>Document</option>
		<option>Sheet</option>
		<option onclick="alert('hi')">Picture</option>
	</select>
</drop-down>
```



================================================
FILE: elements/drop-down/drop-down.js
================================================
if (!HTMLSlotElement.prototype.assign) {
	// Include Imperative Slot Assignment polyfill
	await import("https://unpkg.com/dom-slot-assign");
}

export default class DropDown extends HTMLElement {
	#internals
	#triggerSlot
	#menuSlot
	#observer
	#resizeObserver
	#menu
	#trigger

	constructor () {
		super();

		this.attachShadow({
			mode: "open",
			slotAssignment: "manual",
			delegatesFocus: true,
		});
		this.shadowRoot.innerHTML = `
		<style>@import "${new URL("style.css", import.meta.url)}";</style>
		<div id="trigger-container">
			<slot name="trigger"></slot>
		</div>
		<slot name="menu"></slot>
		`;
		this.#triggerSlot = this.shadowRoot.querySelector("slot[name=trigger]");
		this.#menuSlot = this.shadowRoot.querySelector("slot[name=menu]");
		this.#trigger = this.shadowRoot.querySelector("#trigger");

		this.#childrenChanged();

		// this.#internals = this.attachInternals?.();

		this.#observe();

		this.addEventListener("click", evt => this.#handleEvent(evt));
		this.addEventListener("input", evt => this.#handleEvent(evt));
		this.addEventListener("focusin", evt => this.#handleEvent(evt));
		this.addEventListener("focusout", evt => this.#handleEvent(evt));
	}

	get labels() {
		return this.#internals?.labels;
	}

	#handleEvent (evt) {
		if (evt.type === "input") {
			if (evt.target === this.#menu) {
				this.#trigger.removeAttribute("aria-pressed");
				let item = evt.target.selectedOptions[0];
				let value = evt.target.value;

				this.dispatchEvent(new CustomEvent("dropdownselect", {
					bubbles: true,
					detail: { item, value }
				}));

				// Dispatch individual click event to selected option
				item.dispatchEvent(new MouseEvent("click"));

				// Reset selected option
				this.#menu.options[0].selected = true;

				// Stop input event from propagating further
				evt.stopPropagation();
			}
		}
		else if (evt.type === "click") {
			if (evt.target === this.#menu) {
				this.#trigger.setAttribute("aria-pressed", "true");
				this.ownerDocument.addEventListener("click", evt => this.#handleEvent(evt), {once: true});
			}
			if (!this.contains(evt.target)) {
				this.#trigger.removeAttribute("aria-pressed");
			}
		}
		else if (evt.type === "focusin") {

		}
		else if (evt.type === "focusout") {
			if (evt.target === this.#menu) {
				this.#trigger.removeAttribute("aria-pressed");
			}
		}
	}

	#observe () {
		this.#observer = this.#observer || new MutationObserver(mutations => {
			// An element can't be in both slots at once, so once the <select> is assigned
			// to the select slot, it will be removed from the trigger slot
			this.#childrenChanged();
		});

		this.#observer.observe(this, {
			childList: true,
		});
	}

	#childrenChanged () {
		let select = this.querySelectorAll(":scope > select")[0];
		let trigger = this.querySelectorAll(":scope > :not(select)")[0];
		this.#triggerSlot.assign(trigger);
		this.#menuSlot.assign(select);

		this.#trigger = trigger;
		this.#menu = select;

		let label = this.#menu.ariaLabel || "Select:";
		this.#menu.insertAdjacentHTML("afterbegin", `<option style="opacity: .5" disabled selected>${label}</option>`);
		this.#menu.size = 0; // make sure it has a popup
		this.#menu.multiple = false;

		this.#trigger.ariaHasPopup = "true";

		// Observe trigger size so we can set <select> size appropriately
		this.#resizeObserver = this.#resizeObserver || new ResizeObserver(entries => {
			for (let entry of entries) {
				this.style.setProperty("--trigger-width", `${entry.borderBoxSize.width}px`);
			}
		});

		this.#resizeObserver.observe(this.#trigger);
	}

	// #unobserve() {
	// 	if (this.#observer) {
	// 		this.#observer.takeRecords();
	// 		this.#observer.disconnect();
	// 	}
	// }

	// static get formAssociated() {
	// 	return true;
	// }
}

customElements.define("drop-down", DropDown);

================================================
FILE: elements/drop-down/style.css
================================================
:host {
	display: inline-grid;
	grid-template: auto / auto;
	overflow: hidden;
}

slot[name=menu]::slotted(select),
#trigger-container {
	grid-column: 1;
	grid-row: 1;
}

slot[name=menu]::slotted(select) {
	opacity: 0 !important;
	transform: scaleY(2);
	transform-origin: top;
	min-width: 0 !important;
	width: var(--trigger-width, 100%) !important;
}


slot[name=trigger]::slotted(button) {
	/*padding-right: 1.5rem;
	-webkit-appearance: none;
	background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">\
		<path d="m 20 10 L 50 40 L 80 10" fill="none" stroke="black" stroke-opacity=".4" stroke-width="13" stroke-linejoin="round" stroke-linecap="round" />\
	</svg>');
	background-position: calc(100% + 1rem) 55%;
	background-size: auto .4rem;
	background-repeat: no-repeat;
	background-origin: content-box;*/
	list-style: "N"
}

================================================
FILE: elements/html-demo/README.md
================================================
---
id: html-demo
---

<header>

# `<html-demo>`

An element for displaying HTML content alongside its source code.
Great for documenting web components!

</header>



## Features

- Provide a code snippet and it will create the demo, or provide the demo and it will create the code snippet.
- Demo inherits page styles but you can optionally isolate
- Executes `<script>` tags (in code-first mode)

### Roadmap

From most to least likely to be implemented:

- More style customization (parts, CSS properties)
- Option to collapse code by default
- Open in CodePen button (need a way to specify dependencies)
- Structured attribute values
- Work with CSS and JS snippets (without having to include them in HTML markup)
- Different layouts
- Editable examples


## Examples

### Basic

Code-first:

```html
<html-demo>
	<pre class="language-html"><code>
		&lt;input type=range>
	</code></pre>
</html-demo>
```

Content-first:

```html
<html-demo id=foo>
	<input type=range>
</html-demo>
```

### Adjusters

Only `font-size` for now:

```html
<html-demo adjust="font-size">
	<button>Click me</button>
</html-demo>
```

Use `--font-size-min` and `--font-size-max` to set the range (default: `50%` to `300%`).

### Style isolation

By default the demo is rendered in the light DOM, and thus inherits the normal page styles.
In most cases, this is what you want.
If not, you can use the `isolate` attribute to use the UA’s default styles.
This works with both modes:

<table>
<thead>
	<tr>
		<th>Content-first</th>
		<th>Code-first</th>
	</tr>
</thead>
<tr>
<td>

```html
<html-demo isolate>
	<button>Click me</button>
</html-demo>
```
</td>
<td>

```html
<html-demo isolate>
	<pre class="language-html"><code>
		&lt;button>Click me&lt;/button>
	</code></pre>
</html-demo>
```
</td>
</tr>
</table>




### Execute script

In code-first mode, any `<script>` elements will also be executed:

```html
<html-demo>
	<pre class="language-html"><code>
		&lt;button>Click me&lt;/button>
		&lt;script>{
			let button = document.currentScript.previousElementSibling;
			// button.onclick = e =>
			button.textContent = "Hi from script!";
		}&lt;/script>
	</code></pre>
</html-demo>
```

#### Executing scripts in isolated mode { #script-isolate }

Do note that there is **limited utility in doing this in isolated mode**, since
there is no (easy) way to get a reference to any of the other elements in the demo:
- [`document.currentScript` is `null` in shadow trees](https://html.spec.whatwg.org/multipage/dom.html#dom-document-currentscript-dev)
- All `document.querySelector*()` or `document.getElementBy*()` calls will query the light DOM
- Ids will not create variables
- `this` will be the global `window` object or `undefined` in module scripts.


```html
<html-demo isolate>
	<pre id="isolated-demo" class="language-html"><code>
		&lt;p>This demo has no actual content, but scroll down a bit 👇🏼 &lt;/p>
		&lt;script>{
			let pre = document.getElementById("isolated-demo");
			let container = pre.closest("body > *");
			container.after("Hi from shadow tree script!");
		}&lt;/script>
	</code></pre>
</html-demo>
```

## Auto-wrapping HTML code snippets on a whole page

The element class provides two helper methods for this very thing:

```js
import HTMLDemoElement from "https://nudeui.com/elements/html-demo/html-demo.js";

HTMLDemoElement.wrapAll({
	container: mySection,
	ignore: ".no-html-demo, #installation, #some-other-section",
});
```

All parameters are optional.

| Name | Default value | Description |
| --- | --- | --- |
| `container` | `document.body` | The element to search for `<html-demo>` elements. |
| `ignore` | `""` | A CSS selector for elements to ignore. |
| `languages` | `["html", "markup"]` | The `language-xxx` classes whose code snippets to wrap |



================================================
FILE: elements/html-demo/html-demo.css
================================================
:host {
	--_font-size-min: var(--font-size-min, 50%);
	--_font-size-max: var(--font-size-max, 400%);

	--color-neutral: oklch(50% 0.03 230);
	--color-canvas: color-mix(in oklch, canvas, oklch(none 0.002 none) 100%);
	--color-neutral-95: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 96%);
	--color-neutral-90: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 90%);
	--color-neutral-80: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 80%);
	--color-neutral-70: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 70%);

	display: flex;
	flex-flow: column;
	border: 1px solid var(--color-neutral-70);
	border-radius: .3rem;
	margin-block: .5em;
}

::slotted(pre) {
	margin: 0 !important;
	padding: .6em .8em;
	background: var(--color-neutral-95);
	border-top: 1px solid var(--color-neutral-80);
	border-radius: inherit;
	border-top-left-radius: 0 !important;
	border-top-right-radius: 0 !important;
	font-size: 80%;
}

#toolbar {
	display: flex;
	gap: 1em;
	border-bottom: 1px solid var(--color-neutral-80);

	&:empty,
	&:has(#adjusters:empty) {
		display: none;
	}

	> * {
		padding: .3rem .5rem;
		flex: 1;

		&:not(:first-child) {
			border-left: 1px solid var(--color-neutral-80);
		}
	}
}

#adjusters {
	display: flex;
	flex: 1;
	gap: 1em;
}

.adjuster {
	flex: 1;

	&.font-size {
		display: flex;
		gap: .1em;
		align-items: center;

		.small, .big {
			font: 100%/1 serif;
		}

		.small {
			font-size: var(--_font-size-min);
		}

		.big {
			font-size: clamp(var(--_font-size-min), var(--_font-size-max), 200%);
		}

		input {
			flex: 1;
			height: .3em;
		}
	}
}



slot[name=demo] {
	display: block;
	--font-size-adjust: var(--adjust-font-size, 0.5);
	--font-size-range-low: calc(100% - var(--_font-size-min));
	--font-size-range-high: calc(var(--_font-size-max) - 100%);

	padding: 1em;

	font-size: calc(
		var(--_font-size-min)
		+ clamp(0, var(--font-size-adjust) * 2, 1) * var(--font-size-range-low)
		+ clamp(0, (var(--font-size-adjust) - 0.5) * 2, 1) * var(--font-size-range-high)
	);
}

================================================
FILE: elements/html-demo/html-demo.js
================================================
let styleURL = new URL("./html-demo.css", import.meta.url);

let Prism = globalThis.Prism;
if (!Prism) {
	await import("https://prismjs.com/prism.js");
}
Prism = globalThis.Prism;

if (!Prism.plugins.NormalizeWhitespace) {
	await import("https://prismjs.com/plugins/normalize-whitespace/prism-normalize-whitespace.min.js");
}

let self = class HTMLDemoElement extends HTMLElement {
	#el = {};
	#slots = {};
	adjust = {};
	#observer = new MutationObserver((mutations) => {
		this.#assignSlots();
		this.#render();
	});
	#dummy = document.createElement("div");

	constructor () {
		super();
		this.attachShadow({
			mode: "open",
			slotAssignment: "manual"
		});

		// TODO CodePen
		// https://assets.codepen.io/t-1/codepen-logo.svg

		this.shadowRoot.innerHTML = `
			<style>@import url("${ styleURL }")</style>
			<div id="toolbar">
				<div id="adjusters"></div>
				<slot name="toolbar"></slot>
			</div>
			<slot name="demo" data-default></slot>
			<slot name="code" data-assign="pre"></slot>
		`;

		for (let slot of this.shadowRoot.querySelectorAll("slot")) {
			this.#slots[slot.name] = slot;

			if (!slot.name || slot.dataset.default !== undefined) {
				this.#slots.default = slot;
			}
		}

		for (let el of this.shadowRoot.querySelectorAll("[id]")) {
			this.#el[el.id] = el;
		}
	}

	connectedCallback () {
		this.#assignSlots();
		this.#render();

		this.#observe();
	}

	#observe () {
		this.#observer.observe(this, { childList: true });
	}

	#unoobserve () {
		this.#observer.disconnect();
	}

	disconnectedCallback () {
		this.#unoobserve();
	}

	#assignSlots () {
		let children = this.childNodes;
		let slotElements = Object.values(this.#slots);
		let assignments = new WeakMap();

		// Assign to slots
		for (let child of children) {
			let assignedSlot;

			if (child.slot) {
				// Explicit slot
				assignedSlot = this.#slots[child.slot];
			}
			else if (child.matches) {
				assignedSlot = slotElements.find(slot => child.matches(slot.dataset.assign));
			}

			assignedSlot ??= this.#slots.default;
			let all = assignments.get(assignedSlot) ?? new Set();
			all.add(child);
			assignments.set(assignedSlot, all);
		}

		for (let slot of slotElements) {
			let all = assignments.get(slot) ?? new Set();
			slot.assign(...all);
		}
	}

	#render () {
		if (this.children.length === 0) {
			return;
		}

		this.#unoobserve(); // avoid mutation cycles

		this.#el.codeElements = [...this.#slots.code.assignedNodes()];
		this.#el.demoNodes = [...this.#slots.demo.assignedNodes()];

		// Once source is determined mutations can't change it
		this.source ??= this.getAttribute("source") ?? (this.#el.codeElements.length > 0 ? "code" : "content");
		this.isolate = this.hasAttribute("isolate");

		if (this.source == "code") {
			// Code-first
			let previousCode = this.code;

			// TODO handle non-markup code
			this.code = this.#el.codeElements.map(code => code.textContent).join("\n");

			if (previousCode === this.code) {
				return;
			}

			// TODO handle scripts

			if (this.isolate) {
				// Remove past demo nodes
				this.#el.demoNodes.forEach(node => node.remove());
				this.#slots.demo.assign();
				this.#slots.demo.innerHTML = this.code;
				runScripts(this.#slots.demo.children);
			}
			else {
				this.#dummy.innerHTML = this.code;
				let nodes = [...this.#dummy.childNodes]
				this.append(...nodes);
				this.#slots.demo.assign(...nodes);
				runScripts(nodes);
			}
		}
		else {
			// Get code from content
			this.code = this.#el.demoNodes.map(el => el.outerHTML ?? el.textContent).join("");

			// TODO Clean up markup
			let pre = document.createElement("pre");
			let code = document.createElement("code");
			pre.classList.add("language-html", "html-demo-code");
			code.textContent = this.code;
			pre.append(code);

			this.append(pre);
			this.#slots.code.assign(pre);

			if (this.isolate) {
				// Move demo nodes to shadow root
				let fragment = document.createDocumentFragment();
				fragment.append(...this.#el.demoNodes);
				this.#slots.demo.replaceChildren(...fragment.childNodes);
			}
		}

		Prism.highlightAllUnder(this);

		// Render adjusters
		this.#el.adjusters.innerHTML = "";

		if (this.hasAttribute("adjust")) {
			let adjusters = this.getAttribute("adjust").split(/\s+/);
			for (let adjuster of adjusters) {
				this.#renderAdjuster(adjuster);
			}
		}

		this.#observe();
	}

	#renderAdjuster (adjuster) {
		let template = self.adjusterTemplates[adjuster]?.call(this);

		if (!template) {
			return null;
		}

		let container = appendHTML(this.#el.adjusters, template);
		let formControl = container.querySelector(".main-adjuster, input, select, textarea");
		formControl.addEventListener("input", e => {
			let value = Number(formControl.value);
			this.adjust[adjuster] = value;
			this.#slots.demo.style.setProperty("--adjust-" + adjuster, value);
		});

		return container;
	}

	/**
	 * Wrap one or more elements with <html-demo>
	 * Assumes elements are siblings
	 * @param  {...any} elements
	 * @returns
	 */
	static wrap (...elements) {
		let wrapper = document.createElement("html-demo");
		elements[0].replaceWith(wrapper);
		wrapper.append(...elements);
		return wrapper;
	}

	/**
	 * Wrap <pre> elements under a given container
	 * @param {object} options
	 * @param {Node} [options.container=document]
	 * @param {string[]} [options.languages=["html", "markup"]
	 * @param {string} [options.ignore=".no-html-demo, #installation"]
	 */
	static wrapAll ({
		container = document,
		languages = ["html", "markup"],
		ignore = ".no-html-demo, #installation",
		selector = "",
	} = {}) {
		let languageSelector = languages.flatMap(id => `.language-${ id }, .language-${ id } *`).join(", ");
		let ignoreSelector = `html-demo *, :is(${ignore}) *`;

		// TODO wrap adjacent <pre> elements together
		// TODO handle CSS and JS
		let elements = container.querySelectorAll(`pre:has(> code:is(${ languageSelector }):not(${ ignoreSelector }))`);
		elements = Array.from(elements, pre => this.wrap(pre));
		return elements;
	}

	static adjusterTemplates = {
		"font-size": function() {
			return `
			<label class="font-size adjuster">
				<small class="small">A</small>
				<input type="range" min="0" max="1" step=".01" value="${ this.adjust["font-size"] ?? "0.5"}" aria-label="Adjust font size" />
				<strong class="big">A</strong>
			</label>`;
		}
	};
}

function appendHTML (container, html) {
	container.insertAdjacentHTML("beforeend", html);
	return container.children[container.children.length - 1];
}

/**
 * Execute any inline <scripts> in an array of nodes
 * @param {Array<Node>} nodes
 */
function runScripts (nodes) {
	for (let node of nodes) {
		if (node.matches?.("script")) {
			const clone = document.createElement("script");
			node.getAttributeNames().forEach(name => clone.setAttribute(name, node.getAttribute(name)));
			clone.append(node.innerHTML);
			node.replaceWith(clone);
		}
	}
}

customElements.define("html-demo", self);

export default self;

================================================
FILE: elements/img-input/README.md
================================================
---
id: img-input
---

<header>

# `<img-input>`

Form control for image linking and uploading.

</header>



## Features

- Paste, drag & drop, upload, or provide a URL, all with the same unified API!
- Inline preview (`nopreview` attribute to disable)
- Uses [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) to work like a built-in form element
- Ultra light

## TODO

- `multiple` attribute?
- Retargeting of input attributes (`autofocus`, `placeholder` etc)


## Examples

Basic

```html
<img-input></img-input>
```

## Customizing the preview

By default, the preview is shown in the same element as the input.
There are two ways to customize this: using the `preview` slot, or the `preview` attribute.

You can set the `preview` attribute to `none` for no preview:

```html
<img-input preview="none"></img-input>
```

You can also set it to a CSS selector pointing to another element:

```html
<img-input preview="#preview"></img-input>
<img id="preview">
```

Alternatively, you can use the `preview` slot to provide your own `<img>` element:

```html
<img-input>
  <img slot="preview">
</img-input>
```

Please note that if the `preview` attribute is set, the `preview` slot will be ignored.

The attribute can be dynamic as well:

```html
<img-input></img-input>
<button onclick="this.previousElementSibling.preview =
  this.previousElementSibling.preview === 'none' ? '' : 'none'">
	Toggle preview
</button>
```

## CSS parts

- `input`, `location` - The input element used for URL or filename
- `dropzone` The drop zone
- `button`, `browse-button` - The button used to open the file browser
- `preview` - The preview image

## Slots

- `input` to replace the default input element
- `browse` to replace the default “Browse…” button
- `preview` to replace the default preview image



================================================
FILE: elements/img-input/img-input.js
================================================
export default class ImageInput extends HTMLElement {
	#internals
	#el = {}
	#inputMethod
	#previewURL
	#files = []
	#initialized = false

	constructor () {
		super();

		this.attachShadow({ mode: "open" });
		this.shadowRoot.innerHTML = `<style>@import "${new URL("style.css", import.meta.url)}";</style>
		<input type="file" accept="image/*" />
		<div id="drop-zone" part="dropzone">
			<slot name="input">
				<input id="url" part="input location"${ this.hasAttribute("autofocus") ? ' autofocus' : "" } />
			</slot>
			<slot name="browse">
				<button part="button browse-button">Browse…</button>
			</slot>
		</div>
		<slot name="preview">
			<img id="preview" part="preview" />
		</slot>`;

		this.#el.input = this.shadowRoot.querySelector("input[part~=input]");
		this.#el.fileInput = this.shadowRoot.querySelector("input[type=file]");
		this.#el.preview = this.shadowRoot.querySelector("img#preview");
		this.#el.previewSlot = this.shadowRoot.querySelector("slot[name=preview]");
		this.#el.dropZone = this.shadowRoot.querySelector("div#drop-zone");
		this.#el.browseButton = this.shadowRoot.querySelector("button[part~=browse-button]");

		this.#internals = this.attachInternals?.();

		if (this.#internals) {
			// this.#internals.role = "region";
		}

		this.attributeChangedCallback();
	}

	connectedCallback () {
		if (this.#initialized) {
			// Prevent multiple initializations
			return;
		}

		this.#el.browseButton.addEventListener("click", () => {
			this.#el.fileInput.click();
		});

		for (event of "drag dragstart dragend dragover dragenter dragleave drop".split(" ")) {
			this.#el.dropZone.addEventListener(event, e => {
				e.preventDefault();
				e.stopPropagation();
			});
		}

		for (event of "dragover dragenter".split(" ")) {
			this.#el.dropZone.addEventListener(event, () => {
				this.#el.dropZone.part.add("dragover");
			});
		}

		for (event of "dragleave dragend drop".split(" ")) {
			this.#el.dropZone.addEventListener(event, () => {
				this.#el.dropZone.part.remove("dragover");
			});
		}

		this.#el.dropZone.addEventListener("drop", e => {
			this.#files = [...e.dataTransfer.files];
			this.#inputMethod = "drop";
			this.#internals?.setFormValue(this.#formValue);
			this.#render();
		});

		this.#el.fileInput.addEventListener("change", e => {
			this.#files = [...e.target.files];
			this.#inputMethod = "browse";
			this.#internals?.setFormValue(this.#formValue);
			this.#render();
		});

		this.addEventListener("paste", e => {
			let files = [...e.clipboardData.items].filter(item => item.kind === "file" && /^image\//.test(item.type));

			if (files.length > 0) {
				// Images were pasted
				this.#files = files.map(item => item.getAsFile());
				console.log(this.#files)
				this.#inputMethod = "paste";
				this.#internals?.setFormValue(this.#formValue);
				this.#render();
			}
		});

		this.#el.input.addEventListener("input", e => {
			let inputMethod = this.#inputMethod;

			if (this.#inputMethod && this.#inputMethod !== "url") {
				if (/^https?:\/\//.test(this.#el.input.value)) {
					// Back to URL mode, discard the files we have
					this.#inputMethod = "url";
				}
			}

			if (inputMethod !== this.#inputMethod) {
				// Input method changed, re-render
				this.#render();
			}

			this.#internals?.setFormValue(this.#formValue);
		});



		this.#initialized = true;
		this.#render();
	}

	#render () {
		if (!this.#inputMethod || this.#inputMethod === "url") {
			if (this.#inputMethod === "url") {
				this.#previewURL = this.#el.input.value;
			}

			Object.assign(this.#el.input, {
				type: "url",
				ariaLabel: "URL",
				placeholder: "https://"
			});
		}
		else {
			this.#previewURL = URL.createObjectURL(this.files[0]);
			this.#el.input.value = this.files[0].name;

			Object.assign(this.#el.input, {
				type: "",
				ariaLabel: "Filename",
				placeholder: ""
			});

			requestAnimationFrame(() => {
				// TODO select only the filename (without the extension)
				this.#el.input.select();
			});
		}

		this.#renderPreview();
	}

	#renderPreview () {
		if (this.preview !== "none" && this.#previewURL) {
			this.#el.preview.src = this.#previewURL;
		}
	}

	get name () {
		return this.getAttribute("name");
	}

	set name (value) {
		this.setAttribute("name", value);
	}

	get inputMethod () {
		return this.#inputMethod;
	}

	get preview () {
		return this.getAttribute("preview") || "auto";
	}

	set preview (value) {
		this.setAttribute("preview", value);
	}

	// get multiple () {
	// 	return this.hasAttribute("multiple");
	// }

	// set multiple (value) {
	// 	if (value) {
	// 		this.setAttribute("multiple", "");
	// 	}
	// 	else {
	// 		this.removeAttribute("multiple");
	// 	}
	// }

	get #formValue () {
		let fd = new FormData();
		fd.set("file", this.files[0]);
		fd.set("url", this.#inputMethod === "url"? this.#el.value : null);
		return fd;
	}

	get value () {
		return this.#el.value;
	}

	set value (value) {
		if (!value) {
			this.#el.value = value;
			this.#internals?.setFormValue(this.#formValue);
			this.files = [];
		}
		else {
			// This is how the native file input works
			// See https://html.spec.whatwg.org/multipage/input.html#dom-input-value-filename
			throw new DOMException("InvalidStateError");
		}
	}

	get files () {
		if (this.#inputMethod === "url") {
			return [];
		}
		else {
			let files = this.#files.slice(0, 1); // we don't do multiple files yet

			if (this.#el.input.value !== this.#files[0].name) {
				// Filename edited
				files[0] = new File([files[0]], this.#el.input.value, files[0]);
			}

			return files;
		}
	}

	get labels() {
		return this.#internals?.labels;
	}

	focus() {
		this.#el.input.focus();
	}

	static get observedAttributes() {
		return ["preview"];
	}

	attributeChangedCallback(name, oldValue) {
		let value = this.getAttribute(name);

		if (oldValue === value) {
			return;
		}

		if (!name || name === "preview") {
			this.#el.preview.style.display = value === "none" ? "none" : "";

			if (value && !["auto", "none"].includes(value)) {
				// Value is a CSS selector
				this.#el.preview = this.ownerDocument.querySelector(value);
			}
			else {
				this.#el.preview = this.#el.previewSlot.assignedElements()[0] // Is an element slotted?
				                   || this.shadowRoot.querySelector("img[part~=preview]"); // get default element
			}

			this.#renderPreview();
		}
	}

	static get formAssociated() {
		return true;
	}
}

customElements.define("img-input", ImageInput);


================================================
FILE: elements/img-input/style.css
================================================
#drop-zone {
	display: grid;
	gap: .3em;
	grid-template: "url browse" auto
	                "preview preview" auto / 1fr auto;

	&[part~="dragover"] {
		outline: 2px dashed hsl(0 0% 10% / .5);
		outline-offset: 4px;
	}
}

#url {
	grid-area: url;
}

input[type=file] {
	display: none;
}

#preview {
	grid-area: preview;
	max-width: 100%;
}

:host([nopreview]) #preview {
	display: none;
}

================================================
FILE: elements/img-input/test.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>Document</title>
	<script src="img-input.js" type="module"></script>
</head>
<body>
	<form id="form">
		<button>Go</button>
		<input type="file" name="native">
		<img-input name="imginput"></img-input>
	</form>
	<script>
		form.onsubmit = e => {
			let o = Object.fromEntries(new FormData(form).entries());
			console.log(o);
			e.preventDefault();
		};
	</script>
</body>
</html>

================================================
FILE: elements/index.css
================================================
/* Import all CSS-only modules */

@import url("./nd-switch/nd-switch.css");

================================================
FILE: elements/index.js
================================================
export { default as ButtonGroup } from "./button-group/button-group.js";
export { default as CycleToggle } from "./cycle-toggle/cycle-toggle.js";
export { default as MeterDiscrete } from "./meter-discrete/meter-discrete.js";
export { default as NudeRating } from "./nd-rating/nd-rating.js";
export { default as HTMLDemoElement } from "./html-demo/html-demo.js";

// CSS-only modules
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="${new URL(`index.css`, import.meta.url)}" />`);

================================================
FILE: elements/meter-discrete/README.md
================================================
---
id: meter-discrete
---

<header>

# `<meter-discrete>`

Like `<meter>`, but discrete. Useful to display ratings etc.

</header>



## Features

- Scales with font size
- Use emoji or custom icons
- Styleable bar and inactive part
- Uses [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) for accessibiity
- Ultra light (3KB **unminified** and **uncompressed**!)

## Examples

No attributes

```html
<meter-discrete></meter-discrete>
```

Without specifying icon

```html
<meter-discrete max="5" value="2.5"></meter-discrete>
```

With custom icon, and a max of 10

```html
<meter-discrete max="10" value="6.6" icon="❤️"></meter-discrete>
```

With step

```html
<meter-discrete max="10" value="6.6" step="0.5" icon="❤️"></meter-discrete>
```

Dynamic value

```html
<meter-discrete max="5" value="3.5" icon="💩"></meter-discrete>
<button onclick="this.previousElementSibling.value = Math.random() * 5">Random value</button>
```

Different styles


```html
<style>
#minimal_rating {
	font-size: 200%;
}

#minimal_rating::part(value),
#minimal_rating::part(inactive) {
	filter: contrast(0%) sepia() hue-rotate(140deg);
}

#minimal_rating::part(inactive) {
	opacity: .5;
}
</style>
<meter-discrete id="minimal_rating" max="5" value="2.5" icon="💜"></meter-discrete>
```

Actual image instead of emoji:


```html
<meter-discrete value="3.5" icon="/logo.svg"></meter-discrete>
```

## See also

* [`<nd-rating>`](../nd-rating), an editable version of `<meter-discrete>`



================================================
FILE: elements/meter-discrete/meter-discrete.js
================================================
export const internals = Symbol("internals");

export default class MeterDiscrete extends HTMLElement {
	#internals

	constructor() {
		super();

		this.attachShadow({ mode: "open" });
		this.shadowRoot.innerHTML = `
		<style>@import "${new URL("style.css", import.meta.url)}";</style>
		<div id=value part=value></div><div id=inactive part="inactive"></div>`;

		this[internals] = this.attachInternals?.() ?? {};
		this[internals].role = "meter";
		this[internals].ariaValueMin = this.min;
	}

	get icon () {
		return this.getAttribute("icon") ?? "⭐️";
	}

	// So it can be handled like a <meter>
	get min () {
		return 0;
	}

	get max () {
		return +this.getAttribute("max") || 5;
	}

	set max (max) {
		if (max) {
			this.setAttribute("max", max);
		}
	}

	get value () {
		let value = this.getAttribute("value");

		if (value === null) {
			return null;
		}

		value = +value;

		let step = this.step;

		if (step !== null) {
			// Quantize by step
			value = quantize(value, step);
		}

		return value;
	}

	set value (value) {
		this.setAttribute("value", value);
	}

	get step () {
		return this.hasAttribute("step") ? +this.getAttribute("step") : null;
	}

	get #iconURL () {
		let isURL = this.icon.includes(".");

		return isURL? this.icon : emojiToImage(this.icon);
	}

	static get observedAttributes() {
		return ["value", "max", "icon"];
	}

	attributeChangedCallback(name, oldValue, newValue) {
		if (!name || name === "max") {
			let max = this.max;
			this.style.setProperty("aspect-ratio", `${max} / 1`);
			this.style.setProperty("--max", max);
			this[internals].ariaValueMax = max;
		}

		if (!name || name === "value") {
			let value = this.value;
			this.style.setProperty("--value", value);
			this[internals].ariaValueNow = value;
		}

		if (!name || name === "icon") {
			this.style.setProperty("--icon-image", `url('${ this.#iconURL }')`);
		}
	}

	connectedCallback() {
		this.attributeChangedCallback();
	}
}

function emojiToImage(emoji) {
	// For debug: <rect stroke="black" fill="none" stroke-width="2" width="100%" height="100%" />
	return `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">`
	+ `<text style="font-size: 80px" x="50%" y=".85em" dominant-baseline="middle" text-anchor="middle">${emoji}</text></svg>`
}

function quantize (value, step) {
	return Math.round(value / step) * step;
}


customElements.define("meter-discrete", MeterDiscrete);

================================================
FILE: elements/meter-discrete/style.css
================================================
:host {
	display: inline-flex;
	vertical-align: -.25em;
	height: 1.2em;
	user-select: none;
}

#value, #inactive {
	background: var(--icon-image) 0 / auto 100%;
}

#value {
	width: calc(var(--value) / var(--max) * 100%);
}

#inactive {
	opacity: .5;
	filter: saturate(50%) contrast(.5);
	flex: 1;
	background-position-x: right;
}

================================================
FILE: elements/nd-calendar/README.md
================================================
---
id: nd-calendar
---

<header>

# `<nd-calendar>`

Display dates, date ranges, or date/time ranges by day or hour.

</header>



## Features

- Weekly rows or entire months
- TODO: Custom colors per date

## Examples

No attributes

```html
<nd-calendar>
	<time datetime="2022-09-05T00:00"></time> <!-- Times are ignored -->
	<time datetime="2022-09-07 / 2022-09-10"></time> <!-- Range -->
	<time datetime="2022-09-13"></time>
</nd-calendar>
```

Custom max
```html
<nd-calendar max="2022-09-15">
	<time datetime="2022-09-05"></time>
	<time datetime="2022-09-07"></time>
	<time datetime="2022-09-11"></time>
</nd-calendar>
```

Custom min
```html
<nd-calendar min="2022-09-01">
	<time datetime="2022-09-05"></time>
	<time datetime="2022-09-07"></time>
	<time datetime="2022-09-11"></time>
</nd-calendar>
```

Custom min and max
```html
<nd-calendar min="2022-08-01" max="2022-09-30">
	<time datetime="2022-09-05"></time>
	<time datetime="2022-09-07"></time>
	<time datetime="2022-09-11"></time>
</nd-calendar>
```

By months:

```html
<nd-calendar rows="months">
	<time datetime="2022-05-02"></time>
	<time datetime="2022-05-12"></time>
	<time datetime="2022-06-13T15:00"></time> <!-- Times are ignored -->
	<time datetime="2022-07-12"></time>
	<time datetime="2022-08-22"></time>
	<time datetime="2022-09-05"></time>
	<time datetime="2022-09-07"></time>
	<time datetime="2022-09-11"></time>
</nd-calendar>
```



================================================
FILE: elements/nd-calendar/nd-calendar.js
================================================
const DAYS_OF_WEEK = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

const dur = { ms: 1 };
dur.sec = dur.ms * 1000;
dur.min = dur.sec * 60;
dur.hour = dur.min * 60;
dur.day = dur.hour * 24;
dur.week = dur.day * 7;
dur.month = dur.day * 30.4368;

export default class NudeCalendar extends HTMLElement {
	#headers
	#calendar
	#observer;

	constructor() {
		super();

		this.attachShadow({ mode: "open" });
		this.shadowRoot.innerHTML = `
		<style>@import "${new URL("style.css", import.meta.url)}";</style>
		<div id="headers"></div>
		<div id="calendar"></div>
		`;
		this.#headers = this.shadowRoot.getElementById("headers");
		this.#calendar = this.shadowRoot.getElementById("calendar");
		this.#observe();
	}

	#createHeaders () {
		if (this.getAttribute("rows") === "months") {
			let days = Array(31).fill(1).map((a, i) => i + 1);
			this.#headers.innerHTML = days.map((d, i) => `<div style="--day: ${i + 1}">${d}</div>`).join("\n");
		}
		else {
			this.#headers.innerHTML = DAYS_OF_WEEK.map((d, i) => `<div style="--weekday: ${i + 1}">${d}</div>`).join("\n");
		}

	}

	#observe () {
		this.#observer ??= new MutationObserver(() => this.#render());
		this.#observer.observe(this, { childList: true, subtree: true, attributeFilter: ["datetime"] });
	}

	#unobserve () {
		this.#observer.takeRecords();
		this.#observer.disconnect();
	}

	#render() {
		this.#unobserve();

		let dates = [...this.children].flatMap(time => {
			let dt = time.getAttribute("datetime");

			if (dt.includes("/")) {
				// Date range
				// add ALL dates between these
				let [low, high] = dt.split("/").map(d => new BetterDate(d.trim()));

				// Return all dates between low and high
				let dates = [];
				let daysApart = (high - low) / dur.day;

				if (isNaN(high) || isNaN(low)) {
					return [];
				}

				for (let d = new BetterDate(low), i = 0; d <= new BetterDate(Number(high) + dur.day); d.setDate(d.getDate() + 1)) {
					i++;
					dates.push(d.isoDate);
					if (i > daysApart) break; // failsafe
				}

				return dates.map(d => new BetterDate(d));
			}

			return new BetterDate(dt);
		}).filter(d => !isNaN(d)).sort((a, b) => a - b);

		if (dates.length === 0) {
			this.#observe();
			return;
		}

		this.#calendar.innerHTML = "";

		let hasMin = this.hasAttribute("min");
		let hasMax = this.hasAttribute("max");
		this.min = hasMin && new BetterDate(this.getAttribute("min")) || dates[0];
		this.max = hasMax && new BetterDate(this.getAttribute("max")) || dates.at(-1);

		if (!hasMax) {
			if (this.getAttribute("rows") === "months") {
				this.max = new BetterDate(this.max);
				this.max.setMonth(this.max.getMonth() + 1);
				this.max.setDate(0);
			}
			else {
				let now = new BetterDate();
				if (now - this.max < dur.month) {
					// If max is recent use today as the default max
					this.max = now;
				}
			}

		}

		if (!hasMin) {
			// If no mix and max is specified, extend min and max in some cases for better presentation
			if (this.getAttribute("rows") === "months") {
				// Grow ranges to be full months
				this.min = new BetterDate(this.min);
				this.min.setDate(1);

			}
			else {
				this.min = new BetterDate(this.max - dur.month);
			}
		}

		this.dates = new Set(dates.map(d => d.isoDate));

		let previousMonth;
		let daysApart = (this.max - this.min) / dur.day;
		for (let date = this.min, i = 0; !(date > this.max); date.setDate(date.getDate() + 1)) {
			if (isNaN(date)) {
				return;
			}

			const dayElement = document.createElement("time");
			dayElement.part = "day" + (this.dates.has(date.isoDate) ? " active" : "");
			dayElement.setAttribute("datetime", date.isoDate);
			dayElement.title = date.toLocaleString("en-US", { dateStyle: "long" });
			dayElement.style.setProperty("--weekday", date.weekday);

			let year = date.getComponent("year");
			let month = date.getComponent("month", "short");
			let day = date.getComponent("day");
			dayElement.style.setProperty("--year", `"${year}"`);
			dayElement.style.setProperty("--month", `"${month}"`);
			dayElement.style.setProperty("--day", day);

			this.#calendar.appendChild(dayElement);

			if (previousMonth !== month) {
				dayElement.insertAdjacentHTML("beforebegin", `<div part="month" style="--month: "${month}";">${month}</div>`);
			}

			if (i > daysApart) break; // failsafe

			previousMonth = month;
			i++;
		}

		this.#observe();
	}

	static observedAttributes = ["rows"]

	attributeChangedCallback(name, oldValue, newValue) {
		if (!name || name === "rows") {
			this.#createHeaders();
		}
	}

	connectedCallback() {
		this.attributeChangedCallback();
		this.#render();
	}
}

class BetterDate extends Date {
	constructor(...args) {
		super(...args);

		this.hasTimezone = typeof args[0] === "string" && /\+|Ζ/.test(args[0]) || args[0]?.hasTimezone;

		if (!this.hasTimezone) {
			// Use UTC time if no timezone provided
			this.setMinutes(this.getMinutes() + this.getTimezoneOffset());
		}
	}

	get weekday() {
		return this.getDay() || 7;
	}

	get isoDate () {
		try {
			return this.toISOString().split("T")[0];
		}
		catch (e) {
			return "";
		}
	}

	getComponent(component, format = "numeric") {
		return this.toLocaleString("en-US", { timeZone: "UTC", [component]: format });
	}
}

customElements.define("nd-calendar", NudeCalendar);


================================================
FILE: elements/nd-calendar/style.css
================================================
:host {
	--_active-day-background: var(--active-day-background, var(--accent-color, hsl(220 60% 50%)));
	--_inactive-day-background: var(--inactive-day-background, hsl(220 10% 70%));

	display: grid;
	grid-template-columns: repeat(7, 1fr);
	grid-gap: .2em;
}

:host([rows=months]) {
	grid-template-columns: auto repeat(31, 1fr)
}

:host([rows=months]) #headers {
		font-weight: normal
	}

:host([rows=months]) #headers :first-child {
			grid-column: 2;
		}

#calendar {
	display: contents;
}

#headers {
	display: contents;
	color: hsl(var(--gray), 50%);
	font-weight: bold;
	text-align: center;
}

[part~="month"] {
	grid-column: 1;
	font-weight: bold;
	text-transform: uppercase;
	color: var(--_inactive-day-background);
	filter: brightness(80%);
	align-self: center;
	font-size: 75%;
}

[part~="day"] {
	position: relative;
	border-radius: .2em;
	background: var(--_inactive-day-background);
	color: white;
	font-weight: bold;
	text-decoration: none;
	overflow: hidden;
	padding: .2em 0;
	text-align: center;
	container-type: inline-size
}

[part~="day"][part~="active"] {
		background: var(--_active-day-background);
	}

[part~="day"]::after {
		counter-reset: day var(--day);
		content: counter(day);
	}

@container (max-width: 4em) {
		[part~="day"]::before {
			display: block;
			font-size: 70%;
		}

		[part~="day"]::after {
			display: block;
		}
	}

:host(:not([rows=months])) [part~="day"] {
		grid-column: var(--weekday)
}

:host(:not([rows=months])) [part~="day"]::before {
			content: var(--month) " ";
			font-weight: 300;
		}

:host([rows=months]) [part~="day"] {
		font-size: 90%;
		letter-spacing: -.03em;
		grid-column: calc(var(--day) + 1)
}

:host(:not([rows=months])) [part~="month"] {
		display: none;
	}







================================================
FILE: elements/nd-calendar/style.postcss
================================================
:host {
	--_active-day-background: var(--active-day-background, var(--accent-color, hsl(220 60% 50%)));
	--_inactive-day-background: var(--inactive-day-background, hsl(220 10% 70%));

	display: grid;
	grid-template-columns: repeat(7, 1fr);
	grid-gap: .2em;
}

:host([rows=months]) {
	grid-template-columns: auto repeat(31, 1fr);

	& #headers {
		font-weight: normal;

		& :first-child {
			grid-column: 2;
		}
	}
}

#calendar {
	display: contents;
}

#headers {
	display: contents;
	color: hsl(var(--gray), 50%);
	font-weight: bold;
	text-align: center;
}

[part~="month"] {
	grid-column: 1;
	font-weight: bold;
	text-transform: uppercase;
	color: var(--_inactive-day-background);
	filter: brightness(80%);
	align-self: center;
	font-size: 75%;
}

[part~="day"] {
	position: relative;
	border-radius: .2em;
	background: var(--_inactive-day-background);
	color: white;
	font-weight: bold;
	text-decoration: none;
	overflow: hidden;
	padding: .2em 0;
	text-align: center;
	container-type: inline-size;

	&[part~="active"] {
		background: var(--_active-day-background);
	}

	&::after {
		counter-reset: day var(--day);
		content: counter(day);
	}

	@container (max-width: 4em) {
		&::before {
			display: block;
			font-size: 70%;
		}

		&::after {
			display: block;
		}
	}

	@nest :host(:not([rows=months])) & {
		grid-column: var(--weekday);

		&::before {
			content: var(--month) " ";
			font-weight: 300;
		}
	}

	@nest :host([rows=months]) & {
		font-size: 90%;
		letter-spacing: -.03em;
		grid-column: calc(var(--day) + 1);
	}
}

:host(:not([rows=months])) {
	& [part~="month"] {
		display: none;
	}
}







================================================
FILE: elements/nd-rating/README.md
================================================
---
id: nd-rating
---

<header>

# `<nd-rating>`

Like [`<meter-discrete>`](../meter-discrete/), but editable. Useful to display and set ratings etc.

</header>



## Features

- All features of [`<meter-discrete>`](../meter-discrete/), plus:
- Uses [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) to work like a built-in form element
- Keyboard accessible (use arrow keys)
- Ultra light (3KB **unminified** and **uncompressed** + another 3KB for `<meter-discrete>`)

## Examples

Basic

```html
<nd-rating max="5" value="3.5"></nd-rating>
<button onclick="this.previousElementSibling.readonly = !this.previousElementSibling.readonly">Toggle readonly</button>
```

With step

```html
<nd-rating max="5" value="3.5" step="0.1" style="font-size: 200%"></nd-rating>
```

Different styles


```html
<style>
#minimal_rating {
	font-size: 200%;
}

#minimal_rating::part(value),
#minimal_rating::part(inactive) {
	filter: contrast(0%) sepia() hue-rotate(140deg);
}

#minimal_rating::part(inactive) {
	opacity: .5;
}
</style>
<nd-rating id="minimal_rating" max="5" value="2.5" step="0.5" icon="💜"></nd-rating>
```

Actual image instead of emoji:


```html
<nd-rating value="3.5" icon="../logo.svg"></nd-rating>
```

Participates in form submission (requires [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) support):

```html
<form action="about:blank" target="_blank">
	<nd-rating name="myrating"></nd-rating>
	<button type=submit>Submit</button>
</form>
```



================================================
FILE: elements/nd-rating/nd-rating.js
================================================
import MeterDiscrete, {internals} from "../meter-discrete/meter-discrete.js";

export default class NudeRating extends MeterDiscrete {
	constructor () {
		super();

		if (!this.hasAttribute("tabindex")) {
			this.tabIndex = 0;
		}
	}

	get readonly () {
		return this.hasAttribute("readonly");
	}

	get value () {
		return super.value;
	}

	set value (value) {
		let oldValue = super.value;
		super.value = value;

		this[internals].setFormValue(value);
	}

	set readonly (readonly) {
		if (readonly) {
			this.setAttribute("readonly", "");
		}
		else {
			this.removeAttribute("readonly");
		}
	}

	static get observedAttributes() {
		return [...super.observedAttributes, "readonly"];
	}

	attributeChangedCallback(name, oldValue, newValue) {
		super.attributeChangedCallback(name, oldValue, newValue);

		if (name === "readonly" || (!name && !this.readonly)) {
			if (name === "readonly" && newValue !== null) {
				this.#endEditing();
			}
			else {
				this.#startEditing();
			}
		}
	}

	#startEditing () {
		this.addEventListener("mouseenter", this.edit);
		this.addEventListener("focus", this.edit);
	}

	#endEditing () {
		this.removeEventListener("mouseenter", this.edit);
		this.removeEventListener("focus", this.edit);
	}

	edit () {
		// Code adapted from Mavo: https://github.com/mavoweb/mavo/blob/master/src/elements.js#L378
		let {min = 0, max, step} = this;
		let range = max - min;

		step = step ?? (range > 1? 1 : range/100);

		let value = this.value;

		let handlers = {
			mousemove: evt => {
				// Change property as mouse moves
				let {left, width} = this.getBoundingClientRect();
				let offset = evt.offsetX / width;
				let newValue = quantize(min + range * offset, step);

				this.value = newValue;
			},

			mouseleave: evt => {
				// Return to actual value
				this.value = value;

				for (let event in handlers) {
					this.removeEventListener(event, handlers[event]);
				}
			},

			click: evt => {
				// Register change
				value = this.value;

				this.dispatchEvent(new InputEvent("input", { bubbles: true }));
			},

			keydown: evt => {
				// Edit with arrow keys
				if (["ArrowLeft", "ArrowRight"].includes(evt.key)) {
					let increment = step * (evt.key === "ArrowRight"? 1 : -1) * (evt.shiftKey? 10 : 1);
					let newValue = this.value + increment;
					newValue = Math.max(min, Math.min(newValue, max));

					this.value = newValue;

					this.dispatchEvent(new InputEvent("input", { bubbles: true }));

					evt.preventDefault();
				}
			}
		};

		handlers.blur = handlers.mouseleave;

		for (let event in handlers) {
			this.addEventListener(event, handlers[event]);
		}
	}

	get labels() {
		return this[internals]?.labels;
	}

	static formAssociated = true;
}

function quantize (value, step) {
	return Math.round(value / step) * step;
}

customElements.define("nd-rating", NudeRating);

================================================
FILE: elements/nd-slider/README.md
================================================
---
id: nd-slider
---
<script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.15.0/cdn/components/format-number/format-number.js"></script>
<header>

# `<nd-slider>`

Several improvements over the native `<input type=range>`.

</header>



## Features

- Editable spinner that shows the value or progress of the slider, displayed as a tooltip or inline
- Convenience methods

## Examples

### Basic

Simplest version:

```html
<nd-slider></nd-slider>
```

You can also *provide* a specific slider element yourself so it can be easier to style or customize:

```html
<nd-slider>
	<input type=range>
</nd-slider>
```

You can also provide a specific element for the value:

```html
<nd-slider>
	<sl-format-number slot="value" type="currency" currency="USD"></sl-format-number>
</nd-slider>
```

If it has a `value` property it will be assumed to be editable, otherwise it will be read-only.

---

All usual slider attributes work and are simply copied to the slider and spinner elements:

```html
<nd-slider min="-180" max="180" step="0.01"></nd-slider>
```

You are encouraged to provide a slider with the right attributes from the start, to minimize updates.

---

By default the number is shown as a read/write tooltip, but you can make it display inline:

```html
<nd-slider style="--value-position: end"></nd-slider>
```

Yes, this is a regular CSS property that you can even set in your stylesheet.
It requires [style queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_size_and_style_queries#container_style_queries_2),
which means [at the time of this writing, it won’t work in Firefox](https://caniuse.com/css-container-queries-style).

By default shown is the slider value, but you can switch to showing (and editing) the progress instead:

```html
<nd-slider show="progress"></nd-slider>
```

## Properties and methods

In addition to the usual properties and methods of a slider (`min`, `max`, `step`, `value`, `defaultValue`), the following are available:
- `show`: Whether to show the value or the progress. Possible values: `value`, `progress`. Default: `value`.
- `valueElement`: The element that shows the value.
- `sliderElement`: The element that is the slider.
- `progress`: The progress of the slider, as a number between 0 and 1.
- `progressAt(value)`: Returns the progress at a given point, as a number between 0 and 1.
- `valueAt(progress)`: Returns the value at a given progress, as a number between `min` and `max`.



================================================
FILE: elements/nd-slider/nd-slider.css
================================================
:host {
	display: flex;
	gap: .5em;
	position: relative;
}

.nd-slider,
slot:not([name])::slotted(*),
slot:not([name]) > * {
	width: 100%;
}

.slider-tooltip,
slot[name="value"] {
	--_slider-thumb-width: var(--slider-thumb-width, 16px);
	--_tooltip-background: var(--tooltip-background, color-mix(in oklab, canvas 80%, oklab(none none none / 0%)));
	--_tooltip-border-radius: var(--tooltip-border-radius, .3em);
	--_tooltip-pointer-height: var(--tooltip-pointer-height, .3em);
	--_tooltip-pointer-angle: var(--tooltip-pointer-angle, 90deg);

	@supports (field-sizing: content) {
		--field-sizing-width: auto;
	}

	position: absolute;
	left: clamp(-20%,
			100% * var(--progress)
			- (var(--progress) - 0.5) * var(--_slider-thumb-width) / 2 /* center on slider thumb */
		, 100%);
	bottom: calc(100% + 3px);
	translate: -50%;
	transform-origin: bottom;
	display: flex;
	padding-block: .3em;
	padding-inline: .4em;
	border: var(--_tooltip-pointer-height) solid transparent;
	border-radius: calc(var(--_tooltip-border-radius) + var(--_tooltip-pointer-height));
	text-align: center;
	color: canvastext;
	background:
		conic-gradient(from calc(-1 * var(--_tooltip-pointer-angle) / 2) at bottom, var(--_tooltip-background) var(--_tooltip-pointer-angle), transparent 0)
			border-box bottom / 100% var(--_tooltip-pointer-height) no-repeat,
		var(--_tooltip-background) padding-box;
	color-scheme: dark;
	transition:
		visibility 0s 200ms,
		opacity 200ms,
		scale 200ms,
		width 100ms,
		left 200ms cubic-bezier(.17,.67,.49,1.48);

	/* Prevent input from moving all over the place as we type */
	&:focus-within {
		transition-delay: .5s;
	}

	&::after {
		content: var(--value-suffix);
	}

	input,
	&::slotted(input) {
		all: unset;
	}

	> input[type=number],
	&:is(input[type=number]),
	&::slotted(input[type=number]) {
		--content-width: calc(var(--value-length) * 1ch);
		field-sizing: content;
		width: var(--field-sizing-width, calc(var(--content-width, 2ch) + 1.2em));
		min-width: calc(2ch + 1.2em);
		box-sizing: content-box;

		&::-webkit-textfield-decoration-container {
			gap: .2em;
		}

		@container style(--value-suffix) {
			&::-webkit-textfield-decoration-container {
				flex-flow: row-reverse;
			}
		}

		/* Don’t auto hide the spin buttons */
		&::-webkit-inner-spin-button {
			opacity: 1;
		}
	}

	&:not(:is(:focus-within, :hover) > *, .color-slider:is(:focus, :hover) + *, :focus, :hover) {
		/* visibility: hidden;
		opacity: 0;
		scale: .5; */
	}

	@container style(--value-position) {
		display: contents;
		color-scheme: inherit;

		@container style(--value-position: start) {
			order: -1;
		}

		input,
		&::slotted(input) {
			all: revert-layer;
			font: inherit;
		}
	}
}

:host([show="progress"]) :where(.slider-tooltip, slot[name="value"]) {
	--value-suffix: "%";
}

================================================
FILE: elements/nd-slider/nd-slider.js
================================================
let self = class NudeSlider extends HTMLElement {
	sliderElement = null;
	valueElement = null;
	#slots = {};

	static tagName = "nd-slider";
	static observedAttributes = ["min", "max", "step", "value", "show"];

	constructor () {
		super();

		this.attachShadow({mode: "open"});
		let styleURL = new URL(self.tagName + ".css", import.meta.url);
		this.shadowRoot.innerHTML = `
			<style>@import url("${ styleURL }")</style>
			<slot>
				<input type="range">
			</slot>
			<slot name="value">
				<input type="number">
			</slot>
		`;

		this.#slots = Object.fromEntries([...this.shadowRoot.querySelectorAll("slot")].map(slot => [slot.name || "slider", slot]));

		this.#slots.slider.addEventListener("slotchange", this);
		this.#slots.value.addEventListener("slotchange", this);

		this.handleEvent({type: "slotchange"});
	}

	handleEvent (event) {
		if (event.type === "slotchange") {
			// What is the source of truth?
			// "slider" by default, but becomes "value" if no slider is slotted, but a spinner is.
			let source = "slider";

			for (let name in this.#slots) {
				let slot = this.#slots[name];
				let elementProp = name + "Element";
				let oldElement = this[elementProp];
				let nodes = slot.assignedNodes();
				let elements = slot.assignedElements();

				if (elements[0] === oldElement) {
					continue;
				}

				if (name === "slider" && elements.length === 0 && nodes.every(node => !node.nodeValue.trim())) {
					// Literally every node assigned to this slot is an empty text node. Likely formatting, remove it.
					// See https://twitter.com/LeaVerou/status/1785904086929346957
					nodes.forEach(node => node.remove());
				}

				let element = slot.assignedElements()[0];

				if (name !== source && element) {
					source = name;
				}

				element ??= slot.firstElementChild;
				this[elementProp] = element;

				["min", "max", "step"].forEach(prop => this[elementProp][prop] = this[prop]);

				if (oldElement !== this[elementProp]) {
					oldElement?.removeEventListener("input", this);
					this[elementProp]?.addEventListener("input", this);
				}
			}

			this[source + "Element"].dispatchEvent(new Event("input", {bubbles: true}));
		}
		else if (event.type === "input") {
			if (event.target === this.sliderElement) {
				this.#valueChanged({source: "slider"});
			}
			else if (event.target === this.valueElement) {
				this.#valueChanged({source: "value"});
			}

			this.dispatchEvent(new Event("input", {
				bubbles: true,
				originalTarget: event.target,
			}));
		}
	}

	#valueChanged ({source} = {}) {
		if (source) {
			let value = this[source + "Element"].value;

			if (source === "slider") {
				this.valueElement.value = this.show === "progress" ? +(this.progressAt(value) * 100).toPrecision(4) : value;
			}
			else if (source === "value") {
				this.sliderElement.value = this.show === "progress" ? this.valueAt(value / 100) : value;
			}
		}

		this.style.setProperty("--value", this.value);
		this.style.setProperty("--progress", this.progress);

		if (!CSS.supports("field-sizing", "content")) {
			let valueStr = this.value + "";
			this.valueElement.style.setProperty("--value-length", valueStr.length);
		}
	}

	get show () {
		return this.getAttribute("show");
	}

	set show (value) {
		this.setAttribute("show", value);
	}

	get progress () {
		return this.progressAt(this.value);
	}

	progressAt (value) {
		return (value - this.min) / (this.max - this.min);
	}

	valueAt (progress) {
		return this.min + progress * (this.max - this.min);
	}

	attributeChangedCallback (name, oldValue, newValue) {
		if (oldValue === newValue) {
			return;
		}

		if (name === "show") {
			let values = this;

			if (newValue === "progress") {
				values = {min: 0, max: 100, step: 1};
			}

			["min", "max", "step"].forEach(prop => this.valueElement.setAttribute(prop, values[prop]));
		}
		else {
			this.sliderElement.setAttribute(name, newValue);
			this.valueElement.setAttribute(name, newValue);
			this.#valueChanged();
		}
	}
}

let defaults = {
	min: 0,
	max: 100,
	step: 1,
	value: 50,
	defaultValue: 50,
}

for (let prop of Object.keys(defaults)) {
	Object.defineProperty(self.prototype, prop, {
		get () {
			let value = this.sliderElement[prop]
			return value === "" ? defaults[prop] : Number(value);
		},
		set (value) {
			let oldValue = this.sliderElement[prop];

			if (oldValue !== value) {
				this.sliderElement[prop] = value;
				this.valueElement[prop] = value;
			}
		},
	});
}

customElements.define(self.tagName, self);

export default self;

================================================
FILE: elements/nd-switch/README.md
================================================
---
title: Nude switch
id: nd-switch
css_only: true
---

<header>

# Nude switch

CSS-only toggle switch

</header>



## Examples

Basic:

```html
<input type="checkbox" class="nd-switch">
```

Bigger:

```html
<input type="checkbox" class="nd-switch" style="font-size: 200%">
```

With larger and smaller thumb:

```html
<input type="checkbox" class="nd-switch" style="--nd-thumb-margin: -.2em">
<input type="checkbox" class="nd-switch" style="--nd-thumb-margin: .2em">
```

Different colors:
```html
<input type="checkbox" class="nd-switch" style="
	--nd-thumb-color: black;
	--nd-switch-color: white; border: 1px solid black;
	--nd-switch-color-checked: red
">
```

Right to left:

```html
<input type="checkbox" class="nd-switch" dir="rtl">
```

Disabled:

```html
<input type="checkbox" class="nd-switch" disabled>
```



================================================
FILE: elements/nd-switch/nd-switch.css
================================================
input[type=checkbox].nd-switch {
	--nd-_switch-width: var(--nd-switch-width, 3em);
	--nd-_switch-height: var(--nd-switch-height, calc(var(--nd-_switch-width) / 2));
	--nd-_switch-color: var(--nd-switch-color, hsl(220 10% 60%));
	--nd-_switch-color-checked: var(--nd-switch-color-checked, hsl(205 50% 50%));
	--nd-_thumb-margin: var(--nd-thumb-margin, .1em);
	--nd-_thumb-color: var(--nd-thumb-color, hsl(220 10% 96%));

	-webkit-appearance: none;
	display: inline-flex;
	vertical-align: var(--nd-_thumb-margin);
	align-items: center;
	width: var(--nd-_switch-width);
	height: var(--nd-_switch-height);
	box-sizing: border-box;
	border: 1px solid rgb(0 0 0 / .1);
	border-radius: 1em;
	--nd-current-background: var(--nd-_switch-color);
	background: var(--nd-current-background);
	cursor: pointer;
	transition: .3s background;

	&::before {
		content: "";
		display: block;
		aspect-ratio: 1 / 1;
		margin: var(--nd-_thumb-margin);
		height: calc(100% - 2 * var(--nd-_thumb-margin));
		background: var(--nd-_thumb-color);
		border-radius: 50%;
		transition: margin;
		transition-duration: inherit;
		box-shadow: 0 0 0 1px var(--nd-current-background), 0 0 var(--nd-focus-ring, );
	}

	&:checked {
		--nd-current-background: var(--nd-_switch-color-checked);

		&::before {
			margin-inline-start: calc(var(--nd-_switch-width) - var(--nd-_switch-height) + var(--nd-_thumb-margin));
		}
	}

	&:disabled {
		filter: grayscale();
		opacity: .6;
		cursor: not-allowed;
	}

	&:focus {
		outline: none;

		&::before {
			--nd-focus-ring: .05em .2em hsl(205 80% 50% / .5);
		}
	}
}

================================================
FILE: elements/with-presets/README.md
================================================
---
id: with-presets
---

<header>

# `<with-presets>`

A freeform text field with visible presets

</header>



## Features

- Freeform text with visible presets, for when you want to display both the label and value of the selected option (unlike `<datalist>`)
- Use with `<select>` and `<input>` or custom elements!
- Reactively selects a preset even if entered in the freeform text field
- Selects the correct preset, even if the preset was created dynamically
- TODO: How would labels be appropriately used for this?

## Examples

With select:

```html
<with-presets id="with_select">
	<select>
		<option value="[time(time, 'minutes')]">HH:ii</option>
		<option value="[time(time, 'hours')]">HH:00</option>
		<option value="[time(time, 'seconds')]">HH:ii:ss</option>
		<option value="[time(time, 'ms')]">HH:ii:ss.ms</option>
	</select>
	<input />
</with-presets>
```

With [`<button-group>`](../button-group/):

```html
<button-group>
	<button>1</button>
	<button value="2">Two</button>
	<button value="3">Three</button>
	<button value="custom">Custom…</button>
</button-group>
```

```html
<with-presets id="with_buttongroup">
	<button-group>
		<button>1</button>
		<button value="2">Two</button>
		<button value="3">Three</button>
		<button value="custom">Custom…</button>
	</button-group>
	<input />
</with-presets>
<button onclick='with_buttongroup.value = Math.floor(Math.random() * 5)'>Set random number 0-4</button>
```

With dynamic select:

```html
<with-presets vertical id="with_dynamic_select">
	<select size="4">
	</select>
	<input value="3">
</with-presets>
<button onclick='with_dynamic_select.children[0].insertAdjacentHTML("beforeend", `<option>${with_dynamic_select.select.children.length}</option>`)'>Add option</button>
```



================================================
FILE: elements/with-presets/style.css
================================================
:host {
	display: flex;
	gap: .2em;
}

:host([vertical]) {
	flex-flow: column;
	align-items: stretch;
}

::slotted(input),
::slotted(:not(select):nth-child(2)) {
	flex: 1;
}

::slotted(button-group) {
	margin: 0;
}

================================================
FILE: elements/with-presets/with-presets.js
================================================
export default class WithPresets extends HTMLElement {
	#observer

	constructor() {
		super();

		this.attachShadow({ mode: "open" });
		this.shadowRoot.innerHTML = `<style>@import "${new URL("style.css", import.meta.url)}";</style><slot></slot>`;

		this.customValue = this.getAttribute("customvalue") ?? "";

		this.addEventListener("input", evt => {
			let value = evt.target.value;

			if (value === "custom" && evt.target === this.select) {
				value = this.customValue ?? "";
			}

			this.value = value;
		});
	}

	isPreset (value) {
		// Is there an option with this value?
		value = value?.replaceAll? value.replaceAll('"', '\\"') : value;
		return !!this.findPreset(value);
	}

	findPreset (value) {
		let options = this.select.options || this.select.children; // might not be a <select>

		return [...options].find(option => option.value === value);
	}

	#value

	get value () {
		return this.#value;
	}

	set value (value) {
		value ??= "";

		if (this.#value && !this.isPreset(this.#value)) {
			// Allow user to toggle between presets and not lose the custom value they entered
			this.customValue = this.#value;
		}

		this.#value = value;

		if (this.input) {
			this.input.value = this.#value;
		}

		if (this.select) {
			this.select.value = this.isPreset(value) ? value : "custom";
		}

		this[(this.isCustom ? 'set' : 'remove') + "Attribute"]("custom", "");
	}

	get isCustom () {
		return this.select?.value === "custom";
	}

	connectedCallback () {
		this.input = this.querySelector(":scope > input");
		this.select = this.querySelector(":scope > select");

		if (this.input && !this.select) {
			this.select = this.querySelector(":scope > :not(input)");
		}
		else if (this.select && !this.input) {
			this.input = this.querySelector(":scope > :not(select)");
		}
		else {
			// Neither an <input> nor a <select> is used, go by child order
			this.input = this.children[1];
			this.select = this.children[0];
		}


		// Just in case
		customElements.upgrade(this.input);
		customElements.upgrade(this.select);

		if (this.select && !this.findPreset("custom")) {
			// TODO get tag name from first option
			this.select.insertAdjacentHTML("beforeend", `<option value="custom">Custom…</option>`);
		}

		if (this.#value) {
			this.value = this.#value;
		}
		else if (this.input?.value) {
			this.value = this.input.value;
		}
		else if (this.select) {
			this.value = this.select.value;
		}

		this.observer = new MutationObserver(records => {
			// Presets changed. We only need to react in two cases:
			// a) The value was a preset, but the preset is no longer available
			// b) The value was not a preset, but the preset is now available
			let wasPreset = !this.isCustom;
			let isPreset = this.isPreset(this.#value);

			if (isPreset !== wasPreset) {
				this.value = this.#value;
			}
		});

		this.observer.observe(this.select, {
			subtree: true,
			childList: true,
			characterData: true,
			attributeFilter: ["value"]
		});
	}
}

customElements.define("with-presets", WithPresets);

================================================
FILE: package.json
================================================
{
  "name": "nudeui",
  "version": "0.0.1",
  "description": "",
  "main": "index.html",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build:css": "npx postcss \"**/*.postcss\" --base . --dir . --ext .css --config postcss.config.cjs",
    "build:html": "npx @11ty/eleventy --config=.eleventy.cjs",
    "build": "npm run build:html && npm run build:css",
    "watch:css": "npx postcss \"**/*.postcss\" --base . --dir . --ext .css --config postcss.config.cjs --watch",
    "watch:html": "npx @11ty/eleventy --config=.eleventy.cjs --watch",
    "watch": "npx concurrently -n w: npm:watch:*"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/leaverou/nudeui.git"
  },
  "keywords": [
    "CSS",
    "forms"
  ],
  "author": "Lea Verou",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/leaverou/nudeui/issues"
  },
  "homepage": "https://github.com/leaverou/nudeui#readme",
  "devDependencies": {
    "concurrently": "^7.6.0",
    "markdown-it-anchor": "^8.6.7",
    "markdown-it-attrs": "^4.1.6",
    "postcss": "^8.3.9",
    "postcss-cli": "^9.0.1",
    "postcss-nesting": "^8.0.1"
  }
}


================================================
FILE: postcss.config.cjs
================================================
module.exports = {
	plugins: [
		require('postcss-nesting')({})
	],
};

================================================
FILE: style/forms.css
================================================
@import url('tokens.css');

button, input, textarea, select,
.button,
::part(button), ::part(input) {
	font: inherit;
	box-sizing: border-box;
}

input:where([type="number"]),
::part(input-number) {
	width: 3em;
}

input:where(:not([type]), [type="text"], [type="url"], [type="number"]), textarea, select,
::part(input), ::part(textarea), ::part(select) {
	border: 1px solid var(--color-neutral-50a);
	border-radius: .2em;
	padding: .2em .4em;
	min-width: 4em;
}

fieldset {
	border: none;
	margin: .5rem 0;
	background: var(--color-neutral-10a);
	border-radius: .3em;

	& > legend {
		font-weight: bold;
	}
}

button,
.button,
::part(button) {
	padding: .6em .8em;
	border: 1px solid hsl(0 0 0 / .2);
	border-radius: calc(.15em + .15rem);
	background-color: white;
	cursor: pointer;
	font-size: var(--font-size-small);
	text-decoration: none;
	color: inherit;
	transition-duration: .2s;
	transition-property: background-color, border-color, color;
	line-height: 1.1;
	white-space: nowrap;

	&:disabled {
		cursor: not-allowed;
		opacity: .5;
	}

	&:active,
	&[aria-pressed="true"] {
		box-shadow: 0 .1em .2em hsl(0 0% 0% / .2) inset, 0 0 0 2em var(--color-neutral-10a) inset;
	}

	&:where(:enabled) {
		&:hover {
			border-color: hsl(var(--accent-color-hs) 80%);
			background-color: hsl(var(--accent-color-hs) 95%);
			color: hsl(var(--accent-color-hs) 15%);
		}
	}

	/* Icon */
	& i {
		display: inline-block;
		opacity: .7;
	}

	&.icon-prefix:where(:not(.icon-only)) i:first-of-type {
		margin-right: .4em;
	}

	&.icon-suffix i:last-of-type {
		margin-left: .4em;
	}

	&[type=submit] {
		background: var(--accent-color-2);
		color: white;

		&:where(:enabled) {
			&:hover {
				border-color: hsl(var(--accent-color-2-hs) 80%);
			}
		}
	}
}

================================================
FILE: style/tables.css
================================================
table {
	width: 100%;
	width: -moz-available;
	width: -webkit-fill-available;
	width: stretch;
	border-collapse: collapse;
	border-spacing: 0;
}

td, th {
	padding: .2em .4em;
	border: 1px solid var(--color-neutral-30a);
}

:where(thead) {
	th {
		background-color: var(--color-neutral-90);
	}
}

================================================
FILE: style/tokens.css
================================================
:where(:root) {
	--color-magenta-hs: var(--accent-color-hs);

	--color-blue-hs: var(--accent-color-2-hs);
	--color-blue: var(--accent-color-2);
	--focus-color: var(--color-blue);

	--color-neutral: oklch(50% 0.03 230);
	--color-canvas: oklch(from canvas l 0.002 none);



	--color-neutral-90a: color-mix(in oklch, var(--color-neutral) 90%, oklch(none none none / 0));
	--color-neutral-80a: color-mix(in oklch, var(--color-neutral) 80%, oklch(none none none / 0));
	--color-neutral-70a: color-mix(in oklch, var(--color-neutral) 70%, oklch(none none none / 0));
	--color-neutral-50a: color-mix(in oklch, var(--color-neutral) 50%, oklch(none none none / 0));
	--color-neutral-30a: color-mix(in oklch, var(--color-neutral) 30%, oklch(none none none / 0));
	--color-neutral-20a: color-mix(in oklch, var(--color-neutral) 20%, oklch(none none none / 0));
	--color-neutral-10a: color-mix(in oklch, var(--color-neutral) 10%, oklch(none none none / 0));

	--color-neutral-95: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 96%);
	--color-neutral-90: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 90%);
	--color-neutral-80: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 80%);
	--color-neutral-70: color-mix(in oklch, var(--color-neutral), var(--color-canvas) 70%);

	/* Semantic colors */
	--accent-color-hs: 335 90%;
	--accent-color: hsl(var(--accent-color-hs) 50%);

	--accent-color-2-hs: 200 90%;
	--accent-color-2: hsl(var(--accent-color-2-hs) 50%);

	/* Fonts */
	--font-body: system-ui, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
	--font-mono: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
}

================================================
FILE: style.css
================================================
@import url('style/tokens.css');
@import url('style/forms.css');
@import url('style/tables.css');
@import url('https://prismjs.com/themes/prism-solarizedlight.min.css');

:root {
	--page-width: 900px;
	--page-margin-inline: clamp(1em, 50vw - var(--page-width) / 2, 50vw);
}

body {
	display: flex;
	flex-direction: column;
	margin: 0;
	min-height: 100vh;
	font: 100%/1.5 var(--font-body);
	padding-inline: var(--page-margin-inline);

	> :is(header, nav, footer) {
		padding-inline: var(--page-margin-inline);

	}

	> :is(header, nav, footer, .full-width) {
		margin-inline: calc(-1 * var(--page-margin-inline));
	}

	> header {
		order: -2;
		background: var(--accent-color);
		color: white;
		padding-block: 1em;

		& h1 {
			margin: 0;
			font-size: 300%;

			& + p {
				margin: 0;
				font-weight: bold;
			}
		}

		& a {
			color: inherit;
		}

		& .home {
			text-transform: uppercase;
			font-weight: 900;
			font-size: 75%;

			&:not(:hover) {
				text-decoration: none;
			}

			&::before {
				content: "🏠";
				margin-right: .3em;
			}
		}
	}

	> nav {
		order: -1;

		& + * {
			margin-top: 1em;
		}
	}
}

h1, h2, h3, h4, h5, h6 {
	line-height: 1.1;
	margin-block: 1.5rem .5rem;

	.header-anchor {
		color: inherit;

		&:not(:hover, :focus) {
			text-decoration: none;
		}
	}
}

h2 {
	font-size: 2.5rem;
	font-weight: 300;
	color: var(--accent-color);
}

ul, ol {
	margin-block: .5rem 1rem;
}

nav {
	order: 1;
	display: flex;
	padding-top: 0;
	padding-bottom: 0;
	background: hsl(var(--accent-color-hs) 65%);

	& > a {
		flex: 1;
		padding: .4em .5em;
		color: white;
		font-weight: bold;
		background: linear-gradient(to right, hsl(var(--accent-color-hs) 50%), hsl(var(--accent-color-hs) 75%)) no-repeat left / 0 100%;
		transition: .3s;

		&:hover {
			background-size: 100% 100%;
		}

		&:where(:not(:hover)) {
			text-decoration: none;
		}
	}
}

code, pre {
	font-family: var(--font-mono);
}

iframe {
	border: 0;
}

.failed {
	--color-hs: 0 50%;
	border: 1px solid hsl(var(--color-hs) 60%);
	background: hsl(var(--color-hs) 95%);
	color: hsl(var(--color-hs) 20%);
	padding: 1em;

	& :is(h2, h3, h4, h5, h6) {
		color: hsl(var(--color-hs) 50%);
	}

	& h2 {
		margin-top: 0;

		&::before {
			content: "⚠️";
		}
	}
}
Download .txt
gitextract_w7vts8kx/

├── .eleventy.cjs
├── .gitattributes
├── .gitignore
├── LICENSE
├── README.md
├── _headers
├── _includes/
│   └── page.njk
├── _redirects
├── assets/
│   └── global.js
├── elements/
│   ├── button-group/
│   │   ├── README.md
│   │   ├── button-group.js
│   │   ├── style.css
│   │   └── style.postcss
│   ├── cycle-toggle/
│   │   ├── README.md
│   │   ├── cycle-toggle.js
│   │   └── style.css
│   ├── data-bind/
│   │   ├── Observer.js
│   │   ├── README.md
│   │   ├── Recipe.js
│   │   ├── data-bind.js
│   │   ├── properties.js
│   │   └── util.js
│   ├── drop-down/
│   │   ├── README.md
│   │   ├── drop-down.js
│   │   └── style.css
│   ├── html-demo/
│   │   ├── README.md
│   │   ├── html-demo.css
│   │   └── html-demo.js
│   ├── img-input/
│   │   ├── README.md
│   │   ├── img-input.js
│   │   ├── style.css
│   │   └── test.html
│   ├── index.css
│   ├── index.js
│   ├── meter-discrete/
│   │   ├── README.md
│   │   ├── meter-discrete.js
│   │   └── style.css
│   ├── nd-calendar/
│   │   ├── README.md
│   │   ├── nd-calendar.js
│   │   ├── style.css
│   │   └── style.postcss
│   ├── nd-rating/
│   │   ├── README.md
│   │   └── nd-rating.js
│   ├── nd-slider/
│   │   ├── README.md
│   │   ├── nd-slider.css
│   │   └── nd-slider.js
│   ├── nd-switch/
│   │   ├── README.md
│   │   └── nd-switch.css
│   └── with-presets/
│       ├── README.md
│       ├── style.css
│       └── with-presets.js
├── package.json
├── postcss.config.cjs
├── style/
│   ├── forms.css
│   ├── tables.css
│   └── tokens.css
└── style.css
Download .txt
SYMBOL INDEX (151 symbols across 14 files)

FILE: elements/button-group/button-group.js
  class ButtonGroup (line 1) | class ButtonGroup extends HTMLElement {
    method constructor (line 5) | constructor () {
    method #buttonChanged (line 60) | #buttonChanged (button) {
    method name (line 83) | get name () {
    method name (line 87) | set name (value) {
    method multiple (line 91) | get multiple () {
    method multiple (line 95) | set multiple (value) {
    method value (line 106) | get value () {
    method value (line 110) | set value (value) {
    method pressedButtons (line 131) | get pressedButtons () {
    method pressedButton (line 135) | get pressedButton () {
    method labels (line 139) | get labels() {
    method connectedCallback (line 143) | connectedCallback () {
    method disconnectedCallback (line 154) | disconnectedCallback () {
    method formAssociated (line 158) | static get formAssociated() {
  function getValue (line 163) | function getValue(button) {

FILE: elements/cycle-toggle/cycle-toggle.js
  class CycleToggle (line 6) | class CycleToggle extends HTMLElement {
    method constructor (line 11) | constructor () {
    method name (line 31) | get name () {
    method name (line 35) | set name (value) {
    method readonly (line 39) | get readonly () {
    method readonly (line 43) | set readonly (value) {
    method value (line 54) | get value () {
    method value (line 58) | set value (value) {
    method selectedOptions (line 70) | get selectedOptions () {
    method selectedOption (line 74) | get selectedOption () {
    method labels (line 78) | get labels() {
    method cycle (line 83) | cycle () {
    method #setSelected (line 94) | #setSelected (option, selected = false) {
    method connectedCallback (line 117) | connectedCallback () {
    method #observe (line 123) | #observe () {
    method #unobserve (line 136) | #unobserve() {
    method disconnectedCallback (line 143) | disconnectedCallback () {
    method formAssociated (line 147) | static get formAssociated() {
  function getValue (line 152) | function getValue(element) {

FILE: elements/data-bind/Observer.js
  method constructor (line 9) | constructor (element, recipes) {
  method observe (line 15) | observe (fn) {
  method unobserve (line 60) | unobserve () {
  method changed (line 79) | changed (change) {

FILE: elements/data-bind/Recipe.js
  method constructor (line 17) | constructor (...specs) {
  method add (line 21) | add (...recipes) {
  method #getMutation (line 77) | #getMutation () {
  function getRecipe (line 104) | function getRecipe (propertyOrAttribute) {

FILE: elements/data-bind/data-bind.js
  method constructor (line 8) | constructor () {
  method connectedCallback (line 12) | connectedCallback () {
  method configure (line 17) | configure () {
  method update (line 57) | update ({ force, property } = {}) {
  method updateElement (line 67) | updateElement (element) {
  method attributeChangedCallback (line 94) | attributeChangedCallback (name, oldValue, newValue) {

FILE: elements/data-bind/util.js
  function getPropertyDescriptor (line 7) | function getPropertyDescriptor (object, key) {
  function interceptPropertyWrites (line 19) | function interceptPropertyWrites (obj, property, callback) {
  function uninterceptPropertyWrites (line 47) | function uninterceptPropertyWrites (obj, property, descriptor) {
  function flushMutationObserver (line 57) | function flushMutationObserver (mutationObserver, callback) {

FILE: elements/drop-down/drop-down.js
  class DropDown (line 6) | class DropDown extends HTMLElement {
    method constructor (line 15) | constructor () {
    method labels (line 46) | get labels() {
    method #handleEvent (line 50) | #handleEvent (evt) {
    method #observe (line 91) | #observe () {
    method #childrenChanged (line 103) | #childrenChanged () {

FILE: elements/html-demo/html-demo.js
  method constructor (line 23) | constructor () {
  method connectedCallback (line 56) | connectedCallback () {
  method #observe (line 63) | #observe () {
  method #unoobserve (line 67) | #unoobserve () {
  method disconnectedCallback (line 71) | disconnectedCallback () {
  method #assignSlots (line 75) | #assignSlots () {
  method #render (line 104) | #render () {
  method #renderAdjuster (line 183) | #renderAdjuster (adjuster) {
  method wrap (line 207) | static wrap (...elements) {
  method wrapAll (line 221) | static wrapAll ({
  function appendHTML (line 249) | function appendHTML (container, html) {
  function runScripts (line 258) | function runScripts (nodes) {

FILE: elements/img-input/img-input.js
  class ImageInput (line 1) | class ImageInput extends HTMLElement {
    method constructor (line 9) | constructor () {
    method connectedCallback (line 43) | connectedCallback () {
    method #render (line 123) | #render () {
    method #renderPreview (line 154) | #renderPreview () {
    method name (line 160) | get name () {
    method name (line 164) | set name (value) {
    method inputMethod (line 168) | get inputMethod () {
    method preview (line 172) | get preview () {
    method preview (line 176) | set preview (value) {
    method #formValue (line 193) | get #formValue () {
    method value (line 200) | get value () {
    method value (line 204) | set value (value) {
    method files (line 217) | get files () {
    method labels (line 233) | get labels() {
    method focus (line 237) | focus() {
    method observedAttributes (line 241) | static get observedAttributes() {
    method attributeChangedCallback (line 245) | attributeChangedCallback(name, oldValue) {
    method formAssociated (line 268) | static get formAssociated() {

FILE: elements/meter-discrete/meter-discrete.js
  class MeterDiscrete (line 3) | class MeterDiscrete extends HTMLElement {
    method constructor (line 6) | constructor() {
    method icon (line 19) | get icon () {
    method min (line 24) | get min () {
    method max (line 28) | get max () {
    method max (line 32) | set max (max) {
    method value (line 38) | get value () {
    method value (line 57) | set value (value) {
    method step (line 61) | get step () {
    method #iconURL (line 65) | get #iconURL () {
    method observedAttributes (line 71) | static get observedAttributes() {
    method attributeChangedCallback (line 75) | attributeChangedCallback(name, oldValue, newValue) {
    method connectedCallback (line 94) | connectedCallback() {
  function emojiToImage (line 99) | function emojiToImage(emoji) {
  function quantize (line 105) | function quantize (value, step) {

FILE: elements/nd-calendar/nd-calendar.js
  constant DAYS_OF_WEEK (line 1) | const DAYS_OF_WEEK = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
  class NudeCalendar (line 11) | class NudeCalendar extends HTMLElement {
    method constructor (line 16) | constructor() {
    method #createHeaders (line 30) | #createHeaders () {
    method #observe (line 41) | #observe () {
    method #unobserve (line 46) | #unobserve () {
    method #render (line 51) | #render() {
    method attributeChangedCallback (line 162) | attributeChangedCallback(name, oldValue, newValue) {
    method connectedCallback (line 168) | connectedCallback() {
  class BetterDate (line 174) | class BetterDate extends Date {
    method constructor (line 175) | constructor(...args) {
    method weekday (line 186) | get weekday() {
    method isoDate (line 190) | get isoDate () {
    method getComponent (line 199) | getComponent(component, format = "numeric") {

FILE: elements/nd-rating/nd-rating.js
  class NudeRating (line 3) | class NudeRating extends MeterDiscrete {
    method constructor (line 4) | constructor () {
    method readonly (line 12) | get readonly () {
    method value (line 16) | get value () {
    method value (line 20) | set value (value) {
    method readonly (line 27) | set readonly (readonly) {
    method observedAttributes (line 36) | static get observedAttributes() {
    method attributeChangedCallback (line 40) | attributeChangedCallback(name, oldValue, newValue) {
    method #startEditing (line 53) | #startEditing () {
    method #endEditing (line 58) | #endEditing () {
    method edit (line 63) | edit () {
    method labels (line 121) | get labels() {
  function quantize (line 128) | function quantize (value, step) {

FILE: elements/nd-slider/nd-slider.js
  method constructor (line 9) | constructor () {
  method handleEvent (line 32) | handleEvent (event) {
  method #valueChanged (line 89) | #valueChanged ({source} = {}) {
  method show (line 110) | get show () {
  method show (line 114) | set show (value) {
  method progress (line 118) | get progress () {
  method progressAt (line 122) | progressAt (value) {
  method valueAt (line 126) | valueAt (progress) {
  method attributeChangedCallback (line 130) | attributeChangedCallback (name, oldValue, newValue) {
  method get (line 162) | get () {
  method set (line 166) | set (value) {

FILE: elements/with-presets/with-presets.js
  class WithPresets (line 1) | class WithPresets extends HTMLElement {
    method constructor (line 4) | constructor() {
    method isPreset (line 23) | isPreset (value) {
    method findPreset (line 29) | findPreset (value) {
    method value (line 37) | get value () {
    method value (line 41) | set value (value) {
    method isCustom (line 62) | get isCustom () {
    method connectedCallback (line 66) | connectedCallback () {
Condensed preview — 57 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (120K chars).
[
  {
    "path": ".eleventy.cjs",
    "chars": 1120,
    "preview": "let markdownIt = require(\"markdown-it\");\nlet markdownItAnchor = require(\"markdown-it-anchor\");\nlet markdownItAttrs = req"
  },
  {
    "path": ".gitattributes",
    "chars": 66,
    "preview": "# Auto detect text files and perform LF normalization\n* text=auto\n"
  },
  {
    "path": ".gitignore",
    "chars": 55,
    "preview": "index.html\nnd-calendar/style.css\nbutton-group/style.css"
  },
  {
    "path": "LICENSE",
    "chars": 1066,
    "preview": "MIT License\n\nCopyright (c) 2021 Lea Verou\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "README.md",
    "chars": 2373,
    "preview": "<header>\n\n# Nude UI\n\nA collection of accessible, customizable, ultra-light web components\n\n- Using built-in controls whe"
  },
  {
    "path": "_headers",
    "chars": 35,
    "preview": "/*\n\tAccess-Control-Allow-Origin: *\n"
  },
  {
    "path": "_includes/page.njk",
    "chars": 1547,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n\t"
  },
  {
    "path": "_redirects",
    "chars": 243,
    "preview": "/button-group/* /elements/button-group/:splat 301\n/meter-discrete/* /elements/meter-discrete/:splat 301\n/with-presets/* "
  },
  {
    "path": "assets/global.js",
    "chars": 415,
    "preview": "// Website scripts\nimport \"../elements/index.js\";\nimport \"https://prismjs.com/prism.js\";\nimport HTMLDemo from \"../elemen"
  },
  {
    "path": "elements/button-group/README.md",
    "chars": 4621,
    "preview": "---\nid: button-group\ntitle: <button-group>\nincludes: '<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@shoelac"
  },
  {
    "path": "elements/button-group/button-group.js",
    "chars": 3359,
    "preview": "export default class ButtonGroup extends HTMLElement {\n\t#internals\n\t#observer\n\n\tconstructor () {\n\t\tsuper();\n\n\t\tthis.atta"
  },
  {
    "path": "elements/button-group/style.css",
    "chars": 1503,
    "preview": ":host {\n\tdisplay: inline-flex\n\n\t/* 0 specificity default pressed styles */\n}\n\n:host ::slotted(:where([aria-pressed=\"true"
  },
  {
    "path": "elements/button-group/style.postcss",
    "chars": 1264,
    "preview": ":host {\n\tdisplay: inline-flex;\n\n\t/* 0 specificity default pressed styles */\n\t& ::slotted(:where([aria-pressed=\"true\"])) "
  },
  {
    "path": "elements/cycle-toggle/README.md",
    "chars": 1982,
    "preview": "---\ntitle: <cycle-toggle>\nid: cycle-toggle\n---\n\n<header>\n\n# `<cycle-toggle>`\n\nClick to cycle through a variety of option"
  },
  {
    "path": "elements/cycle-toggle/cycle-toggle.js",
    "chars": 3342,
    "preview": "if (!HTMLSlotElement.prototype.assign) {\n\t// Include Imperative Slot Assignment polyfill\n\tawait import(\"https://unpkg.co"
  },
  {
    "path": "elements/cycle-toggle/style.css",
    "chars": 90,
    "preview": ":host {\n\tcursor: pointer;\n\tuser-select: none;\n}\n\nbutton {\n\tall: unset;\n\toutline: revert;\n}"
  },
  {
    "path": "elements/data-bind/Observer.js",
    "chars": 2199,
    "preview": "\nimport {\n\tinterceptPropertyWrites,\n\tflushMutationObserver,\n} from \"./util.js\";\nimport Recipe from \"./Recipe.js\";\n\nlet s"
  },
  {
    "path": "elements/data-bind/README.md",
    "chars": 454,
    "preview": "---\nid: data-bind\n---\n\n<header>\n\n# `<data-bind>`\n\nAn element for propagating data changes between elements.\n\n</header>\n\n"
  },
  {
    "path": "elements/data-bind/Recipe.js",
    "chars": 2888,
    "preview": "import properties from \"./properties.js\";\n\nconst self = class ObserveRecipe {\n\tevents = [];\n\tattributes = [];\n\tpropertie"
  },
  {
    "path": "elements/data-bind/data-bind.js",
    "chars": 2455,
    "preview": "import Observer from \"./Observer.js\";\n\nconst tagName = \"data-bind\";\n\nlet self = class DataBindlement extends HTMLElement"
  },
  {
    "path": "elements/data-bind/properties.js",
    "chars": 1224,
    "preview": "let properties = {\n\ttextContent: {\n\t\tchildren: true,\n\t\ttext: true,\n\t\tdeep: true,\n\t},\n\tinnerHTML: {\n\t\tchildren: true,\n\t\tt"
  },
  {
    "path": "elements/data-bind/util.js",
    "chars": 1550,
    "preview": "/**\n * Get a property descriptor from an object or its prototype chain.\n * @param {*} object\n * @param {*} key\n * @retur"
  },
  {
    "path": "elements/drop-down/README.md",
    "chars": 1109,
    "preview": "---\ntitle: <drop-down>\nid: drop-down\n---\n<header>\n\n# `<drop-down>`\n\nDrop-down menu that performs actions when items are "
  },
  {
    "path": "elements/drop-down/drop-down.js",
    "chars": 3804,
    "preview": "if (!HTMLSlotElement.prototype.assign) {\n\t// Include Imperative Slot Assignment polyfill\n\tawait import(\"https://unpkg.co"
  },
  {
    "path": "elements/drop-down/style.css",
    "chars": 873,
    "preview": ":host {\n\tdisplay: inline-grid;\n\tgrid-template: auto / auto;\n\toverflow: hidden;\n}\n\nslot[name=menu]::slotted(select),\n#tri"
  },
  {
    "path": "elements/html-demo/README.md",
    "chars": 3772,
    "preview": "---\nid: html-demo\n---\n\n<header>\n\n# `<html-demo>`\n\nAn element for displaying HTML content alongside its source code.\nGrea"
  },
  {
    "path": "elements/html-demo/html-demo.css",
    "chars": 2056,
    "preview": ":host {\n\t--_font-size-min: var(--font-size-min, 50%);\n\t--_font-size-max: var(--font-size-max, 400%);\n\n\t--color-neutral: "
  },
  {
    "path": "elements/html-demo/html-demo.js",
    "chars": 6952,
    "preview": "let styleURL = new URL(\"./html-demo.css\", import.meta.url);\n\nlet Prism = globalThis.Prism;\nif (!Prism) {\n\tawait import(\""
  },
  {
    "path": "elements/img-input/README.md",
    "chars": 1832,
    "preview": "---\nid: img-input\n---\n\n<header>\n\n# `<img-input>`\n\nForm control for image linking and uploading.\n\n</header>\n\n\n\n## Feature"
  },
  {
    "path": "elements/img-input/img-input.js",
    "chars": 6450,
    "preview": "export default class ImageInput extends HTMLElement {\n\t#internals\n\t#el = {}\n\t#inputMethod\n\t#previewURL\n\t#files = []\n\t#in"
  },
  {
    "path": "elements/img-input/style.css",
    "chars": 387,
    "preview": "#drop-zone {\n\tdisplay: grid;\n\tgap: .3em;\n\tgrid-template: \"url browse\" auto\n\t                \"preview preview\" auto / 1fr"
  },
  {
    "path": "elements/img-input/test.html",
    "chars": 579,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n\t"
  },
  {
    "path": "elements/index.css",
    "chars": 76,
    "preview": "/* Import all CSS-only modules */\n\n@import url(\"./nd-switch/nd-switch.css\");"
  },
  {
    "path": "elements/index.js",
    "chars": 506,
    "preview": "export { default as ButtonGroup } from \"./button-group/button-group.js\";\nexport { default as CycleToggle } from \"./cycle"
  },
  {
    "path": "elements/meter-discrete/README.md",
    "chars": 1505,
    "preview": "---\nid: meter-discrete\n---\n\n<header>\n\n# `<meter-discrete>`\n\nLike `<meter>`, but discrete. Useful to display ratings etc."
  },
  {
    "path": "elements/meter-discrete/meter-discrete.js",
    "chars": 2416,
    "preview": "export const internals = Symbol(\"internals\");\n\nexport default class MeterDiscrete extends HTMLElement {\n\t#internals\n\n\tco"
  },
  {
    "path": "elements/meter-discrete/style.css",
    "chars": 329,
    "preview": ":host {\n\tdisplay: inline-flex;\n\tvertical-align: -.25em;\n\theight: 1.2em;\n\tuser-select: none;\n}\n\n#value, #inactive {\n\tback"
  },
  {
    "path": "elements/nd-calendar/README.md",
    "chars": 1415,
    "preview": "---\nid: nd-calendar\n---\n\n<header>\n\n# `<nd-calendar>`\n\nDisplay dates, date ranges, or date/time ranges by day or hour.\n\n<"
  },
  {
    "path": "elements/nd-calendar/nd-calendar.js",
    "chars": 5262,
    "preview": "const DAYS_OF_WEEK = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"];\n\nconst dur = { ms: 1 };\ndur.sec = dur.ms * 1000;"
  },
  {
    "path": "elements/nd-calendar/style.css",
    "chars": 1734,
    "preview": ":host {\n\t--_active-day-background: var(--active-day-background, var(--accent-color, hsl(220 60% 50%)));\n\t--_inactive-day"
  },
  {
    "path": "elements/nd-calendar/style.postcss",
    "chars": 1612,
    "preview": ":host {\n\t--_active-day-background: var(--active-day-background, var(--accent-color, hsl(220 60% 50%)));\n\t--_inactive-day"
  },
  {
    "path": "elements/nd-rating/README.md",
    "chars": 1532,
    "preview": "---\nid: nd-rating\n---\n\n<header>\n\n# `<nd-rating>`\n\nLike [`<meter-discrete>`](../meter-discrete/), but editable. Useful to"
  },
  {
    "path": "elements/nd-rating/nd-rating.js",
    "chars": 2842,
    "preview": "import MeterDiscrete, {internals} from \"../meter-discrete/meter-discrete.js\";\n\nexport default class NudeRating extends M"
  },
  {
    "path": "elements/nd-slider/README.md",
    "chars": 2500,
    "preview": "---\nid: nd-slider\n---\n<script type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.15.0/cdn/compon"
  },
  {
    "path": "elements/nd-slider/nd-slider.css",
    "chars": 2792,
    "preview": ":host {\n\tdisplay: flex;\n\tgap: .5em;\n\tposition: relative;\n}\n\n.nd-slider,\nslot:not([name])::slotted(*),\nslot:not([name]) >"
  },
  {
    "path": "elements/nd-slider/nd-slider.js",
    "chars": 4514,
    "preview": "let self = class NudeSlider extends HTMLElement {\n\tsliderElement = null;\n\tvalueElement = null;\n\t#slots = {};\n\n\tstatic ta"
  },
  {
    "path": "elements/nd-switch/README.md",
    "chars": 826,
    "preview": "---\ntitle: Nude switch\nid: nd-switch\ncss_only: true\n---\n\n<header>\n\n# Nude switch\n\nCSS-only toggle switch\n\n</header>\n\n\n\n#"
  },
  {
    "path": "elements/nd-switch/nd-switch.css",
    "chars": 1570,
    "preview": "input[type=checkbox].nd-switch {\n\t--nd-_switch-width: var(--nd-switch-width, 3em);\n\t--nd-_switch-height: var(--nd-switch"
  },
  {
    "path": "elements/with-presets/README.md",
    "chars": 1750,
    "preview": "---\nid: with-presets\n---\n\n<header>\n\n# `<with-presets>`\n\nA freeform text field with visible presets\n\n</header>\n\n\n\n## Feat"
  },
  {
    "path": "elements/with-presets/style.css",
    "chars": 214,
    "preview": ":host {\n\tdisplay: flex;\n\tgap: .2em;\n}\n\n:host([vertical]) {\n\tflex-flow: column;\n\talign-items: stretch;\n}\n\n::slotted(input"
  },
  {
    "path": "elements/with-presets/with-presets.js",
    "chars": 3015,
    "preview": "export default class WithPresets extends HTMLElement {\n\t#observer\n\n\tconstructor() {\n\t\tsuper();\n\n\t\tthis.attachShadow({ mo"
  },
  {
    "path": "package.json",
    "chars": 1160,
    "preview": "{\n  \"name\": \"nudeui\",\n  \"version\": \"0.0.1\",\n  \"description\": \"\",\n  \"main\": \"index.html\",\n  \"scripts\": {\n    \"test\": \"ech"
  },
  {
    "path": "postcss.config.cjs",
    "chars": 70,
    "preview": "module.exports = {\n\tplugins: [\n\t\trequire('postcss-nesting')({})\n\t],\n};"
  },
  {
    "path": "style/forms.css",
    "chars": 1745,
    "preview": "@import url('tokens.css');\n\nbutton, input, textarea, select,\n.button,\n::part(button), ::part(input) {\n\tfont: inherit;\n\tb"
  },
  {
    "path": "style/tables.css",
    "chars": 295,
    "preview": "table {\n\twidth: 100%;\n\twidth: -moz-available;\n\twidth: -webkit-fill-available;\n\twidth: stretch;\n\tborder-collapse: collaps"
  },
  {
    "path": "style/tokens.css",
    "chars": 1693,
    "preview": ":where(:root) {\n\t--color-magenta-hs: var(--accent-color-hs);\n\n\t--color-blue-hs: var(--accent-color-2-hs);\n\t--color-blue:"
  },
  {
    "path": "style.css",
    "chars": 2233,
    "preview": "@import url('style/tokens.css');\n@import url('style/forms.css');\n@import url('style/tables.css');\n@import url('https://p"
  }
]

About this extraction

This page contains the full source code of the LeaVerou/nudeui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 57 files (102.8 KB), approximately 31.0k tokens, and a symbol index with 151 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!