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

Installation

{% if css_only %}

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

@import url('https://nudeui.com/elements/{{ id }}/{{ id }}.css');

Then use class="{{ id }}" on the types of elements described above.

{% else %}

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

<script src="https://nudeui.com/elements/{{ id }}/{{ id }}.js" type="module"></script>

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 %}

{% endif %} ================================================ 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", `Nude UI`); } } HTMLDemo.wrapAll(); ================================================ FILE: elements/button-group/README.md ================================================ --- id: button-group title: includes: '' ---
# `` Group of exclusive push buttons
## 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 ``` Providing values: ```html ``` Pre-selected state via `aria-pressed`: ```html ``` Multiple: ```html ``` Participates in form submission (requires [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) support): ```html
``` Vertical ```html ``` Separate ```html ``` Dynamically setting `element.value`: ```html ``` Dynamically adding `aria-pressed` attribute: ```html ``` Dynamically adding options: ```html ``` [WIP](https://twitter.com/LeonieWatson/status/1547544701036888065): `` 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 ``` Regular labels should work too (requires [`ElementInternals`](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) support): ```html ``` You don't even need to use an actual ` ``` Set `element.value`: ```html 👍🏼 👎🏼 ``` Dynamic `aria-selected`: ```html 😔 😕 😐 🙂 😀 ``` ================================================ 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 = ``; 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 ---
# `` An element for propagating data changes between elements.
## Features - TBD ## Examples ### Basic Display slider value: ```html ``` Show character count: ```html ``` ================================================ 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