color-elements
Advanced tools
Comparing version 0.0.2 to 0.0.3
@@ -7,3 +7,3 @@ { | ||
"_includes/plain.njk", | ||
"_data/eleventyComputed.js", | ||
"data/eleventyComputed.js", | ||
["_build/eleventy.js", "_build/eleventy-original.js"], | ||
@@ -10,0 +10,0 @@ "_build/filters.js" |
import markdownIt from "markdown-it"; | ||
import markdownItAttrs from "markdown-it-attrs"; | ||
import markdownItAnchor from "markdown-it-anchor"; | ||
import configOriginal from "./eleventy-original.js"; | ||
@@ -8,3 +9,3 @@ import * as filters from "./filters-extra.js"; | ||
"permalink": "{{ page.filePathStem | replace('README', '') | replace('index', '') }}/index.html", | ||
"body_classes": "cn-ignore" | ||
"body_classes": "cn-ignore", | ||
}; | ||
@@ -18,3 +19,7 @@ | ||
.disable("code") | ||
.use(markdownItAttrs); | ||
.use(markdownItAttrs) | ||
.use(markdownItAnchor, { | ||
permalink: markdownItAnchor.permalink.headerLink(), | ||
level: 2, | ||
}); | ||
@@ -21,0 +26,0 @@ export default config => { |
export function tag_to_class (tag) { | ||
return tag?.replace(/(?:^|-)([a-z])/g, ($0, $1) => $1.toUpperCase()); | ||
} | ||
} |
import "https://colorjs.io/assets/js//prism.js"; | ||
import "https://colorjs.io/assets/js/colors.js"; | ||
import "https://blissfuljs.com/bliss.shy.js"; | ||
@@ -7,7 +8,2 @@ import { styleCallouts } from "https://colorjs.io/assets/js/enhance.js"; | ||
let root = document.documentElement; | ||
document.addEventListener("scroll", evt => { | ||
root.style.setProperty("--scrolltop", root.scrollTop); | ||
}, {passive: true}); | ||
import HTMLDemoElement from "https://nudeui.com/elements/html-demo/html-demo.js"; | ||
@@ -17,2 +13,6 @@ | ||
HTMLDemoElement.wrapAll(); | ||
} | ||
} | ||
if (window.toc) { | ||
import("https://colorjs.io/assets/js/docs.js"); | ||
} |
export { default as ColorPicker } from "./src/color-picker/color-picker.js"; | ||
export { default as ColorScale } from "./src/color-scale/color-scale.js"; | ||
export { default as ColorChart } from "./src/color-chart/color-chart.js"; | ||
export { default as ColorSwatch } from "./src/color-swatch/color-swatch.js"; | ||
@@ -6,2 +8,4 @@ export { default as ColorInline } from "./src/color-inline/color-inline.js"; | ||
export { default as ColorSlider } from "./src/color-slider/color-slider.js"; | ||
export { default as ColorGamut } from "./src/color-gamut/color-gamut.js"; | ||
export { default as GamutBadge } from "./src/gamut-badge/gamut-badge.js"; | ||
export { default as ChannelPicker } from "./src/channel-picker/channel-picker.js"; | ||
export { default as SpacePicker } from "./src/space-picker/space-picker.js"; |
{ | ||
"name": "color-elements", | ||
"version": "0.0.2", | ||
"version": "0.0.3", | ||
"description": "A set of web components for working with color. A Color.js project.", | ||
@@ -44,2 +44,3 @@ "main": "index.js", | ||
"markdown-it-attrs": "^4.1.6", | ||
"markdown-it-anchor": "^8", | ||
"npm-run-all": "^4.1.5", | ||
@@ -46,0 +47,0 @@ "release-it": "^17.2.0" |
@@ -6,2 +6,30 @@ # Color Elements | ||
## All elements | ||
<section class="showcase"> | ||
{% for name, description in components -%} | ||
<a href="{{ page | relative }}/src/{{ name }}/"> | ||
<figure> | ||
<img src="{{ page | relative }}/src/{{ name }}/{{ name }}.webp" alt="A screenshot showcasing the <{{ name }}> color element" /> | ||
<figcaption> | ||
<h2><{{ name }}></h2> | ||
<p>{{ description | safe }}</p> | ||
</figcaption> | ||
</figure> | ||
</a> | ||
{% endfor %} | ||
</section> | ||
### Upcoming | ||
<section class="showcase upcoming"> | ||
{% for name in ["color-plane"] -%} | ||
<figure> | ||
<figcaption> | ||
<h2><{{ name }}></h2> | ||
</figcaption> | ||
</figure> | ||
{% endfor %} | ||
</section> | ||
## Usage | ||
@@ -14,3 +42,3 @@ | ||
```html | ||
<script src="https://elements.colorjs.io/index.js"></script> | ||
<script src="https://elements.colorjs.io/index.js" type="module"></script> | ||
``` | ||
@@ -21,3 +49,3 @@ | ||
```html | ||
<script src="https://elements.colorjs.io/src/COMPONENT_NAME/COMPONENT_NAME.js"></script> | ||
<script src="https://elements.colorjs.io/src/COMPONENT_NAME/COMPONENT_NAME.js" type="module"></script> | ||
``` | ||
@@ -46,14 +74,1 @@ | ||
``` | ||
## All elements | ||
- [`<color-picker>`](src/color-picker/) | ||
- [`<color-slider>`](src/color-slider/) | ||
- [`<channel-slider>`](src/channel-slider/) | ||
- [`<color-swatch>`](src/color-swatch/) | ||
- [`<color-inline>`](src/color-inline/) | ||
- [`<color-gamut>`](src/color-gamut/) | ||
### Upcoming: | ||
- `<color-plane>` |
import "../color-slider/color-slider.js"; | ||
import * as dom from "../common/dom.js"; | ||
import Color from "../common/color.js"; | ||
import NudeElement from "../../node_modules/nude-element/src/Element.js"; | ||
import ColorElement from "../common/color-element.js"; | ||
import { getStep } from "../common/util.js"; | ||
export const tagName = "channel-slider"; | ||
const Self = class ChannelSlider extends ColorElement { | ||
static tagName = "channel-slider"; | ||
static url = import.meta.url; | ||
static shadowStyle = true; | ||
static shadowTemplate = ` | ||
<label class="color-slider-label" part="label"> | ||
<slot> | ||
<span id="channel_info" part="channel-info"></span> | ||
<input type="number" part="spinner" min="0" max="1" step="0.01" id="spinner" /> | ||
</slot> | ||
<color-slider part="color_slider" exportparts="slider" id="slider"></color-slider> | ||
</label>`; | ||
export default class ChannelSlider extends NudeElement { | ||
constructor () { | ||
super(); | ||
this.attachShadow({mode: "open"}); | ||
let styleURL = new URL(`./${tagName}.css`, import.meta.url); | ||
this.shadowRoot.innerHTML = ` | ||
<style>@import url("${ styleURL }")</style> | ||
<label class="color-slider-label" part="label"> | ||
<slot></slot> | ||
<color-slider part="color_slider" exportparts="slider" id="slider" tooltip></color-slider> | ||
</label> | ||
`; | ||
this._el = dom.named(this); | ||
@@ -27,13 +26,15 @@ this._el.slot = this.shadowRoot.querySelector("slot"); | ||
connectedCallback() { | ||
connectedCallback () { | ||
super.connectedCallback?.(); | ||
this._el.slider.addEventListener("input", this); | ||
this._el.slot.addEventListener("input", this); | ||
} | ||
disconnectedCallback() { | ||
disconnectedCallback () { | ||
this._el.slider.removeEventListener("input", this); | ||
this._el.slot.removeEventListener("input", this); | ||
} | ||
handleEvent(event) { | ||
handleEvent (event) { | ||
if (event.type === "input") { | ||
@@ -46,8 +47,10 @@ this.value = event.target.value; | ||
let color = this.defaultColor.clone(); | ||
try { | ||
if (this.channel === "alpha") { | ||
color.alpha = value / 100; | ||
} | ||
else if (this.channel in color.space.coords) { | ||
color.set(this.channel, value); | ||
} | ||
catch (e) { | ||
console.warn(e); | ||
} | ||
return color; | ||
@@ -91,4 +94,17 @@ } | ||
prop.applyChange(this._el.slider, change); | ||
}; | ||
if (["min", "max", "step", "value", "defaultValue"].includes(name)) { | ||
prop.applyChange(this._el.spinner, change); | ||
if (name === "value" && this.value !== undefined) { | ||
this._el.spinner.value = Number(this.value.toPrecision(4)); | ||
if (!CSS.supports("field-sizing", "content")) { | ||
let valueStr = this._el.spinner.value; | ||
this._el.spinner.style.setProperty("--value-length", valueStr.length); | ||
} | ||
} | ||
} | ||
} | ||
if (name === "defaultColor" || name === "space" || name === "channel" || name === "min" || name === "max") { | ||
@@ -98,3 +114,3 @@ this._el.slider.stops = this.stops; | ||
if (name === "space" || name === "channel" || name === "min" || name === "max") { | ||
this._el.slot.innerHTML = `${ this.channelName } <em>(${ this.min } – ${ this.max })</em>`; | ||
this._el.channel_info.innerHTML = `${ this.channelName } <em>(${ this.min } – ${ this.max })</em>`; | ||
} | ||
@@ -104,4 +120,2 @@ } | ||
get channelName () { | ||
@@ -115,3 +129,3 @@ return this.channelSpec?.name ?? this.channel; | ||
parse (value) { | ||
if (value instanceof Color.Space || value === null || value === undefined) { | ||
if (value instanceof Self.Color.Space || value === null || value === undefined) { | ||
return value; | ||
@@ -122,3 +136,3 @@ } | ||
return Color.Space.get(value); | ||
return Self.Color.Space.get(value); | ||
}, | ||
@@ -150,2 +164,8 @@ stringify (value) { | ||
get () { | ||
if (this.channel === "alpha") { | ||
return { | ||
name: "Alpha", | ||
}; | ||
} | ||
let channelSpec = this.space?.coords[this.channel]; | ||
@@ -159,3 +179,3 @@ | ||
return channelSpec; | ||
} | ||
}, | ||
}, | ||
@@ -190,3 +210,12 @@ refRange: { | ||
default () { | ||
return this.defaultColor.get(this.channel); | ||
if (this.channel === "alpha") { | ||
return this.defaultColor.alpha * 100; | ||
} | ||
else if (this.channel in this.defaultColor.space.coords) { | ||
return this.defaultColor.get(this.channel); | ||
} | ||
else { | ||
let firstChannel = Object.keys(this.defaultColor.space.coords)[0]; | ||
return this.defaultColor.get(firstChannel); | ||
} | ||
}, | ||
@@ -204,3 +233,5 @@ reflect: { | ||
defaultColor: { | ||
type: Color, | ||
get type () { | ||
return Self.Color; | ||
}, | ||
convert (color) { | ||
@@ -213,7 +244,7 @@ return color.to(this.space); | ||
let spec = this.space.coords[channel]; | ||
let range = spec.refRange ?? spec.range; | ||
let range = spec.refRange ?? spec.range ?? [0, 100]; | ||
coords.push((range[0] + range[1]) / 2); | ||
} | ||
return new Color(this.space, coords); | ||
return new Self.Color(this.space, coords); | ||
}, | ||
@@ -225,3 +256,5 @@ reflect: { | ||
color: { | ||
type: Color, | ||
get type () { | ||
return Self.Color; | ||
}, | ||
get () { | ||
@@ -236,3 +269,3 @@ return this.colorAt(this.value); | ||
}, | ||
} | ||
}; | ||
@@ -243,3 +276,3 @@ static events = { | ||
return this._el.slider; | ||
} | ||
}, | ||
}, | ||
@@ -249,3 +282,3 @@ input: { | ||
return this._el.slider; | ||
} | ||
}, | ||
}, | ||
@@ -261,3 +294,3 @@ valuechange: { | ||
static formAssociated = { | ||
getSource: el => el._el.slider, | ||
like: el => el._el.slider, | ||
role: "slider", | ||
@@ -267,4 +300,6 @@ valueProp: "value", | ||
}; | ||
} | ||
}; | ||
customElements.define(tagName, ChannelSlider); | ||
Self.define(); | ||
export default Self; |
@@ -9,4 +9,4 @@ # `<channel-slider>` | ||
It offers many conveniences for these cases: | ||
- It takes care of applying the right `min` and `max` values to the slider | ||
- It automatically generates the start and end colors, | ||
- It takes care of applying the right `min`, `max`, and `step` values to the slider | ||
- It automatically generates the start and end colors | ||
- It can provide an editable tooltip as a tooltip that both shows and edits the current value | ||
@@ -23,2 +23,8 @@ - Already includes a suitable label | ||
The alpha channel is also supported: | ||
```html | ||
<channel-slider space="oklch" channel="alpha"></channel-slider> | ||
``` | ||
In most cases you’d also want to set a color to set the other channels and the initial value: | ||
@@ -86,2 +92,3 @@ | ||
<option>h</option> | ||
<option>alpha</option> | ||
</select> | ||
@@ -96,4 +103,4 @@ </label> | ||
space_select.value = dynamic_slider.space.id; | ||
channel_select.innerHTML = Object.keys(dynamic_slider.space.coords).map(c => `<option>${c}</option>`).join('\n'); | ||
channel_select.value = dynamic_slider.channel; | ||
channel_select.innerHTML = [...Object.keys(dynamic_slider.space.coords).map(c => `<option>${c}</option>`), "<option>alpha</option>"].join('\n'); | ||
channel_select.value = dynamic_slider.channel || channel_select.options[0].value; | ||
} | ||
@@ -103,3 +110,3 @@ | ||
dynamic_slider.space = space_select.value; | ||
dynamic_slider.channel = channel_select.value; | ||
dynamic_slider.channel = channel_select.value || channel_select.options[0].value; | ||
} | ||
@@ -117,2 +124,8 @@ | ||
### Slots | ||
| Name | Description | | ||
|------|-------------| | ||
| _(default)_ | The channel slider's label. | | ||
### Attributes & Properties | ||
@@ -126,5 +139,34 @@ | ||
| `max` | `max` | `number` | `this.refRange[1]` | The maximum value for the slider. | | ||
| `step` | `step` | `number` | Computed automatically based on `this.min` and `this.max`. | The granularity that the slider's current value must adhere to. | | ||
| `value` | `value` | `number` | `(this.min + this.max) / 2` | The current value of the slider. | | ||
| `color` | `color` | `Color` | `string` | `oklch(50 50% 180)` | The current color value. | | ||
| - | `minColor` | `Color` | `oklch(0 50% 180)` | The minimum color value _(read-only)_. | | ||
| - | `maxColor` | `Color` | `oklch(100 50% 180)` | The maximum color value _(read-only)_. | | ||
| `color` | `color` | `Color` | `string` | `oklch(50% 50% 180)` | The current color value. | | ||
### Getters | ||
These properties are read-only. | ||
| Property | Type | Description | | ||
|----------|------|-------------| | ||
| `minColor` | `Color` | The color corresponding to the minimum value of the slider. | | ||
| `maxColor` | `Color` | The color corresponding to the maximum value of the slider. | | ||
| `stops` | `Array<Color>` | The array of the slider color stops used for rendering. Unsupported color spaces or angular channels (hues) will have more color stops, while other channels may have as little as two: `minColor` and `maxColor`. | | ||
| `progress` | `number` | The slider value converted to a 0-1 number with `0` corresponding to the min of the range and `1` to the max. | | ||
| `channelName` | `string` | The name of the channel (e.g. `Hue` or `Alpha`). | | ||
### Events | ||
| Name | Description | | ||
|------|-------------| | ||
| `input` | Fired when the color changes due to user action. | | ||
| `change` | Fired when the color changes due to user action. | | ||
| `valuechange` | Fired when the value changes for any reason, and once during initialization. | | ||
| `colorchange` | Fired when the color changes for any reason, and once during initialization. | | ||
### Parts | ||
| Name | Description | | ||
|------|-------------| | ||
| `color_slider` | The internal `<color-slider>` element. | | ||
| `slider` | The `slider` part of the internal [`<color-slider>`](../color-slider/#parts) element. | | ||
| `label` | The internal `<label>` element used as a wrapper around the default slot and the slider. | |
@@ -1,12 +0,8 @@ | ||
import Color from "../common/color.js"; | ||
import ColorElement from "../common/color-element.js"; | ||
let styleURL = new URL("./color-inline.css", import.meta.url); | ||
export default class ColorInline extends HTMLElement { | ||
#swatch; | ||
constructor () { | ||
super(); | ||
this.attachShadow({mode: "open"}); | ||
this.shadowRoot.innerHTML = `<style>@import url("${ styleURL }");</style> | ||
const Self = class ColorInline extends ColorElement { | ||
static tagName = "color-inline"; | ||
static url = import.meta.url; | ||
static shadowStyle = true; | ||
static shadowTemplate = ` | ||
<div part="swatch-wrapper"> | ||
@@ -16,49 +12,25 @@ <div id="swatch" part="swatch"></div> | ||
</div>`; | ||
this.#swatch = this.shadowRoot.querySelector("#swatch"); | ||
this.attributeChangedCallback(); | ||
} | ||
connectedCallback () { | ||
this.#render(); | ||
ColorInline.#mo.observe(this, {childList: true, subtree: true, characterData: true}); | ||
} | ||
constructor () { | ||
super(); | ||
#value; | ||
get value () { | ||
return this.#value; | ||
this._el = {}; | ||
this._el.swatch = this.shadowRoot.querySelector("#swatch"); | ||
} | ||
set value (value) { | ||
this.#value = value; | ||
this.#render(); | ||
} | ||
#color; | ||
get color () { | ||
return this.#color; | ||
connectedCallback () { | ||
super.connectedCallback?.(); | ||
Self.#mo.observe(this, {childList: true, subtree: true, characterData: true}); | ||
} | ||
#render () { | ||
let colorText = this.value || this.textContent; | ||
propChangedCallback ({name, prop, detail: change}) { | ||
if (name === "color") { | ||
let isValid = this.color !== null; | ||
this._el.swatch.classList.toggle("invalid", !isValid); | ||
try { | ||
this.#color = new Color(colorText); | ||
this.#swatch.style.cssText = `--color: ${this.#color.display()}`; | ||
this.#swatch.classList.remove("invalid"); | ||
let colorString = this.color?.display(); | ||
this._el.swatch.style.setProperty("--color", colorString); | ||
} | ||
catch (e) { | ||
this.#color = null; | ||
this.#swatch.classList.add("invalid"); | ||
} | ||
} | ||
static get observedAttributes () { | ||
return ["value"]; | ||
} | ||
attributeChangedCallback (name, newValue) { | ||
if (!name && this.hasAttribute("value") || name === "value") { | ||
this.value = this.getAttribute("value"); | ||
} | ||
} | ||
static #mo = new MutationObserver(mutations => { | ||
@@ -73,9 +45,43 @@ for (let mutation of mutations) { | ||
if (target) { | ||
target.#render(); | ||
target.value = target.textContent.trim(); | ||
} | ||
} | ||
}); | ||
} | ||
static props = { | ||
value: { | ||
type: String, | ||
default () { | ||
return this.textContent.trim(); | ||
}, | ||
}, | ||
color: { | ||
get type () { | ||
return Self.Color; | ||
}, | ||
defaultProp: "value", | ||
parse (value) { | ||
if (!value) { | ||
return null; | ||
} | ||
customElements.define("color-inline", ColorInline); | ||
return Self.Color.get(value); | ||
}, | ||
reflect: false, | ||
}, | ||
}; | ||
static events = { | ||
colorchange: { | ||
propchange: "color", | ||
}, | ||
valuechange: { | ||
propchange: "value", | ||
}, | ||
}; | ||
}; | ||
Self.define(); | ||
export default Self; |
@@ -17,2 +17,8 @@ --- | ||
You can use `value` to set the color swatch while displaying something else as the content (or even nothing at all): | ||
```html | ||
<color-inline value="lch(50% 40 30)"></color-inline> | ||
``` | ||
Editable: | ||
@@ -32,2 +38,37 @@ ```html | ||
<color-inline>foobar</color-inline> | ||
``` | ||
``` | ||
## Reference | ||
### Slots | ||
| Name | Description | | ||
|------|-------------| | ||
| _(default)_ | The element's main content—the color to be shown. Placed next to the color swatch. | | ||
### Attributes & Properties | ||
| Attribute | Property | Property type | Default value | Description | | ||
|-----------|----------|---------------|---------------|-------------| | ||
| `color` | `color` | `Color` | `null` | - | The current color value. `null` for invalid colors. | | ||
| `value` | `value` | `string` | - | The textual form of the color. Will have a value even if the color is invalid. | | ||
### CSS variables | ||
| Variable | Type | Description | | ||
|----------|---------------|-------------| | ||
| `--transparency-grid` | `<image>` | Gradient used as a background for transparent parts of the swatch. | | ||
| `--transparency-cell-size` | `<length>` | The size of the tiles in the transparency grid. This will not be used if you are overriding `--transparency-grid`. | | ||
| `--transparcency-background` | `<color>` | The background color of the transparency gradient. | | ||
| `--transparency-darkness` | `<percentage>` | The opacity of the black color used for dark parts of the transparency gradient. | | ||
| `--border-width` | `<length>` | The width of the border around the swatch. | | ||
| `--box-shadow-blur` | `<length>` | The blur radius of the box shadow around the swatch. | | ||
| `--box-shadow-color` | `<color>` | The color of the box shadow around the swatch. | | ||
### Parts | ||
| Name | Description | | ||
|------|-------------| | ||
| `swatch-wrapper` | The component’s base wrapper. | | ||
| `swatch` | An internal element used to provide a visual preview of the current color. | |
@@ -0,9 +1,23 @@ | ||
import ColorElement from "../common/color-element.js"; | ||
import "../channel-slider/channel-slider.js"; | ||
import "../color-swatch/color-swatch.js"; | ||
import NudeElement from "../../node_modules/nude-element/src/Element.js"; | ||
import "../space-picker/space-picker.js"; | ||
import * as dom from "../common/dom.js"; | ||
import Color from "../common/color.js"; | ||
const Self = class ColorPicker extends NudeElement { | ||
const Self = class ColorPicker extends ColorElement { | ||
static tagName = "color-picker"; | ||
static url = import.meta.url; | ||
static dependencies = new Set(["channel-slider"]); | ||
static shadowStyle = true; | ||
static shadowTemplate = ` | ||
<slot name="color-space"> | ||
<space-picker id="space_picker" part="color-space" exportparts="base: color-space-base"></space-picker> | ||
</slot> | ||
<div id="sliders" part="sliders"></div> | ||
<slot name="swatch"> | ||
<color-swatch size="large" id="swatch" part="swatch" exportparts="swatch: swatch-base, gamut, details, info, color-wrapper"> | ||
<slot slot="swatch-content"></slot> | ||
<input value="oklch(70% 0.25 138)" id="color" /> | ||
</color-swatch> | ||
</slot>`; | ||
@@ -13,45 +27,34 @@ constructor () { | ||
this.attachShadow({mode: "open"}); | ||
let styleURL = new URL(`./${Self.tagName}.css`, import.meta.url); | ||
this.shadowRoot.innerHTML = ` | ||
<style>@import url("${ styleURL }")</style> | ||
<div id=sliders></div> | ||
<slot name="swatch"> | ||
<color-swatch size="large" id="swatch"> | ||
<slot slot="swatch-content"></slot> | ||
<input value="oklch(70% 0.25 138)" id="color" /> | ||
</color-swatch> | ||
</slot> | ||
`; | ||
this._el = dom.named(this); | ||
this._slots = { | ||
color_space: this.shadowRoot.querySelector("slot[name=color-space]"), | ||
}; | ||
} | ||
connectedCallback() { | ||
connectedCallback () { | ||
super.connectedCallback?.(); | ||
this._el.sliders.addEventListener("input", this); | ||
this._el.swatch.addEventListener("input", this); | ||
this.render(); | ||
this._slots.color_space.addEventListener("input", this); | ||
} | ||
disconnectedCallback() { | ||
disconnectedCallback () { | ||
this._el.sliders.removeEventListener("input", this); | ||
this._el.swatch.removeEventListener("input", this); | ||
this._slots.color_space.removeEventListener("input", this); | ||
} | ||
handleEvent(event) { | ||
this.render(event.target); | ||
handleEvent (event) { | ||
let source = event.target; | ||
this.dispatchEvent(new event.constructor(event.type, {...event})); | ||
} | ||
render (source) { | ||
if (!source || this._el.sliders.contains(source)) { | ||
if (this._el.sliders.contains(source)) { | ||
// From sliders | ||
let coords = [...this._el.sliders.children].map(el => el.value); | ||
this.color = new Color(this.space, coords); | ||
this._el.swatch.value = this.color; | ||
let alpha = this.color.alpha; | ||
if (coords.length > 3) { | ||
alpha = coords.pop() / 100; | ||
} | ||
this.color = new Self.Color(this.space, coords, alpha); | ||
} | ||
else if (!source || this._el.swatch.contains(source)) { | ||
else if (this._el.swatch.contains(source)) { | ||
// From swatch | ||
@@ -64,22 +67,42 @@ if (!this._el.swatch.color) { | ||
} | ||
else if (this._el.space_picker.contains(source) || this._slots.color_space.assignedElements().includes(source)) { | ||
this.spaceId = event.target.value; | ||
} | ||
for (let slider of this._el.sliders.children) { | ||
slider.defaultColor = this.color; | ||
} | ||
this.dispatchEvent(new event.constructor(event.type, {...event})); | ||
} | ||
propChangedCallback ({name, prop, detail: change}) { | ||
if (name === "space") { | ||
if (name === "space" || name === "alpha") { | ||
let space = this.space; | ||
if (this.color.space !== space) { | ||
this.color = this.color.to(space); | ||
} | ||
let i = 0; | ||
for (let channel in this.space.coords) { | ||
let channels = [...Object.keys(this.space.coords)]; | ||
if (this.alpha) { | ||
channels.push("alpha"); | ||
} | ||
for (let channel of channels) { | ||
let slider = this._el.sliders.children[i++]; | ||
if (slider) { | ||
slider.space = this.space; | ||
slider.space = space; | ||
slider.channel = channel; | ||
} | ||
else { | ||
this._el.sliders.insertAdjacentHTML("beforeend", `<channel-slider space="${ this.space.id }" channel="${ channel }"></channel-slider>`); | ||
this._el.sliders.insertAdjacentHTML("beforeend", `<channel-slider space="${ space.id }" channel="${ channel }" part="channel-slider"></channel-slider>`); | ||
} | ||
} | ||
if (this._el.sliders.children.length > channels.length) { | ||
// Remove the slider for alpha | ||
this._el.sliders.children[channels.length].remove(); | ||
} | ||
for (let slider of this._el.sliders.children) { | ||
slider.color = this.color; | ||
} | ||
} | ||
@@ -92,3 +115,3 @@ | ||
if (this.color && (!this._el.swatch.color || !this.color.equals(this._el.swatch.color))) { | ||
if (!this._el.swatch.color || !this.color.equals(this._el.swatch.color)) { | ||
// Avoid typing e.g. "red" and having it replaced with "rgb(100% 0% 0%)" under your caret | ||
@@ -101,20 +124,66 @@ prop.applyChange(this._el.swatch, change); | ||
static props = { | ||
space: { | ||
spaceId: { | ||
default: "oklch", | ||
parse (value) { | ||
if (value instanceof Color.Space || value === null || value === undefined) { | ||
convert (value) { | ||
if (value === null || value === undefined) { | ||
return value; | ||
} | ||
else if (value instanceof Self.Color.Space) { | ||
return value.id; | ||
} | ||
value += ""; | ||
return value + ""; | ||
}, | ||
changed ({parsedValue, source, ...change}) { | ||
if (!parsedValue && source !== "default") { | ||
// Something went wrong. We should always have a value. Falling back to the current space | ||
this.spaceId = this.space.id; | ||
return; | ||
} | ||
return Color.Space.get(value); | ||
if (this.props.space && this.props.space.id !== parsedValue) { | ||
// The space object we have in the cache is outdated. We need to delete it so that the space getter returns the updated one | ||
delete this.props.space; | ||
} | ||
}, | ||
stringify (value) { | ||
return value?.id; | ||
reflect: { | ||
from: "space", | ||
}, | ||
}, | ||
space: { | ||
get () { | ||
return this._el.space_picker.selectedSpace; | ||
}, | ||
set: true, | ||
changed ({parsedValue, ...change}) { | ||
if (parsedValue === undefined) { | ||
// this.spaceId changed | ||
if (this._el.space_picker.value !== this.spaceId) { | ||
this._el.space_picker.value = this.spaceId; | ||
} | ||
return; | ||
} | ||
else if (!parsedValue) { | ||
// Something went wrong. We should always have a value. Falling back to the current space | ||
this.space = this._el.space_picker.selectedSpace; | ||
return; | ||
} | ||
parsedValue = parsedValue instanceof Self.Color.Space ? parsedValue.id : parsedValue; | ||
if (this.spaceId !== parsedValue) { | ||
this._el.space_picker.value = parsedValue; | ||
this.spaceId = parsedValue; | ||
} | ||
}, | ||
dependencies: ["spaceId"], | ||
defaultProp: "spaceId", | ||
reflect: false, | ||
}, | ||
defaultColor: { | ||
type: Color, | ||
get type () { | ||
return Self.Color; | ||
}, | ||
convert (color) { | ||
@@ -131,3 +200,3 @@ return color.to(this.space); | ||
return new Color(this.space, coords); | ||
return new Self.Color(this.space, coords); | ||
}, | ||
@@ -140,7 +209,28 @@ reflect: { | ||
color: { | ||
type: Color, | ||
set (value) { | ||
this.defaultColor = new Color(value).to(this.space); | ||
get type () { | ||
return Self.Color; | ||
}, | ||
defaultProp: "defaultColor", | ||
reflect: false, | ||
}, | ||
alpha: { | ||
parse (value) { | ||
if (value === undefined || value === null) { | ||
return; | ||
} | ||
if (value === false || value === "false") { | ||
return false; | ||
} | ||
if (value === "" || value === "alpha" || value === true || value === "true") { | ||
// Boolean attribute | ||
return true; | ||
} | ||
}, | ||
reflect: { | ||
from: true, | ||
}, | ||
}, | ||
}; | ||
@@ -151,13 +241,10 @@ | ||
from () { | ||
return [this._el.sliders, this._el.swatch]; | ||
} | ||
return [this._el.space_picker, this._el.sliders, this._el.swatch]; | ||
}, | ||
}, | ||
input: { | ||
from () { | ||
return [this._el.sliders, this._el.swatch]; | ||
} | ||
return [this._el.space_picker, this._el.sliders, this._el.swatch]; | ||
}, | ||
}, | ||
valuechange: { | ||
propchange: "value", | ||
}, | ||
colorchange: { | ||
@@ -167,6 +254,6 @@ propchange: "color", | ||
}; | ||
} | ||
}; | ||
customElements.define(Self.tagName, Self); | ||
Self.define(); | ||
export default Self; | ||
export default Self; |
@@ -17,2 +17,16 @@ # `<color-picker>` | ||
If no color space or color is provided, the default ones will be used: `oklch` for the space and `oklch(50% 50% 180)` for the color. | ||
```html | ||
<color-picker></color-picker> | ||
``` | ||
### The `alpha` attribute | ||
Colors with the alpha channel are also supported. Add the `alpha` boolean attribute to show the alpha channel: | ||
```html | ||
<color-picker space="oklch" color="oklch(60% 30% 180 / 0.6)" alpha></color-picker> | ||
``` | ||
### Slots | ||
@@ -26,2 +40,25 @@ | ||
You can use your component instead of the default color swatch: | ||
```html | ||
<color-picker space="oklch" color="oklch(50% 50% 180)" | ||
oncolorchange="this.firstElementChild.textContent = this.color"> | ||
<color-inline slot="swatch" style="place-self: center; min-inline-size: fit-content"></color-inline> | ||
</color-picker> | ||
``` | ||
or your own form element instead of the default space picker: | ||
```html | ||
<color-picker space="oklab" color="oklab(60% -0.12 0)"> | ||
<select slot="color-space" size="4"> | ||
<optgroup label="Rectangular Spaces"> | ||
<option value="lab">Lab</option> | ||
<option value="oklab" selected>Oklab</option> | ||
<option value="prophoto">ProPhoto</option> | ||
</optgroup> | ||
</select> | ||
</color-picker> | ||
``` | ||
### Events | ||
@@ -34,9 +71,56 @@ | ||
oncolorchange="this.firstElementChild.textContent = this.color.oklch.join(' ')"> | ||
<div class="coords" style="font-weight: bold; text-shadow: 0 0 .1em white, 0 0 .1em white, 0 0 .1em white"></div> | ||
<div class="coords"></div> | ||
</color-picker> | ||
``` | ||
### Dynamic | ||
All attributes are reactive: | ||
```html | ||
<color-picker space="oklch" color="oklch(60% 30% 180)" id="dynamic_picker"> | ||
<fieldset slot="color-space"> | ||
<legend>Polar Spaces</legend> | ||
<label> | ||
<input type="radio" name="space" value="oklch" checked /> OKLCh | ||
</label> | ||
<label> | ||
<input type="radio" name="space" value="hwb" /> HWB | ||
</label> | ||
<label> | ||
<input type="radio" name="space" value="hsl" /> HSL | ||
</label> | ||
</fieldset> | ||
</color-picker> | ||
<script> | ||
let radios = dynamic_picker.querySelectorAll("input[name=space]"); | ||
radios.forEach(radio => radio.addEventListener("change", evt => dynamic_picker.spaceId = evt.target.value)); | ||
</script> | ||
<style> | ||
label + label { | ||
margin-inline-start: .3em; | ||
} | ||
</style> | ||
``` | ||
```html | ||
<label> | ||
<input type="checkbox" onchange="this.parentElement.nextElementSibling.alpha = this.checked" /> Alpha channel | ||
</label> | ||
<color-picker></color-picker> | ||
``` | ||
## Reference | ||
### Slots | ||
| Name | Description | | ||
|------|-------------| | ||
| (default) | The color picker's main content. Goes into the swatch. | | ||
| `color-space` | An element to display (and if writable, also set) the current color space. If not provided, a [`<space-picker>`](../space-picker/) is used. | | ||
| `swatch` | An element used to provide a visual preview of the current color. | | ||
### Attributes & Properties | ||
@@ -46,7 +130,25 @@ | ||
|-----------|----------|---------------|---------------|-------------| | ||
| `space` | `space` | `ColorSpace` | `string` | `oklch` | The color space to use for interpolation. | | ||
| `color` | `color` | `Color` | `string` | `oklch(50 50% 180)` | The current color value. | | ||
| `space` | `spaceId` | `string` | `oklch` | The color space to use for interpolation. | | ||
| – | `space` | `ColorSpace` | `OKLCh` | Color space object corresponding to the `space` attribute. | | ||
| `color` | `color` | `Color` | `string` | `oklch(50% 50% 180)` | The current color value. | | ||
| `alpha` | `alpha` | `boolean` | `undefined` | `undefined` | Whether to show the alpha channel slider or not. | | ||
## To-Do | ||
### Events | ||
- Alpha | ||
| Name | Description | | ||
|------|-------------| | ||
| `input` | Fired when the color changes due to user action, such as adjusting the sliders, entering a color in the swatch's text field, or choosing a different color space. | | ||
| `change` | Fired when the color changes due to user action, such as adjusting the sliders, entering a color in the swatch's text field, or choosing a different color space. | | ||
| `colorchange` | Fired when the color changes for any reason, and once during initialization. | | ||
### CSS variables | ||
The styling of `<color-picker>` is fully customizable via CSS variables provided by the [`<color-slider>`](../color-slider/#css-variables) and [`<color-swatch>`](../color-swatch/#css-variables). | ||
### Parts | ||
| Name | Description | | ||
|------|-------------| | ||
| `color-space` | The default [`<space-picker>`](../space-picker/) element, used if the `color-space` slot has no slotted elements. | | ||
| `color-space-base` | The internal `<select>` element of the default [`<space-picker>`](../space-picker/) element. | | ||
| `swatch` | The default [`<color-swatch>`](../color-swatch/) element, used if the `swatch` slot has no slotted elements. | |
@@ -1,4 +0,2 @@ | ||
import Color from "../common/color.js"; | ||
import NudeElement from "../../node_modules/nude-element/src/Element.js"; | ||
import ColorElement from "../common/color-element.js"; | ||
import { getStep } from "../common/util.js"; | ||
@@ -11,20 +9,16 @@ | ||
const Self = class ColorSlider extends NudeElement { | ||
static postConstruct = []; | ||
const Self = class ColorSlider extends ColorElement { | ||
static tagName = "color-slider"; | ||
static url = import.meta.url; | ||
static shadowStyle = true; | ||
static shadowTemplate = ` | ||
<input type="range" class="color-slider" part="slider" min="0" max="1" step="0.01" /> | ||
<slot name="tooltip" class="slider-tooltip"> | ||
<input type="number" part="spinner" min="0" max="1" step="0.01" /> | ||
</slot> | ||
<slot></slot>`; | ||
constructor () { | ||
super(); | ||
this.attachShadow({mode: "open"}); | ||
let styleURL = new URL(`./${this.constructor.tagName}.css`, import.meta.url); | ||
this.shadowRoot.innerHTML = ` | ||
<style>@import url("${ styleURL }")</style> | ||
<input type="range" class="color-slider" part="slider" min="0" max="1" step="0.01" part="slider" /> | ||
<slot name="tooltip" class="slider-tooltip"> | ||
<input type="number" part="spinner" min="0" max="1" step="0.01" /> | ||
</slot> | ||
<slot></slot> | ||
`; | ||
this._el = { | ||
@@ -36,3 +30,3 @@ slider: this.shadowRoot.querySelector(".color-slider"), | ||
connectedCallback() { | ||
connectedCallback () { | ||
super.connectedCallback?.(); | ||
@@ -44,3 +38,3 @@ | ||
disconnectedCallback() { | ||
disconnectedCallback () { | ||
this._el.slider.removeEventListener("input", this); | ||
@@ -69,4 +63,13 @@ this._el.spinner.removeEventListener("input", this); | ||
let spinnerValue = this.tooltip === "progress" ? +(this.progress * 100).toPrecision(4) : this.value; | ||
prop.applyChange(this._el.spinner, {...change, value: spinnerValue}); | ||
let value = change.value; | ||
if (this.tooltip === "progress") { | ||
if (name === "value" || name === "defaultValue") { | ||
value = +(this.progress * 100).toPrecision(4); | ||
} | ||
else { | ||
// Spinner values when tooltip is "progress" | ||
value = ({ min: 1, max: 100, step: 1 })[name]; | ||
} | ||
} | ||
prop.applyChange(this._el.spinner, {...change, value: +(+value).toPrecision(4)}); | ||
} | ||
@@ -79,3 +82,23 @@ | ||
if (!supported) { | ||
// CSS does not support (yet?) a raw hue interpolation, | ||
// so we need to fake it with tessellateStops() in cases of polar space and far-apart stops. | ||
let farApart = false; | ||
let space = this.space; | ||
if (space.isPolar) { | ||
for (let i = 1; i < stops.length; i++) { | ||
// Even though space is polar, color stops might be in non-polar spaces | ||
let first = stops[i - 1].to(space); | ||
let second = stops[i].to(space); | ||
let firstHue = first.get("h"); | ||
let secondHue = second.get("h"); | ||
if (Math.abs(firstHue - secondHue) >= 180) { | ||
farApart = true; | ||
break; | ||
} | ||
} | ||
} | ||
if (!supported || farApart) { | ||
stops = this.tessellateStops({ steps: 3 }); | ||
@@ -107,6 +130,6 @@ } | ||
} | ||
else if (name === "value") { | ||
else if (name === "value" || name === "min" || name === "max") { | ||
this.style.setProperty("--progress", this.progress); | ||
if (!supports.fieldSizing) { | ||
if (name === "value" && !supports.fieldSizing) { | ||
let valueStr = this.value + ""; | ||
@@ -116,2 +139,17 @@ this._el.spinner.style.setProperty("--value-length", valueStr.length); | ||
} | ||
else if (name === "tooltip") { | ||
if (change.value !== undefined) { | ||
let values = this; | ||
if (change.value === "progress") { | ||
values = { | ||
min: 1, max: 100, step: 1, | ||
value: +(this.progress * 100).toPrecision(4), | ||
}; | ||
} | ||
["min", "max", "step", "value"].forEach(name => { | ||
this._el.spinner[name] = values[name]; | ||
}); | ||
} | ||
} | ||
} | ||
@@ -123,3 +161,3 @@ | ||
for (let i=1; i<stops.length; i++) { | ||
for (let i = 1; i < stops.length; i++) { | ||
let start = stops[i - 1]; | ||
@@ -194,7 +232,9 @@ let end = stops[i]; | ||
stops: { | ||
type: Array, | ||
typeOptions: { | ||
itemType: Color, | ||
type: { | ||
is: Array, | ||
get values () { | ||
return Self.Color; | ||
}, | ||
}, | ||
default: el => [] | ||
default: el => [], | ||
}, | ||
@@ -221,3 +261,3 @@ defaultValue: { | ||
parse (value) { | ||
if (value instanceof Color.Space || value === null || value === undefined) { | ||
if (value instanceof Self.Color.Space || value === null || value === undefined) { | ||
return value; | ||
@@ -228,3 +268,3 @@ } | ||
return Color.Space.get(value); | ||
return Self.Color.Space.get(value); | ||
}, | ||
@@ -237,3 +277,5 @@ stringify (value) { | ||
color: { | ||
type: Color, | ||
get type () { | ||
return Self.Color; | ||
}, | ||
get () { | ||
@@ -249,6 +291,6 @@ return this.colorAt(this.value); | ||
for (let i=1; i<stops.length; i++) { | ||
for (let i = 1; i < stops.length; i++) { | ||
let start = stops[i - 1]; | ||
let end = stops[i]; | ||
let range = start.range(end, { space: this.space, hue: "raw" }); | ||
let range = start.range(end, { space: this.space }); | ||
scales.push(range); | ||
@@ -271,3 +313,3 @@ } | ||
return this._el.slider; | ||
} | ||
}, | ||
}, | ||
@@ -283,3 +325,3 @@ valuechange: { | ||
static formAssociated = { | ||
getSource: el => el._el.slider, | ||
like: el => el._el.slider, | ||
role: "slider", | ||
@@ -289,6 +331,6 @@ valueProp: "value", | ||
}; | ||
} | ||
}; | ||
customElements.define(Self.tagName, Self); | ||
Self.define(); | ||
export default Self; | ||
export default Self; |
@@ -9,3 +9,3 @@ # `<color-slider>` | ||
E.g. if all you need is styling sliders with arbitrary gradients you don’t even need a component, | ||
you can just [use the CSS file](#css-only) and a few classes and CSS variables to style regular HTML sliders. | ||
you can just [use the CSS file](#css-only-usage) and a few classes and CSS variables to style regular HTML sliders. | ||
@@ -146,2 +146,9 @@ The actual component does a lot more: | ||
### Slots | ||
| Name | Description | | ||
|------|-------------| | ||
| _(default)_ | Content placed after the color slider. | | ||
| `tooltip` | An element used as a tooltip. | | ||
### Attributes & Properties | ||
@@ -152,4 +159,4 @@ | ||
| `space` | `space` | `ColorSpace` | `string` | `oklch` | The color space to use for interpolation. | | ||
| `color` | `color` | `Color` | `string` | `oklch(50 50% 180)` | The current color value. | | ||
| `stops` | `stops` | `String` | `Array<Color>` | - | Comma-separated list of color stops | | ||
| `color` | `color` | `Color` | `string` | `oklch(50% 50% 180)` | The current color value. | | ||
| `stops` | `stops` | `String` | `Array<Color>` | - | Comma-separated list of color stops. | | ||
| `min` | `min` | `number` | 0 | The minimum value for the slider. | | ||
@@ -169,3 +176,7 @@ | `max` | `max` | `number` | 1 | The maximum value for the slider. | | ||
| `--color-space` | `<ident>` | The color space to use for interpolation. | | ||
| `--hue-interpolation` | `[shorter` | `longer` | `increasing` | `decreasing] hue` | The color space to use for interpolation. | | ||
| `--hue-interpolation` | `[shorter` | `longer` | `increasing` | `decreasing] hue` | The hue interpolation method to use. | | ||
| `--transparency-grid` | `<image>` | Gradient used as a background for transparent parts of the slider. | | ||
| `--transparency-cell-size` | `<length>` | The size of the cells of the transparency gradient. | | ||
| `--transparcency-background` | `<color>` | The background color of the transparency gradient. | | ||
| `--transparency-darkness` | `<percentage>` | The opacity of the black color used for dark parts of the transparency gradient. | | ||
| `--slider-gradient` | `<image>` | The gradient to use as the background. | | ||
@@ -186,2 +197,11 @@ | `--slider-height` | `<length>` | Height of the slider track. | | ||
### Getters | ||
These properties are read-only. | ||
| Property | Type | Description | | ||
|----------|------|-------------| | ||
| `progress` | `number` | The slider value converted to a 0-1 number with `0` corresponding to the min of the range and `1` to the max. | | ||
### Events | ||
@@ -196,4 +216,11 @@ | ||
### Parts | ||
| Name | Description | | ||
|------|-------------| | ||
| `slider` | The internal `<input type="range">` element. | | ||
| `spinner` | The default `tooltip` slot content (an `<input type="number">` element). Please note that if an element is slotted in the `tooltip` slot, this will not match anyhing. | | ||
## Planned features | ||
- Discrete scales & steps |
@@ -1,262 +0,240 @@ | ||
import Color from "../common/color.js"; | ||
import defineEvents from "../../node_modules/nude-element/src/events/defineEvents.js"; | ||
import "../color-gamut/color-gamut.js"; | ||
import ColorElement from "../common/color-element.js"; | ||
import "../gamut-badge/gamut-badge.js"; | ||
let importIncrementable; | ||
const Self = class ColorSwatch extends HTMLElement { | ||
static initQueue = []; | ||
const Self = class ColorSwatch extends ColorElement { | ||
static tagName = "color-swatch"; | ||
static url = import.meta.url; | ||
static dependencies = new Set(["gamut-badge"]); | ||
static shadowStyle = true; | ||
static shadowTemplate = ` | ||
<slot name="swatch"> | ||
<div id="swatch" part="swatch"> | ||
<slot name="swatch-content"></slot> | ||
</div> | ||
</slot> | ||
<div id="wrapper" part="details"> | ||
<slot name="before"></slot> | ||
<div part="label"></div> | ||
<div part="color"> | ||
<slot></slot> | ||
</div> | ||
<slot name="after"></slot> | ||
</div>`; | ||
#dom = {}; | ||
constructor () { | ||
super(); | ||
this.attachShadow({mode: "open"}); | ||
let styleURL = new URL("./color-swatch.css", import.meta.url); | ||
this.shadowRoot.innerHTML = ` | ||
<style>@import url("${ styleURL }")</style> | ||
<slot name="swatch"> | ||
<div id="swatch" part="swatch"> | ||
<slot name="swatch-content"></slot> | ||
</div> | ||
</slot> | ||
<div id="wrapper"> | ||
<slot name="before"></slot> | ||
<div part="color-wrapper"> | ||
<slot></slot> | ||
</div> | ||
<slot name="after"></slot> | ||
</div> | ||
`; | ||
} | ||
#initialized; | ||
this._el = { | ||
wrapper: this.shadowRoot.querySelector("#wrapper"), | ||
label: this.shadowRoot.querySelector("[part=label]"), | ||
colorWrapper: this.shadowRoot.querySelector("[part=color]"), | ||
}; | ||
connectedCallback () { | ||
if (!this.#initialized) { | ||
this.#initialize(); | ||
} | ||
this._slots = { | ||
default: this.shadowRoot.querySelector("slot:not([name])"), | ||
}; | ||
this.constructor.initQueue.forEach(init => init.call(this)); | ||
// This should eventually be a custom state | ||
this.#dom.wrapper.classList.toggle("static", !this.#dom.input); | ||
if (this.#dom.input) { | ||
if (!this.#dom.input.incrementable) { | ||
// Increment numbers by keyboard arrow keys | ||
importIncrementable.then(Incrementable => new Incrementable(this.#dom.input)); | ||
} | ||
} | ||
this.#render(); | ||
this.#updateStatic(); | ||
this._slots.default.addEventListener("slotchange", evt => this.#updateStatic()); | ||
} | ||
#errorTimeout; | ||
#cs; | ||
#scopeRoot; | ||
#updateStatic () { | ||
let previousInput = this._el.input; | ||
let input = this._el.input = this.querySelector("input"); | ||
// Gets called when the element is connected for the first time | ||
#initialize ({force} = {}) { | ||
if (!force && this.#initialized) { | ||
return; | ||
} | ||
this.static = !input; | ||
this.#initialized = true; | ||
// This should eventually be a custom state | ||
this._el.wrapper.classList.toggle("static", this.static); | ||
this.#dom.wrapper = this.shadowRoot.querySelector("#wrapper"); | ||
this.#dom.colorWrapper = this.shadowRoot.querySelector("[part=color-wrapper]"); | ||
this.#dom.input = this.querySelector("input"); | ||
this.#dom.slot = this.shadowRoot.querySelector("slot:not([name])"); | ||
if (input && input !== previousInput) { | ||
importIncrementable ??= import("https://incrementable.verou.me/incrementable.mjs").then(m => m.default); | ||
importIncrementable?.then(Incrementable => new Incrementable(input)); | ||
this.#dom.slot.addEventListener("slotchange", evt => { | ||
this.#render(); | ||
}); | ||
if (this.#dom.input) { | ||
importIncrementable ??= import("https://incrementable.verou.me/incrementable.mjs").then(m => m.default); | ||
this.#dom.input.addEventListener("input", evt => { | ||
this.#render(evt); | ||
input.addEventListener("input", evt => { | ||
this.value = evt.target.value; | ||
}); | ||
} | ||
} | ||
this.verbatim = this.hasAttribute("verbatim"); | ||
get gamut () { | ||
return this._el.gamutIndicator.gamut; | ||
} | ||
if (this.verbatim) { | ||
// Cannot display gamut info without parsing the color | ||
this.setAttribute("gamuts", "none"); | ||
} | ||
get swatchTextContent () { | ||
// Children that are not assigned to another slot | ||
return [...this.childNodes].filter(n => !n.slot).map(n => n.textContent).join("").trim(); | ||
} | ||
this.gamuts = null; | ||
if (!this.matches('[gamuts="none"]')) { | ||
this.gamuts = this.getAttribute("gamuts") ?? "srgb, p3, rec2020: P3+, prophoto: PP"; | ||
this.#dom.gamutIndicator = document.createElement("color-gamut"); | ||
propChangedCallback ({name, prop, detail: change}) { | ||
let input = this._el.input; | ||
Object.assign(this.#dom.gamutIndicator, { | ||
gamuts: this.gamuts, | ||
id: "gamut", | ||
part: "gamut", | ||
exportparts: "label: gamutlabel", | ||
}); | ||
this.#dom.colorWrapper.appendChild(this.#dom.gamutIndicator); | ||
this.#dom.gamutIndicator.addEventListener("gamutchange", evt => { | ||
this.setAttribute("gamut", evt.detail.gamut); | ||
this.dispatchEvent(new CustomEvent("gamutchange", { | ||
detail: evt.detail, | ||
})); | ||
}); | ||
} | ||
if (this.hasAttribute("property")) { | ||
this.property = this.getAttribute("property"); | ||
this.scope = this.getAttribute("scope") ?? ":root"; | ||
this.#dom.style = document.createElement("style"); | ||
document.head.appendChild(this.#dom.style); | ||
let varRef = `var(${this.property})`; | ||
if (this.verbatim) { | ||
this.style.setProperty("--color", varRef); | ||
this.value ||= varRef; | ||
if (name === "gamuts") { | ||
if (this.gamuts === "none") { | ||
this._el.gamutIndicator?.remove(); | ||
this._el.gamutIndicator = null; | ||
} | ||
else { | ||
let scopeRoot = this.closest(this.scope); | ||
else if (this.gamuts) { | ||
if (!this._el.gamutIndicator) { | ||
this._el.gamutIndicator = Object.assign(document.createElement("gamut-badge"), { | ||
id: "gamut", | ||
part: "gamut", | ||
exportparts: "label: gamutLabel", | ||
gamuts: this.gamuts, | ||
color: this.color, | ||
}); | ||
// Is contained within scope root | ||
if (scopeRoot) { | ||
this.style.setProperty("--color", varRef); | ||
} | ||
this.shadowRoot.append(this._el.gamutIndicator); | ||
scopeRoot ??= document.querySelector(this.scope); | ||
if (scopeRoot) { | ||
let cs = getComputedStyle(scopeRoot); | ||
this.value = cs.getPropertyValue(this.property); | ||
this._el.gamutIndicator.addEventListener("gamutchange", evt => { | ||
let gamut = this._el.gamutIndicator.gamut; | ||
this.setAttribute("gamut", gamut); | ||
this.dispatchEvent(new CustomEvent("gamutchange", { | ||
detail: gamut, | ||
})); | ||
}); | ||
} | ||
else { | ||
this._el.gamutIndicator.gamuts = this.gamuts; | ||
} | ||
} | ||
} | ||
} | ||
#render (evt) { | ||
if (!this.#initialized) { | ||
return; | ||
if (name === "value") { | ||
if (input && (!input.value || input.value !== this.value)) { | ||
input.value = this.value; | ||
} | ||
} | ||
clearTimeout(this.#errorTimeout); | ||
if (!this.isConnected) { | ||
return; | ||
if (name === "label") { | ||
if (this.label.length && this.label !== this.swatchTextContent) { | ||
this._el.label.textContent = this.label; | ||
} | ||
else { | ||
this._el.label.textContent = ""; | ||
} | ||
} | ||
let value = this.value; | ||
this.#color = null; | ||
if (name === "color") { | ||
let isValid = this.color !== null || !this.value; | ||
if (value) { | ||
try { | ||
this.#color = new Color(value); | ||
} | ||
catch (e) { | ||
// Why a timeout? We don't want to produce errors for intermediate states while typing, | ||
// but if this is a genuine error, we do want to communicate it. | ||
this.#errorTimeout = setTimeout(_ => this.#dom.input?.setCustomValidity(e.message), 500); | ||
} | ||
input?.setCustomValidity(isValid ? "" : "Invalid color"); | ||
if (this.#color) { | ||
this.#setColor(this.#color); | ||
this.#dom.input?.setCustomValidity(""); | ||
if (this._el.gamutIndicator) { | ||
this._el.gamutIndicator.color = this.color; | ||
} | ||
this.dispatchEvent(new CustomEvent("colorchange", { | ||
detail: { | ||
color: this.#color, | ||
}, | ||
})); | ||
let colorString = this.color?.display(); | ||
this.style.setProperty("--color", colorString); | ||
} | ||
} | ||
get gamut () { | ||
return this.#dom.gamutIndicator.gamut; | ||
} | ||
if (name === "colorInfo") { | ||
if (!this.colorInfo) { | ||
return; | ||
} | ||
get value () { | ||
return this.#dom.input?.value ?? this.textContent.trim(); | ||
} | ||
this._el.info ??= Object.assign(document.createElement("dl"), {part: "info"}); | ||
if (!this._el.info.parentNode) { | ||
this._el.colorWrapper.after(this._el.info); | ||
} | ||
set value (value) { | ||
let oldValue = this.value; | ||
if (value === oldValue) { | ||
return; | ||
} | ||
let info = []; | ||
for (let coord of this.info) { | ||
let [label, channel] = Object.entries(coord)[0]; | ||
this.#setValue(value); | ||
this.#render(); | ||
} | ||
let value = this.colorInfo[channel]; | ||
if (value === undefined) { | ||
continue; | ||
} | ||
#setValue (value) { | ||
if (!this.#initialized) { | ||
this.#initialize(); | ||
} | ||
value = typeof value === "number" ? Number(value.toPrecision(4)) : value; | ||
if (this.#dom.input) { | ||
this.#dom.input.value = value; | ||
} | ||
else { | ||
this.textContent = value; | ||
} | ||
} | ||
info.push(`<div class="coord"><dt>${ label }</dt><dd>${ value }</dd></div>`); | ||
} | ||
#color; | ||
get color () { | ||
return this.#color; | ||
} | ||
set color (color) { | ||
if (typeof color === "string") { | ||
color = new Color(color); | ||
this._el.info.innerHTML = info.join("\n"); | ||
} | ||
this.#setColor(color); | ||
let colorString; | ||
if (this.verbatim && this.property) { | ||
colorString = `var(${this.property})`; | ||
} | ||
else { | ||
colorString = color.toString({ precision: 2, inGamut: false }); | ||
} | ||
this.#setValue(colorString); | ||
} | ||
#setColor (color) { | ||
this.#color = color; | ||
let colorString; | ||
static props = { | ||
size: {}, | ||
open: {}, | ||
gamuts: { | ||
default: "srgb, p3, rec2020: P3+, prophoto: PP", | ||
}, | ||
value: { | ||
type: String, | ||
default () { | ||
if (this._el.input) { | ||
return this._el.input.value; | ||
} | ||
if (this.verbatim && this.property) { | ||
colorString = `var(${this.property})`; | ||
} | ||
else { | ||
try { | ||
colorString = this.#color.display({inGamut: false}); | ||
} | ||
catch (e) { | ||
colorString = this.value; | ||
} | ||
} | ||
return this.swatchTextContent; | ||
}, | ||
reflect: { | ||
from: true, | ||
}, | ||
}, | ||
label: { | ||
type: String, | ||
default () { | ||
return this.swatchTextContent; | ||
}, | ||
convert (value) { | ||
return value.trim(); | ||
}, | ||
}, | ||
color: { | ||
get type () { | ||
return ColorSwatch.Color; | ||
}, | ||
get () { | ||
if (!this.value) { | ||
return null; | ||
} | ||
if (this.value === colorString) { | ||
return; | ||
} | ||
return ColorSwatch.Color.get(this.value); | ||
}, | ||
set (value) { | ||
this.value = ColorSwatch.Color.get(value)?.display(); | ||
}, | ||
reflect: false, | ||
}, | ||
info: { | ||
type: { | ||
is: Array, | ||
values: { | ||
is: Object, | ||
defaultKey: (coord, i) => ColorSwatch.Color.Space.resolveCoord(coord)?.name, | ||
}, | ||
}, | ||
default: [], | ||
reflect: { | ||
from: true, | ||
}, | ||
}, | ||
colorInfo: { | ||
get () { | ||
if (!this.info.length || !this.color) { | ||
return; | ||
} | ||
this.style.setProperty("--color", colorString); | ||
let ret = {}; | ||
for (let coord of this.info) { | ||
let [label, channel] = Object.entries(coord)[0]; | ||
try { | ||
ret[channel] = this.color.get(channel); | ||
} | ||
catch (e) { | ||
console.error(e); | ||
} | ||
} | ||
if (this.property) { | ||
this.#dom.style.textContent = `${this.scope} { ${this.property}: ${colorString}; }`; | ||
} | ||
return ret; | ||
}, | ||
}, | ||
}; | ||
if (this.#dom.gamutIndicator) { | ||
this.#dom.gamutIndicator.color = this.#color; | ||
} | ||
} | ||
static events = { | ||
@@ -270,10 +248,6 @@ colorchange: { | ||
}; | ||
}; | ||
static observedAttributes = ["for", "property"]; | ||
} | ||
Self.define(); | ||
defineEvents(Self); | ||
customElements.define("color-swatch", Self); | ||
export default Self; | ||
export default Self; |
@@ -5,7 +5,22 @@ # `<color-swatch>` | ||
### Static | ||
### Basic usage | ||
<table> | ||
<thead> | ||
<tr> | ||
<th></th> | ||
<th>Default</th> | ||
<th>Large</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
<tr> | ||
<th>Static</th> | ||
<td> | ||
```html | ||
<color-swatch>oklch(70% 0.25 138)</color-swatch> | ||
``` | ||
</td> | ||
<td> | ||
@@ -15,5 +30,8 @@ ```html | ||
``` | ||
</td> | ||
</tr> | ||
<tr> | ||
<th>Editable</th> | ||
<td> | ||
### Editable | ||
```html | ||
@@ -24,2 +42,4 @@ <color-swatch> | ||
``` | ||
</td> | ||
<td> | ||
@@ -31,5 +51,174 @@ ```html | ||
``` | ||
</td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
You can use a `--details-style: compact` CSS property to only show the details on user interaction: | ||
```html | ||
<color-swatch style="--details-style: compact">oklch(70% 0.25 138)</color-swatch> | ||
<color-swatch size="large" style="--details-style: compact">oklch(70% 0.25 138)</color-swatch> | ||
``` | ||
Warning: This is not keyboard accessible by default. | ||
To make the element focusable and also show the popup when it is focused, you need to add `tabindex="0"` to your element: | ||
```html | ||
<color-swatch size="large" style="--details-style: compact" tabindex="0">oklch(70% 0.25 138)</color-swatch> | ||
``` | ||
By default, the popup will be shown when the element is hovered, focused, `:active`, or the target of the URL hash. | ||
To circumvent user interaction and force the popup to be open use the `open` attribute. | ||
You can also use `open="false"` to force it to be closed regardless of interaction: | ||
```html | ||
<div style="--details-style: compact"> | ||
<color-swatch size="large">oklch(70% 0.25 138)</color-swatch> | ||
<color-swatch size="large" open>oklch(70% 0.25 138)</color-swatch> | ||
<color-swatch size="large" open="false">oklch(70% 0.25 138)</color-swatch> | ||
</div> | ||
``` | ||
### The `value` attribute | ||
You can provide the color via the `value` attribute, | ||
which can be more convenient when you have slotted content. | ||
In that case, the content of the element is merely presentational | ||
(unless it’s an `<input>`). | ||
If you don’t specify any content, no text will be shown. | ||
<table> | ||
<thead> | ||
<tr> | ||
<th>Static</th> | ||
<th>Editable</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
<tr> | ||
<td> | ||
```html | ||
<color-swatch value="oklch(70% 0.25 138)" size="large">red</color-swatch> | ||
``` | ||
</td> | ||
<td> | ||
```html | ||
<color-swatch value="oklch(70% 0.25 138)" size="large"> | ||
<input /> | ||
</color-swatch> | ||
``` | ||
</td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
You can also use this as a property when creating color swatches dynamically: | ||
```html | ||
<div id="future_swatch_container"></div> | ||
<script> | ||
let swatch = document.createElement("color-swatch"); | ||
swatch.value = "oklch(65% 0.15 210)"; | ||
swatch.setAttribute("size", "large"); | ||
swatch.textContent = "Turquoise"; | ||
future_swatch_container.append(swatch); | ||
</script> | ||
``` | ||
### The `label` attribute | ||
You can provide the color label via the `label` attribute. | ||
<table> | ||
<thead> | ||
<tr> | ||
<th></th> | ||
<th>Default</th> | ||
<th>Large</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
<tr> | ||
<th>Static</th> | ||
<td> | ||
```html | ||
<color-swatch label="Turquoise">oklch(65% 0.15 210)</color-swatch> | ||
``` | ||
</td> | ||
<td> | ||
```html | ||
<color-swatch label="Turquoise" size="large">oklch(65% 0.15 210)</color-swatch> | ||
``` | ||
</td> | ||
</tr> | ||
<tr> | ||
<th>Editable</th> | ||
<td> | ||
```html | ||
<color-swatch label="Turquoise"> | ||
<input value="oklch(65% 0.15 210)" /> | ||
</color-swatch> | ||
``` | ||
</td> | ||
<td> | ||
```html | ||
<color-swatch label="Turquoise" size="large"> | ||
<input value="oklch(65% 0.15 210)" /> | ||
</color-swatch> | ||
``` | ||
</td> | ||
</tr> | ||
</tbody> | ||
</table> | ||
If the attribute's value matches the element's content, no additional text with the label will be shown. | ||
```html | ||
<color-swatch label="Turquoise" value="oklch(65% 0.15 210)" size="large">Turquoise</color-swatch> | ||
``` | ||
If used as a property and is not defined via the `label` attribute, its value is that of the element text content. | ||
### The `info` attribute | ||
You can use the `info` attribute to show information about the color. | ||
Currently, the only type of information supported is color coords (in any color space), but more will be added in the future. | ||
```html | ||
<color-swatch info="oklch.l, oklch.c, oklch.h" size="large"> | ||
oklch(70% 0.25 138) | ||
</color-swatch> | ||
``` | ||
By default, the label for each value will be determined automatically from the type of information (e.g. the full coord name if a coord), | ||
but you can customize this by adding a label before the description of the data: | ||
```html | ||
<color-swatch info="L: oklch.l, C: oklch.c, H: oklch.h" size="large"> | ||
oklch(70% 0.25 138) | ||
</color-swatch> | ||
``` | ||
The `info` attribute plays quite nicely with the `--details-style: compact` style: | ||
```html | ||
<color-swatch size="large" info="oklch.l, oklch.c, oklch.h" style="--details-style: compact">oklch(70% 0.25 138)</color-swatch> | ||
``` | ||
### With slot content | ||
Before and after: | ||
```html | ||
@@ -43,4 +232,11 @@ <color-swatch> | ||
```html | ||
<color-swatch> | ||
<label slot="before" for=c1>Accent color:</label> | ||
oklch(70% 0.25 138) | ||
</color-swatch> | ||
``` | ||
```html | ||
<color-swatch size="large"> | ||
<label slot="before" id=c2>Accent color:</label> | ||
<label slot="before" for=c2>Accent color:</label> | ||
<input value="oklch(70% 0.25 138)" id=c2 /> | ||
@@ -60,2 +256,6 @@ <small slot="after">Tip: Pick a bright medium color.</small> | ||
Note that the text color will automatically switch from black to white to remain readable (using [this technique](https://lea.verou.me/blog/2024/contrast-color/)). | ||
---- | ||
Replacing the whole swatch with a custom element: | ||
@@ -70,6 +270,8 @@ | ||
<!-- | ||
### Bound to CSS property | ||
You can automatically bind the color swatch to a CSS property by setting the `property` attribute. | ||
Then you don’t need to provide an initial value, it will be read from the CSS property. | ||
Then you don’t need to provide an initial value, it will be read from the CSS property, | ||
and updating the color will update the CSS property. | ||
@@ -82,2 +284,6 @@ ```html | ||
You can use `scope` to select the closest ancestor (via a CSS selector) on which the CSS property will be read from and written to. | ||
If you don’t, the `<html>` element will be used. | ||
--> | ||
### Events | ||
@@ -108,2 +314,55 @@ | ||
<button onclick='dynamic_editable.color = "oklch(60% 0.15 0)"'>Change color</button> | ||
``` | ||
``` | ||
## Reference | ||
### Attributes & Properties | ||
| Attribute | Property | Property type | Default value | Description | | ||
|-----------|----------|---------------|---------------|-------------| | ||
| `color` | `color` | `Color` | `string` | - | The current color value. | | ||
| `info` | `info` | `string` | - | Comma-separated list of coords of the current color to be shown. | | ||
| `value` | `value` | `string` | - | The current value of the swatch. | | ||
| `label` | `label` | `string` | - | The label of the swatch (e.g., color name). Defaults to the element text content. | | ||
| `size` | - | `large` | - | The size of the swatch. Currently, it is used only to make a large swatch. | | ||
| `property` | `property` | `string` | - | CSS property to bind to. | | ||
| `scope` | `scope` | `string` | `:root` | CSS selector to use as the scope for the specified CSS property. | | ||
| `gamuts` | `gamuts` | `string` | `srgb, p3, rec2020: P3+, prophoto: PP` | Comma-separated list of gamuts to be used by the gamut indicator. | | ||
| `open` | `open` | | `null` | Force the details popup open or closed. | | ||
### Getters | ||
These properties are read-only. | ||
| Name | Type | Description | | ||
|----------|------|-------------| | ||
| `gamut` | `string` | The id of the current gamut (e.g. `srgb`). | | ||
### CSS variables | ||
| Name | Type | Description | | ||
|----------|---------------|-------------| | ||
| `--details-style` | `compact` | `normal` (default) | | | ||
| `--transparency-grid` | `<image>` | Gradient used as a background for transparent parts of the swatch. | | ||
| `--transparency-cell-size` | `<length>` | The size of the cells of the transparency gradient. | | ||
| `--transparcency-background` | `<color>` | The background color of the transparency gradient. | | ||
| `--transparency-darkness` | `<percentage>` | The opacity of the black color used for dark parts of the transparency gradient. | | ||
### Parts | ||
| Name | Description | | ||
|------|-------------| | ||
| `swatch` | The swatch used to render the color. | | ||
| `details` | Wrapper around all non-swatch content (color name, info, etc) | | ||
| `label` | The label of the swatch | | ||
| `color-wrapper` | Wrapper around the color name itself | | ||
| `gamut` | Gamut indicator | | ||
| `info` | Any info generateed by the `info` attribute | | ||
### Events | ||
| Name | Description | | ||
|------|-------------| | ||
| `valuechange` | Fired when the value changes for any reason, and once during initialization. | | ||
| `colorchange` | Fired when the color changes for any reason, and once during initialization. | | ||
| `gamutchange` | Fired when the gamut changes for any reason, and once during initialization. | |
@@ -61,2 +61,2 @@ export function named (host, attributes = ["id", "part"]) { | ||
} | ||
} | ||
} |
@@ -1,104 +0,9 @@ | ||
/** | ||
* Defines instance properties by defining an accessor that automatically replaces itself with a writable property when accessed. | ||
* @param {Function} Class | ||
* @param {string} name | ||
* @param {function} getValue | ||
*/ | ||
export function defineInstanceProperty ( | ||
Class, name, getValue, | ||
{writable = true, configurable = true, enumerable = false} = {}) { | ||
let setter = function (value) { | ||
Object.defineProperty(this, name, { value, writable, configurable, enumerable }); | ||
export async function wait (ms) { | ||
if (ms === undefined) { | ||
return new Promise(resolve => requestAnimationFrame(resolve)); | ||
} | ||
Object.defineProperty(Class.prototype, name, { | ||
get () { | ||
let value = getValue.call(this, this); | ||
setter.call(this, value); | ||
return value; | ||
}, | ||
set (value) { // Blind set | ||
setter.call(this, value); | ||
}, | ||
configurable: true, | ||
}); | ||
} | ||
export function defineLazyProperty (object, name, options) { | ||
if (typeof options === "function") { | ||
options = { get: options }; | ||
} | ||
let {get, writable = true, configurable = true, enumerable = false} = options; | ||
let setter = function (value) { | ||
Object.defineProperty(this, name, { value, writable, configurable, enumerable }); | ||
} | ||
Object.defineProperty(object, name, { | ||
get () { | ||
let value = get.call(this); | ||
setter.call(this, value); | ||
return value; | ||
}, | ||
set (value) { // Blind set | ||
setter.call(this, value); | ||
}, | ||
configurable: true, | ||
}); | ||
} | ||
export function defineComputed (Class, computed = Class.computed) { | ||
let dependencies = new Map(); | ||
for (let name in computed) { | ||
let spec = computed[name]; | ||
defineInstanceProperty(Class, name, spec.get); | ||
if (spec.dependencies) { | ||
for (let prop of spec.dependencies) { | ||
let deps = dependencies.get(prop) ?? []; | ||
deps.push(name); | ||
dependencies.set(prop, deps); | ||
} | ||
} | ||
} | ||
if (dependencies.size > 0) { | ||
let _propChangedCallback = Class.prototype.propChangedCallback; | ||
Class.prototype.propChangedCallback = function(name, change) { | ||
if (dependencies.has(name)) { | ||
for (let prop of dependencies.get(name)) { | ||
this[prop] = computed[prop].get.call(this, this); | ||
} | ||
} | ||
_propChangedCallback?.call(this, name, change); | ||
} | ||
} | ||
} | ||
export function inferDependencies (fn) { | ||
if (!fn || typeof fn !== "function") { | ||
return []; | ||
} | ||
let code = fn.toString(); | ||
return [...code.matchAll(/\bthis\.([$\w]+)\b/g)].map(match => match[1]); | ||
} | ||
export async function wait (ms) { | ||
return new Promise(resolve => setTimeout(resolve, ms)); | ||
} | ||
export async function nextTick (refreshRate = 20) { | ||
let now = performance.now(); | ||
let remainder = now % refreshRate; | ||
let delay = refreshRate - remainder; | ||
let nextAt = now + delay; | ||
nextTick.start ??= now - remainder; | ||
return new Promise(resolve => setTimeout(() => resolve(nextAt - nextTick.start), delay)); | ||
} | ||
/** | ||
@@ -142,2 +47,10 @@ * Compute the ideal step for a range, to be used as a default in spinners and sliders | ||
return Object.fromEntries(Object.entries(obj).filter(([key]) => properties.includes(key))); | ||
} | ||
} | ||
export function getType (value) { | ||
if (value === null || value === undefined) { | ||
return value + ""; | ||
} | ||
return Object.prototype.toString.call(value).slice(8, -1); | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Unstable ownership
Supply chain riskA new collaborator has begun publishing package versions. Package stability and security risk may be elevated.
Found 1 instance in 1 package
Native code
Supply chain riskContains native code (e.g., compiled binaries or shared libraries). Including native code can obscure malicious behavior.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
71
765140
8
65
3384
1
18