├── _headers
├── src
├── src.json
├── color-chart
│ ├── color-chart.webp
│ ├── color-chart.css
│ ├── color-chart-global.css
│ ├── README.md
│ └── color-chart.js
├── color-scale
│ ├── color-scale.webp
│ ├── color-scale.css
│ ├── color-scale.js
│ └── README.md
├── gamut-badge
│ ├── gamut-badge.webp
│ ├── gamut-badge.js
│ ├── gamut-badge.css
│ └── README.md
├── color-inline
│ ├── color-inline.webp
│ ├── style.css
│ ├── color-inline.css
│ ├── color-inline.js
│ └── README.md
├── color-picker
│ ├── color-picker.webp
│ ├── color-picker.css
│ ├── README.md
│ └── color-picker.js
├── color-slider
│ ├── color-slider.webp
│ ├── color-slider.css
│ ├── color-slider.js
│ └── README.md
├── color-swatch
│ ├── color-swatch.webp
│ ├── color-swatch.css
│ ├── color-swatch.js
│ └── README.md
├── space-picker
│ ├── space-picker.webp
│ ├── space-picker.css
│ ├── space-picker.js
│ └── README.md
├── channel-picker
│ ├── channel-picker.webp
│ ├── channel-picker.css
│ ├── README.md
│ └── channel-picker.js
├── channel-slider
│ ├── channel-slider.webp
│ ├── channel-slider.css
│ ├── README.md
│ └── channel-slider.js
├── index.js.njk
└── common
│ ├── dom.js
│ ├── util.js
│ └── color-element.js
├── _build
├── filters-extra.js
├── copy-config.json
├── eleventy.js
└── copy-config.js
├── .editorconfig
├── elements.11tydata.json
├── .vscode
└── settings.json
├── _redirects
├── .gitignore
├── .prettierrc
├── assets
├── js
│ └── index.js
└── css
│ └── style.css
├── index.js
├── _includes
├── partials
│ └── _nav-links.njk
└── component.njk
├── data
└── components.json
├── README.md
├── package.json
├── logo.svg
└── eslint.config.js
/_headers:
--------------------------------------------------------------------------------
1 | /*
2 | Access-Control-Allow-Origin: *
3 |
--------------------------------------------------------------------------------
/src/src.json:
--------------------------------------------------------------------------------
1 | {
2 | "layout": "component"
3 | }
4 |
--------------------------------------------------------------------------------
/src/color-chart/color-chart.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/color-chart/color-chart.webp
--------------------------------------------------------------------------------
/src/color-scale/color-scale.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/color-scale/color-scale.webp
--------------------------------------------------------------------------------
/src/gamut-badge/gamut-badge.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/gamut-badge/gamut-badge.webp
--------------------------------------------------------------------------------
/src/color-inline/color-inline.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/color-inline/color-inline.webp
--------------------------------------------------------------------------------
/src/color-picker/color-picker.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/color-picker/color-picker.webp
--------------------------------------------------------------------------------
/src/color-slider/color-slider.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/color-slider/color-slider.webp
--------------------------------------------------------------------------------
/src/color-swatch/color-swatch.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/color-swatch/color-swatch.webp
--------------------------------------------------------------------------------
/src/space-picker/space-picker.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/space-picker/space-picker.webp
--------------------------------------------------------------------------------
/src/channel-picker/channel-picker.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/channel-picker/channel-picker.webp
--------------------------------------------------------------------------------
/src/channel-slider/channel-slider.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/color-js/elements/HEAD/src/channel-slider/channel-slider.webp
--------------------------------------------------------------------------------
/_build/filters-extra.js:
--------------------------------------------------------------------------------
1 | export function tag_to_class (tag) {
2 | return tag?.replace(/(?:^|-)([a-z])/g, ($0, $1) => $1.toUpperCase());
3 | }
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | charset = utf-8
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 | end_of_line = lf
9 |
--------------------------------------------------------------------------------
/elements.11tydata.json:
--------------------------------------------------------------------------------
1 | {
2 | "layout": "page",
3 | "permalink": "{{ page.filePathStem | replace('README', '') | replace('index', '') }}/index.html",
4 | "body_classes": "cn-ignore"
5 | }
--------------------------------------------------------------------------------
/src/index.js.njk:
--------------------------------------------------------------------------------
1 | ---
2 | permalink: "index.js"
3 | layout: null
4 | ---
5 | {% for name, description in components -%}
6 | export { default as {{ name | tag_to_class }} } from "./src/{{ name }}/{{ name }}.js";
7 | {% endfor %}
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "editor.formatOnSaveMode": "file",
4 | "editor.defaultFormatter": "esbenp.prettier-vscode",
5 | "prettier.enable": true,
6 | "debug.enableStatusBarColor": false
7 | }
8 |
--------------------------------------------------------------------------------
/src/color-inline/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | color-scheme: light dark;
3 | }
4 |
5 | @media (prefers-color-scheme: dark) {
6 | html {
7 | background: hsl(220 5% 20%);
8 | }
9 | }
10 |
11 | color-inline {
12 | display: block;
13 | margin: 1em 0;
14 | }
15 |
--------------------------------------------------------------------------------
/_redirects:
--------------------------------------------------------------------------------
1 | # Specific versions
2 | node_modules/nude-element/* https://cdn.jsdelivr.net/npm/nude-element@0.0.14/:splat 200
3 |
4 | # Catch all NPM fallback
5 | node_modules/:modulename/* https://cdn.jsdelivr.net/npm/:modulename@latest/:splat 200
6 |
7 | assets/* https://colorjs.io/assets/:splat 200
8 |
9 | /:tag/* /src/:tag/:splat 200
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # OS droppings
4 | .DS_Store
5 | Thumbs.db
6 |
7 | # Build artifacts
8 | lib
9 | !lib/README.md
10 | index.html
11 | **/index.html
12 |
13 | # Copied
14 | _build/filters.js
15 | _includes/partials/_nav.njk
16 | _includes/page.njk
17 | data/eleventyComputed.js
18 | _includes/plain.njk
19 | _build/eleventy-original.js
20 | package-lock.json
21 |
--------------------------------------------------------------------------------
/_build/copy-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "source": "https://github.com/color-js/color.js",
3 | "paths": [
4 | "_includes/partials/_nav.njk",
5 | "_includes/page.njk",
6 | "_includes/plain.njk",
7 | "data/eleventyComputed.js",
8 | ["_build/eleventy.js", "_build/eleventy-original.js"],
9 | "_build/filters.js"
10 | ],
11 | "scripts": ["build:html", "watch:html"],
12 | "packages": ["@11ty/eleventy"]
13 | }
14 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": [
3 | "prettier-plugin-brace-style",
4 | "prettier-plugin-space-before-function-paren",
5 | "prettier-plugin-merge"
6 | ],
7 | "braceStyle": "stroustrup",
8 | "arrowParens": "avoid",
9 | "bracketSpacing": true,
10 | "endOfLine": "auto",
11 | "semi": true,
12 | "singleQuote": false,
13 | "tabWidth": 4,
14 | "useTabs": true,
15 | "trailingComma": "all",
16 | "printWidth": 100
17 | }
18 |
--------------------------------------------------------------------------------
/src/space-picker/space-picker.css:
--------------------------------------------------------------------------------
1 | :host {
2 | --border-width: 1px;
3 | --border-color: rgb(0 0 0 / 0.2);
4 | --border-radius: 0.2em;
5 |
6 | padding: 0.3em 0.5em;
7 |
8 | border-radius: var(--border-radius);
9 | border: var(--border-width) solid var(--border-color);
10 | }
11 |
12 | #picker {
13 | font: inherit;
14 | color: inherit;
15 | background: inherit;
16 | border: none;
17 | field-sizing: content;
18 | cursor: pointer;
19 |
20 | &:focus:not(:focus-visible) {
21 | outline: none;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/assets/js/index.js:
--------------------------------------------------------------------------------
1 | import "https://colorjs.io/assets/js//prism.js";
2 | import "https://colorjs.io/assets/js/colors.js";
3 | import "https://blissfuljs.com/bliss.shy.js";
4 |
5 | import { styleCallouts } from "https://colorjs.io/assets/js/enhance.js";
6 | styleCallouts();
7 |
8 | import HTMLDemoElement from "https://nudeui.com/elements/html-demo/html-demo.js";
9 |
10 | if (document.body.classList.contains("component-page")) {
11 | HTMLDemoElement.wrapAll();
12 | }
13 |
14 | if (window.toc) {
15 | import("https://colorjs.io/assets/js/docs.js");
16 | }
17 |
--------------------------------------------------------------------------------
/src/channel-picker/channel-picker.css:
--------------------------------------------------------------------------------
1 | :host {
2 | --border-width: 1px;
3 | --border-color: rgb(0 0 0 / 0.2);
4 | --border-radius: 0.2em;
5 |
6 | padding: 0.3em 0.5em;
7 |
8 | border: var(--border-width) solid var(--border-color);
9 | border-radius: var(--border-radius);
10 | }
11 |
12 | #picker {
13 | font: inherit;
14 | color: inherit;
15 | background: inherit;
16 | border: none;
17 | field-sizing: content;
18 | cursor: pointer;
19 |
20 | &:focus:not(:focus-visible) {
21 | outline: none;
22 | }
23 | }
24 |
25 | #space_picker {
26 | padding: initial;
27 | padding-inline-end: 0.4em;
28 | border-radius: 0;
29 | border: none;
30 | border-inline-end: var(--border-width) solid var(--border-color);
31 | }
32 |
--------------------------------------------------------------------------------
/src/color-picker/color-picker.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: grid;
3 | grid-template-columns: 3fr 1fr;
4 | gap: 1em;
5 | }
6 |
7 | #space_picker {
8 | margin-inline-start: -0.7em; /* align with the channel names */
9 | font-size: 150%;
10 | transition: border-color 0.2s;
11 |
12 | &:not(:hover) {
13 | border-color: transparent;
14 | }
15 | }
16 |
17 | [part="color-space"],
18 | slot[name="color-space"]::slotted(*) {
19 | grid-column: 1 / -1;
20 | justify-self: start;
21 | }
22 |
23 | #sliders {
24 | display: flex;
25 | flex-flow: column;
26 | justify-content: space-between;
27 | gap: 0.3em;
28 | min-inline-size: 10em;
29 | }
30 |
31 | [part="swatch"],
32 | slot[name="swatch"]::slotted(*) {
33 | width: auto;
34 | margin: 0;
35 | }
36 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | export { default as ColorPicker } from "./src/color-picker/color-picker.js";
2 | export { default as ColorScale } from "./src/color-scale/color-scale.js";
3 | export { default as ColorChart } from "./src/color-chart/color-chart.js";
4 | export { default as ColorSwatch } from "./src/color-swatch/color-swatch.js";
5 | export { default as ColorInline } from "./src/color-inline/color-inline.js";
6 | export { default as ChannelSlider } from "./src/channel-slider/channel-slider.js";
7 | export { default as ColorSlider } from "./src/color-slider/color-slider.js";
8 | export { default as GamutBadge } from "./src/gamut-badge/gamut-badge.js";
9 | export { default as ChannelPicker } from "./src/channel-picker/channel-picker.js";
10 | export { default as SpacePicker } from "./src/space-picker/space-picker.js";
11 |
--------------------------------------------------------------------------------
/src/color-scale/color-scale.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: grid;
3 | gap: 0.3em;
4 | grid-auto-flow: row;
5 | grid-template-rows: auto auto;
6 | grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
7 | }
8 |
9 | #swatches {
10 | display: contents;
11 | }
12 |
13 | color-swatch {
14 | margin: 0;
15 | }
16 |
17 | @supports (grid-template-columns: subgrid) {
18 | /* Avoid uneven swatch heights */
19 | color-swatch {
20 | max-width: 100%;
21 | grid-row: 1 / span 2;
22 | display: grid;
23 | grid-template-rows: subgrid;
24 | /*
25 | Container queries don't play well together with subgrid in Chrome 129.
26 | See https://issues.chromium.org/issues/369331413
27 | This is a workaround to avoid the issue until the new Chrome version is released.
28 | */
29 | contain: inline-size layout;
30 |
31 | &::part(swatch) {
32 | grid-row: 1;
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/_includes/partials/_nav-links.njk:
--------------------------------------------------------------------------------
1 | Color.js
2 |
10 |
11 | GitHub
12 |
18 | ♡ Sponsor
19 |
20 |
21 |
--------------------------------------------------------------------------------
/data/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "color-picker": "Select and adjust colors in any color space using sliders and input fields.",
3 | "color-scale": "Display a list of colors. Primarily intended for color palettes.",
4 | "color-chart": "Display lists of colors as a scatterplot or line chart.",
5 | "color-swatch": "Display a visual representation of a color alongside the info about it.",
6 | "color-inline": "Display a color swatch alone or alongside its textual representation.",
7 | "channel-slider": "Display a <color-slider> for a specific channel.",
8 | "color-slider": "Display a slider with a gradient background. Primarily intended for color picking.",
9 | "gamut-badge": "Gamut indicator. Used internally by <color-swatch>.",
10 | "channel-picker": "Select individual color channels within a specified color space.",
11 | "space-picker": "Select a color space from a list of predefined or custom color spaces."
12 | }
13 |
--------------------------------------------------------------------------------
/_build/eleventy.js:
--------------------------------------------------------------------------------
1 | import markdownIt from "markdown-it";
2 | import markdownItAttrs from "markdown-it-attrs";
3 | import markdownItAnchor from "markdown-it-anchor";
4 | import configOriginal from "./eleventy-original.js";
5 | import * as filters from "./filters-extra.js";
6 |
7 | let data = {
8 | permalink: "{{ page.filePathStem | replace('README', '') | replace('index', '') }}/index.html",
9 | body_classes: "cn-ignore",
10 | };
11 |
12 | let md = markdownIt({
13 | html: true,
14 | linkify: true,
15 | typographer: true,
16 | })
17 | .disable("code")
18 | .use(markdownItAttrs)
19 | .use(markdownItAnchor, {
20 | permalink: markdownItAnchor.permalink.headerLink(),
21 | level: 2,
22 | });
23 |
24 | export default config => {
25 | let ret = configOriginal(config);
26 |
27 | for (let prop in data) {
28 | config.addGlobalData(prop, data[prop]);
29 | }
30 |
31 | for (let f in filters) {
32 | config.addFilter(f, filters[f]);
33 | }
34 |
35 | config.setLibrary("md", md);
36 |
37 | config.addPairedShortcode("md", children => {
38 | return md.render(children);
39 | });
40 |
41 | return ret;
42 | };
43 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Color Elements
2 |
3 | These are some **highly experimental** color-related web components.
4 | Use at your own risk, the API can change at any point.
5 |
6 | ## All elements
7 |
8 |
21 |
22 | ### Upcoming
23 |
24 |
25 | {% for name in ["color-plane"] -%}
26 |
27 |
28 | <{{ name }}>
29 |
30 |
31 | {% endfor %}
32 |
33 |
34 | ## Usage
35 |
36 | ### CDN
37 |
38 | To include all components at once:
39 |
40 | ```html
41 |
42 | ```
43 |
44 | To cherry-pick individual components, follow the instructions within the component’s page, but it generally looks like this:
45 |
46 | ```html
47 |
48 | ```
49 |
50 | Each component imports its own dependencies and styles.
51 |
52 | ### NPM
53 |
54 | As usual:
55 |
56 | ```bash
57 | npm i color-elements
58 | ```
59 |
60 | and then:
61 |
62 | ```js
63 | import "color-elements";
64 | ```
65 |
66 | You can also import individual components:
67 |
68 | ```js
69 | import "color-elements/COMPONENT_NAME";
70 | ```
71 |
--------------------------------------------------------------------------------
/src/channel-slider/channel-slider.css:
--------------------------------------------------------------------------------
1 | .color-slider-label {
2 | --_transition-duration: var(--transition-duration, 200ms);
3 |
4 | display: grid;
5 | grid-template-columns: 1fr auto;
6 | gap: 0.3em;
7 | align-items: center;
8 |
9 | em {
10 | opacity: 60%;
11 | transition: opacity var(--_transition-duration);
12 | }
13 |
14 | &:not(:hover, :focus-within) em {
15 | opacity: 0;
16 | }
17 |
18 | input[type="number"] {
19 | --_border-color: var(
20 | --border-color,
21 | color-mix(
22 | in oklab,
23 | currentcolor calc(var(--_current-color-percent, 30) * 1%),
24 | oklab(none none none / 0%)
25 | )
26 | );
27 |
28 | all: unset;
29 |
30 | --content-width: calc(var(--value-length) * 1ch);
31 | width: calc(var(--content-width, 2ch) + 1.2em);
32 | min-width: calc(2ch + 1.2em);
33 | box-sizing: content-box;
34 | padding: 0.1em 0.2em;
35 | border-radius: 0.2em;
36 | border: 1px solid var(--_border-color);
37 | text-align: center;
38 | font-size: 90%;
39 | transition: var(--_transition-duration) allow-discrete;
40 | transition-property: opacity, border-color;
41 |
42 | &::-webkit-textfield-decoration-container {
43 | gap: 0.2em;
44 | }
45 |
46 | &:not(:hover, :focus) {
47 | --_current-color-percent: 10;
48 |
49 | opacity: 60%;
50 | border-color: var(--_border-color);
51 |
52 | &::-webkit-inner-spin-button {
53 | /* Fade out the spin buttons in Chrome and Safari */
54 | opacity: 0.35;
55 | filter: contrast(2);
56 | }
57 | }
58 |
59 | @supports (field-sizing: content) {
60 | field-sizing: content;
61 | width: auto;
62 | }
63 | }
64 | }
65 |
66 | color-slider {
67 | grid-column: 1 / -1;
68 | }
69 |
--------------------------------------------------------------------------------
/src/common/dom.js:
--------------------------------------------------------------------------------
1 | export function named (host, attributes = ["id", "part"]) {
2 | let ret = {};
3 | let selector = attributes.map(attr => `[${attr}]`).join(", ");
4 |
5 | for (let el of host.shadowRoot.querySelectorAll(selector)) {
6 | // Get the value of the first attribute in attributes that has a value
7 | let attribute = attributes.find(attr => el.hasAttribute(attr));
8 | ret[el[attribute]] = el;
9 | }
10 |
11 | return ret;
12 | }
13 |
14 | export function slots (host) {
15 | let ret = {};
16 |
17 | for (let slot of host.shadowRoot.querySelectorAll("slot")) {
18 | ret[slot.name] = slot;
19 |
20 | if (!slot.name || slot.dataset.default !== undefined) {
21 | ret.default = slot;
22 | }
23 | }
24 |
25 | return ret;
26 | }
27 |
28 | export function toSlots ({
29 | slots = this._slots,
30 | slotElements = slots
31 | ? Object.values(slots)
32 | : Array.from(this.shadowRoot.querySelectorAll("slot")),
33 | }) {
34 | let children = this.childNodes;
35 | let assignments = new WeakMap();
36 |
37 | if (!slots && slotElements) {
38 | slots = Object.fromEntries(slotElements.map(slot => [slot.name, slot]));
39 | }
40 |
41 | // Assign to slots
42 | for (let child of children) {
43 | let assignedSlot;
44 |
45 | if (child.slot) {
46 | // Explicit slot
47 | assignedSlot = slots[child.slot];
48 | }
49 | else if (child.matches) {
50 | assignedSlot = slotElements.find(slot => child.matches(slot.dataset.assign));
51 | }
52 |
53 | assignedSlot ??= slots.default;
54 | let all = assignments.get(assignedSlot) ?? new Set();
55 | all.add(child);
56 | assignments.set(assignedSlot, all);
57 | }
58 |
59 | for (let slot of slotElements) {
60 | let all = assignments.get(slot) ?? new Set();
61 | slot.assign(...all);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/_includes/component.njk:
--------------------------------------------------------------------------------
1 | ---js
2 | {
3 | layout: "page.njk",
4 | body_classes: "cn-ignore component-page",
5 | eleventyComputed: {
6 | name: function (data) {
7 | let url = data.page.url;
8 | if (url.includes("/src/")) {
9 | return url.match(/\/src\/([^/]+)/)[1];
10 | }
11 | },
12 | tag: function (data) {
13 | let name = data.name;
14 | if (name) {
15 | return `<${name}>`;
16 | }
17 | },
18 | title: function (data) {
19 | return data.title || data.tag;
20 | },
21 | }
22 | }
23 | ---
24 |
25 |
26 |
27 |
28 |
29 |
30 | {% for name, description in components -%}
31 | <{{ name }}>
32 | {% endfor %}
33 |
34 |
35 |
36 | {{ content | safe }}
37 |
38 |
39 | {% md %}
40 | ## Installation
41 |
42 | To install all color elements, check out the [instructions on the homepage](../).
43 | The rest of this section is about using _only_ `{{ tag | safe }}`.
44 |
45 | The quick and dirty way is straight from the CDN (kindly provided by [Netlify](https://www.netlify.com/)):
46 |
47 | ```html
48 |
49 | ```
50 |
51 | or in JS:
52 |
53 | ```js
54 | import "https://elements.colorjs.io/src/{{ name }}/{{ name }}.js";
55 | ```
56 |
57 | If you are using npm to manage your dependencies, you can import it via:
58 |
59 | ```js
60 | import "color-elements/{{ name }}";
61 | ```
62 |
63 | or:
64 |
65 | ```js
66 | import { {{ name | tag_to_class }} } from "color-elements";
67 | ```
68 | {% endmd %}
69 |
--------------------------------------------------------------------------------
/src/color-inline/color-inline.css:
--------------------------------------------------------------------------------
1 | [part="swatch-wrapper"] {
2 | display: inline-flex;
3 | align-items: baseline;
4 | gap: 0.2em;
5 | margin-inline: 0.1em;
6 | }
7 |
8 | #swatch {
9 | --_transparency-cell-size: var(--transparency-cell-size, clamp(6px, 0.5em, 30px));
10 | --_transparency-background: var(--transparency-background, transparent);
11 | --_transparency-darkness: var(--transparency-darkness, 5%);
12 | --_transparency-grid: var(
13 | --transparency-grid,
14 | repeating-conic-gradient(
15 | transparent 0 25%,
16 | rgb(0 0 0 / var(--_transparency-darkness)) 0 50%
17 | )
18 | 0 0 / calc(2 * var(--_transparency-cell-size)) calc(2 * var(--_transparency-cell-size))
19 | content-box border-box var(--_transparency-background)
20 | );
21 |
22 | --color-image: linear-gradient(var(--color), var(--color));
23 | --border-width: clamp(2px, 0.15em, 16px);
24 | --box-shadow-blur: clamp(2px, 0.1em, 5px);
25 | --box-shadow-color: rgb(0 0 0 / 0.3);
26 |
27 | position: relative;
28 | bottom: calc(-1 * var(--border-width) - 0.1em);
29 | width: 1.2em;
30 | height: 1.2em;
31 | border: var(--border-width) solid white;
32 | box-sizing: border-box;
33 | background: var(--color-image, 0), var(--_transparency-grid, canvas);
34 | box-shadow: calc(var(--box-shadow-blur) * 0.2) calc(var(--box-shadow-blur) * 0.2)
35 | var(--box-shadow-blur) var(--box-shadow-color);
36 | border-radius: clamp(1px, 0.1em, 10px);
37 | }
38 |
39 | #swatch:hover {
40 | transform: scale(1.5);
41 | transition: 0.4s;
42 | }
43 |
44 | #swatch.invalid {
45 | --color-image: url('data:image/svg+xml,⚠️ ');
46 | --_transparency-grid: initial;
47 | }
48 |
49 | @media (prefers-color-scheme: dark) {
50 | #swatch {
51 | --box-shadow-color: rgb(0 0 0 / 0.8);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "color-elements",
3 | "version": "0.0.4",
4 | "description": "A set of web components for working with color. A Color.js project.",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "eslint": "npx eslint .",
9 | "eslint:fix": "npx eslint . --fix",
10 | "prebuild": "node _build/copy-config.js",
11 | "build": "npm run build:html",
12 | "watch": "run-p watch:*",
13 | "prepack": "npm run build",
14 | "release": "release-it",
15 | "test": "echo \"Error: no test specified\" && exit 1",
16 | "build:html": "npx @11ty/eleventy --config=_build/eleventy.js",
17 | "serve": "npx @11ty/eleventy --config=_build/eleventy.js --serve",
18 | "watch:html": "npx @11ty/eleventy --config=_build/eleventy.js --watch"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/color-js/elements.git"
23 | },
24 | "keywords": [
25 | "color",
26 | "color.js",
27 | "web components"
28 | ],
29 | "author": "Lea Verou",
30 | "funding": {
31 | "type": "opencollective",
32 | "url": "https://opencollective.com/color"
33 | },
34 | "license": "MIT",
35 | "bugs": {
36 | "url": "https://github.com/color-js/elements/issues"
37 | },
38 | "homepage": "https://github.com/color-js/elements#readme",
39 | "dependencies": {
40 | "@11ty/eleventy": "^3.1.2",
41 | "colorjs.io": "^0.5.0",
42 | "nude-element": "latest"
43 | },
44 | "devDependencies": {
45 | "@stylistic/eslint-plugin": "latest",
46 | "eslint": "latest",
47 | "globals": "latest",
48 | "markdown-it-anchor": "^8",
49 | "markdown-it-attrs": "^4.1.6",
50 | "npm-run-all": "^4.1.5",
51 | "prettier-plugin-brace-style": "latest",
52 | "prettier-plugin-merge": "latest",
53 | "prettier-plugin-space-before-function-paren": "latest",
54 | "release-it": "^17.2.0"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/color-inline/color-inline.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 |
3 | const Self = class ColorInline extends ColorElement {
4 | static tagName = "color-inline";
5 | static url = import.meta.url;
6 | static styles = "./color-inline.css";
7 | static shadowTemplate = `
8 | `;
12 |
13 | constructor () {
14 | super();
15 |
16 | this._el = {};
17 | this._el.swatch = this.shadowRoot.querySelector("#swatch");
18 | }
19 |
20 | connectedCallback () {
21 | super.connectedCallback?.();
22 | Self.#mo.observe(this, { childList: true, subtree: true, characterData: true });
23 | }
24 |
25 | propChangedCallback ({ name, prop, detail: change }) {
26 | if (name === "color") {
27 | let isValid = this.color !== null;
28 | this._el.swatch.classList.toggle("invalid", !isValid);
29 |
30 | let colorString = this.color?.display();
31 | this._el.swatch.style.setProperty("--color", colorString);
32 | }
33 | }
34 |
35 | static #mo = new MutationObserver(mutations => {
36 | for (let mutation of mutations) {
37 | let target = mutation.target;
38 |
39 | while (target && !(target instanceof ColorInline)) {
40 | target = target.parentNode;
41 | }
42 |
43 | if (target) {
44 | target.value = target.textContent.trim();
45 | }
46 | }
47 | });
48 |
49 | static props = {
50 | value: {
51 | type: String,
52 | default () {
53 | return this.textContent.trim();
54 | },
55 | },
56 | color: {
57 | get type () {
58 | return Self.Color;
59 | },
60 | defaultProp: "value",
61 | parse (value) {
62 | if (!value) {
63 | return null;
64 | }
65 |
66 | return Self.Color.get(value);
67 | },
68 | reflect: false,
69 | },
70 | };
71 |
72 | static events = {
73 | colorchange: {
74 | propchange: "color",
75 | },
76 | valuechange: {
77 | propchange: "value",
78 | },
79 | };
80 | };
81 |
82 | Self.define();
83 |
84 | export default Self;
85 |
--------------------------------------------------------------------------------
/assets/css/style.css:
--------------------------------------------------------------------------------
1 | @import url("https://colorjs.io/assets/css/style.css");
2 | @import url("https://colorjs.io/assets/css/docs.css");
3 |
4 | .showcase {
5 | --_border-color: hsl(var(--gray) 85%);
6 |
7 | display: grid;
8 | grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
9 | gap: 2rem;
10 |
11 | figure {
12 | display: grid;
13 | grid-template-rows: 1fr auto;
14 |
15 | inline-size: 100%;
16 | margin: 0;
17 | overflow: hidden;
18 |
19 | border: 1px solid var(--_border-color);
20 | border-radius: 0.5em;
21 |
22 | box-shadow: 0 0.02em 0.5em -0.7em hsl(var(--gray) 30%);
23 |
24 | &:is(:hover, :focus-within) {
25 | img {
26 | scale: 1.1;
27 | }
28 | }
29 |
30 | img {
31 | inline-size: 100%;
32 | aspect-ratio: 4 / 3;
33 | transition: scale 0.2s;
34 | }
35 |
36 | figcaption {
37 | margin: 0;
38 | padding: 0 0.7em 0.5em;
39 | border-block-start: 1px solid var(--_border-color);
40 | background-color: hsl(var(--gray) 98%);
41 | z-index: 1;
42 |
43 | h2 {
44 | font-size: 200%;
45 |
46 | &:not(:only-child) {
47 | margin-block-end: 0;
48 | }
49 | }
50 |
51 | p {
52 | text-wrap: balance;
53 | }
54 | }
55 | }
56 |
57 | &.upcoming {
58 | figure {
59 | &::before {
60 | content: "";
61 | inline-size: 100%;
62 | aspect-ratio: 4 / 3;
63 |
64 | background: var(--rainbow);
65 | animation: var(--rainbow-scroll);
66 |
67 | mask-image: url('data:image/svg+xml,\
68 | \
69 | \
70 | \
71 | COMING SOON... \
72 | ');
73 | mask-repeat: no-repeat;
74 | mask-size: 100% 100%;
75 | }
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/color-inline/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: component
3 | css: "style.css"
4 | ---
5 |
6 | # ``
7 |
8 | Basic use:
9 |
10 |
11 |
12 | ```html
13 | lch(50% 40 30)
14 | ```
15 |
16 |
17 | You can use `value` to set the color swatch while displaying something else as the content (or even nothing at all):
18 |
19 | ```html
20 |
21 | ```
22 |
23 | Editable:
24 | ```html
25 | lch(50% 40 30)
26 | ```
27 |
28 | Semi-transparent color:
29 | ```html
30 | hsl(340 90% 50% / .25)
31 | ```
32 |
33 | Invalid color:
34 |
35 | ```html
36 | foobar
37 | ```
38 |
39 | ## Reference
40 |
41 | ### Slots
42 |
43 | | Name | Description |
44 | |------|-------------|
45 | | _(default)_ | The element's main content—the color to be shown. Placed next to the color swatch. |
46 |
47 | ### Attributes & Properties
48 |
49 | | Attribute | Property | Property type | Default value | Description |
50 | |-----------|----------|---------------|---------------|-------------|
51 | | `color` | `color` | `Color` | `null` | - | The current color value. `null` for invalid colors. |
52 | | `value` | `value` | `string` | - | The textual form of the color. Will have a value even if the color is invalid. |
53 |
54 |
55 | ### CSS variables
56 |
57 | | Variable | Type | Description |
58 | |----------|---------------|-------------|
59 | | `--transparency-grid` | `` | Gradient used as a background for transparent parts of the swatch. |
60 | | `--transparency-cell-size` | `` | The size of the tiles in the transparency grid. This will not be used if you are overriding `--transparency-grid`. |
61 | | `--transparcency-background` | `` | The background color of the transparency gradient. |
62 | | `--transparency-darkness` | `` | The opacity of the black color used for dark parts of the transparency gradient. |
63 | | `--border-width` | `` | The width of the border around the swatch. |
64 | | `--box-shadow-blur` | `` | The blur radius of the box shadow around the swatch. |
65 | | `--box-shadow-color` | `` | The color of the box shadow around the swatch. |
66 |
67 | ### Parts
68 |
69 | | Name | Description |
70 | |------|-------------|
71 | | `swatch-wrapper` | The component’s base wrapper. |
72 | | `swatch` | An internal element used to provide a visual preview of the current color. |
--------------------------------------------------------------------------------
/src/color-chart/color-chart.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: grid;
3 | grid-template-columns: auto 1fr;
4 | grid-template-rows: auto 1fr auto;
5 | height: clamp(0em, 20em, 100vh);
6 | contain: size;
7 | container-name: chart;
8 | container-type: size;
9 | }
10 |
11 | .axis {
12 | display: flex;
13 |
14 | .ticks {
15 | flex: 1;
16 | display: grid;
17 |
18 | > * {
19 | font-size: 60%;
20 | line-height: 1;
21 | }
22 | }
23 |
24 | .label {
25 | text-align: center;
26 | }
27 | }
28 |
29 | [part="color-channel"],
30 | slot[name="color-channel"]::slotted(*) {
31 | /* , or anything acting like one, should occupy the whole row above the chart so as not to mess up with the rest of the layout */
32 | grid-column: 1 / -1;
33 | justify-self: start;
34 |
35 | margin-block: 0.5em 0.7em;
36 | font-size: 130%;
37 | }
38 |
39 | #x_axis {
40 | grid-column: 2;
41 | grid-row: 3;
42 |
43 | display: grid;
44 |
45 | .ticks {
46 | grid-row: 1;
47 | grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
48 |
49 | > * {
50 | padding-block-start: 0.5em;
51 | margin-inline-end: auto;
52 | translate: -50% 0;
53 | }
54 | }
55 |
56 | .label {
57 | grid-row: 2;
58 | margin-block-start: .75em;
59 | }
60 | }
61 |
62 | #y_axis {
63 | grid-column: 1;
64 | grid-row: 2;
65 |
66 | .ticks {
67 | align-items: end;
68 |
69 | > * {
70 | padding-inline-end: 0.5em;
71 | translate: 0 0.5lh;
72 | }
73 | }
74 |
75 | .label {
76 | writing-mode: vertical-lr;
77 | rotate: 0.5turn;
78 | margin-right: 0.5em;
79 | }
80 | }
81 |
82 | @property --chart-width {
83 | syntax: "";
84 | initial-value: 100%;
85 | inherits: true;
86 | }
87 |
88 | #chart-container {
89 | container-name: chart;
90 | container-type: size;
91 | }
92 |
93 | #chart {
94 | overflow: hidden;
95 |
96 | position: relative;
97 | border: 1px solid gray;
98 | min-height: 100%;
99 | background:
100 | linear-gradient(to bottom, hsl(220 10% 50% / 40%) 0 1px, transparent 0) 0 -1px / 100%
101 | calc(100% / var(--steps-y, 10)),
102 | linear-gradient(to right, hsl(220 10% 50% / 30%) 0 1px, transparent 0) -1px 0 /
103 | calc(100% / var(--steps-x, 10)) 100%;
104 |
105 | --chart-width: 100cqw;
106 | --chart-height: 100cqh;
107 | --extent-x: calc(var(--max-x) - var(--min-x));
108 | --extent-y: calc(var(--max-y) - var(--min-y));
109 | }
110 |
111 | ::slotted(color-scale) {
112 | --details-style: compact;
113 |
114 | /*
115 | We want color scales to be easy to style: one can hide them, lower their opacity, etc.
116 | At the same time, we don't want their containers to mess up with other chart parts.
117 | For example, we don't want color scales to get the hover styles when one moves the cursor to the top left part of the chart,
118 | where all the color scale containers reside. So, we need to shrink them.
119 | */
120 | display: block;
121 | width: 0;
122 | height: 0;
123 | }
124 |
--------------------------------------------------------------------------------
/src/color-chart/color-chart-global.css:
--------------------------------------------------------------------------------
1 | @property --chart-width {
2 | syntax: "";
3 | initial-value: 100%;
4 | inherits: true;
5 | }
6 |
7 | @property --width {
8 | syntax: "";
9 | initial-value: 0px;
10 | inherits: true;
11 | }
12 |
13 | @property --height {
14 | syntax: "";
15 | initial-value: 0px;
16 | inherits: true;
17 | }
18 |
19 | @property --angle {
20 | syntax: "";
21 | initial-value: 0deg;
22 | inherits: true;
23 | }
24 |
25 | color-chart {
26 | --_point-size: var(--point-size, 0.6em);
27 | --_line-width: var(--line-width, 0.2em);
28 | --color-swatch-width: var(--color-swatch-width, var(--_point-size));
29 | --color-swatch-radius: var(--color-swatch-radius, 50%);
30 | }
31 |
32 | color-chart > color-scale {
33 | --extent-x: calc(var(--max-x) - var(--min-x));
34 | --extent-y: calc(var(--max-y) - var(--min-y));
35 |
36 | &::part(color-swatch) {
37 | position: absolute;
38 | top: calc((1 - (var(--y) - var(--min-y)) / (var(--max-y) - var(--min-y))) * 100cqh);
39 | left: calc((var(--x) - var(--min-x)) / (var(--max-x) - var(--min-x)) * 100%);
40 | width: var(--_point-size);
41 | aspect-ratio: 1;
42 | border-radius: 50%;
43 | background: yellow;
44 | min-block-size: 0;
45 | translate: -50% -50%;
46 | transition: 300ms 0.01ms;
47 | transition-property: width, opacity;
48 | }
49 |
50 | &::part(color-swatch):hover {
51 | /* Cannot use CSS transforms here because that also affects the line */
52 | --_point-size: calc(var(--point-size, 0.6em) * 1.5);
53 | z-index: 1;
54 | }
55 |
56 | /* Lines */
57 | @container not style(--color-scale-type: discrete) {
58 | &::part(color-swatch)::before {
59 | --delta-x: calc(var(--next-x) - var(--x));
60 | --delta-y: calc(var(--next-y) - var(--y));
61 | --delta-y-abs: max(var(--delta-y), -1 * var(--delta-y));
62 | --delta-y-sign: calc(var(--delta-y) / var(--delta-y-abs));
63 |
64 | --width: calc(var(--chart-width) * var(--delta-x) / var(--extent-x));
65 | --height: calc(var(--chart-height) * var(--delta-y-abs) / var(--extent-y));
66 | --angle: atan2(var(--height), var(--width));
67 |
68 | content: "";
69 | position: absolute;
70 | z-index: 1;
71 | left: calc(50% - var(--_line-width) / 2);
72 | top: calc(50% - var(--_line-width) / 2);
73 | width: calc((var(--width) + var(--_line-width)) / cos(var(--angle)));
74 | height: var(--_line-width);
75 | transform-origin: calc(var(--_line-width) / 2) calc(var(--_line-width) / 2);
76 | /* if delta y is negative, this needs to rotate the other way */
77 | rotate: calc(-1 * var(--delta-y-sign) * var(--angle));
78 | background: linear-gradient(to right, var(--color), var(--next-color));
79 |
80 | /* Don't show points tooltips on hovering the line */
81 | pointer-events: none;
82 | }
83 | }
84 |
85 | &::part(info) {
86 | }
87 |
88 | &::part(swatch) {
89 | min-block-size: 0;
90 | padding: 0.2em;
91 | }
92 |
93 | &::part(gamut) {
94 | font-size: 40%;
95 | }
96 | }
97 |
98 | color-chart:has(> color-scale:hover) {
99 | > color-scale:not(:hover) {
100 | opacity: 0.4;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/_build/copy-config.js:
--------------------------------------------------------------------------------
1 | import { exec } from "child_process";
2 | import { writeFileSync, readFileSync } from "fs";
3 | import config from "./copy-config.json" with { type: "json" };
4 |
5 | const TEMP_REPO = "_build/.colorjs.io";
6 |
7 | // Cleanup past attempts
8 | exec(`rm -rf ${TEMP_REPO}`);
9 |
10 | // Copy files from source repo
11 | let commands = [
12 | `git clone ${config.source} ${TEMP_REPO}`,
13 | ...config.paths.map(path => {
14 | let [current_path, new_path] = Array.isArray(path) ? path : [path, path];
15 | let new_path_dir = new_path.split("/").slice(0, -1).join("/");
16 |
17 | let commands = [`rm -rf ./${new_path}`, `cp -r ${TEMP_REPO}/${current_path} ./${new_path}`];
18 |
19 | if (new_path_dir) {
20 | commands.splice(1, 0, `mkdir -p ./${new_path_dir}`);
21 | }
22 |
23 | return commands.join(" && ");
24 | }),
25 | ];
26 |
27 | await new Promise(resolve => {
28 | exec(commands.join(" && "), (err, stdout, stderr) => {
29 | // Cleanup
30 | exec(`rm -rf ${TEMP_REPO}`);
31 |
32 | if (err) {
33 | console.error(err);
34 | process.exit(1);
35 | }
36 | else {
37 | console.log(stdout);
38 | resolve();
39 | }
40 | });
41 | });
42 |
43 | // Make sure copied paths are in .gitignore
44 | let gitignore = readFileSync(".gitignore", "utf8").split("\n");
45 | let paths_added_to_gitignore = [];
46 | let index;
47 |
48 | for (let path of config.paths) {
49 | let [current_path, new_path] = Array.isArray(path) ? path : [path, path];
50 | let i = gitignore.indexOf(new_path);
51 | if (i > -1) {
52 | index = i;
53 | }
54 | else {
55 | // not found
56 | gitignore.push(new_path);
57 | paths_added_to_gitignore.push(new_path);
58 | }
59 | }
60 |
61 | if (paths_added_to_gitignore.length > 0) {
62 | writeFileSync(".gitignore", gitignore.join("\n"));
63 | console.log("Added paths to .gitignore: " + paths_added_to_gitignore.join(", "));
64 | }
65 |
66 | let source_package_json = JSON.parse(readFileSync(`${TEMP_REPO}/package.json`, "utf8"));
67 | let package_json = JSON.parse(readFileSync(`package.json`, "utf8"));
68 |
69 | // Copy npm scripts
70 | let scripts_copied = [];
71 |
72 | for (let script of config.scripts) {
73 | if (
74 | source_package_json.scripts[script] &&
75 | package_json.scripts[script] !== source_package_json.scripts[script]
76 | ) {
77 | package_json.scripts[script] = source_package_json.scripts[script];
78 | scripts_copied.push(script);
79 | }
80 | }
81 |
82 | if (scripts_copied.length > 0) {
83 | console.warn("Copying npm scripts: " + scripts_copied.join(", "));
84 | writeFileSync("package.json", JSON.stringify(package_json, null, 2));
85 | }
86 |
87 | // Install missing packages
88 | if (source_package_json.devDependencies) {
89 | let missing_packages = [];
90 |
91 | for (let name of config.packages) {
92 | if (source_package_json.devDependencies[name] && !package_json.devDependencies?.[name]) {
93 | missing_packages.push(name);
94 | }
95 | }
96 |
97 | if (missing_packages.length) {
98 | console.warn(`Installing packages: ${missing_packages.join(", ")}`);
99 | exec(`npm install -D ${missing_packages.join(" ")}`);
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/gamut-badge/gamut-badge.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 |
3 | const Self = class GamutBadge extends ColorElement {
4 | static tagName = "gamut-badge";
5 | static url = import.meta.url;
6 | static styles = "./gamut-badge.css";
7 | static shadowTemplate = `
8 |
9 |
10 | `;
11 |
12 | #label;
13 |
14 | constructor () {
15 | super();
16 |
17 | if (!this.#label) {
18 | this.#label = this.shadowRoot.querySelector("#label");
19 | }
20 | }
21 |
22 | get gamutLabel () {
23 | return this.gamutInfo?.label ?? "";
24 | }
25 |
26 | propChangedCallback ({ name, prop, detail: change }) {
27 | if (name === "gamuts") {
28 | this.style.setProperty("--gamut-count", this.gamuts.length - 1);
29 | }
30 |
31 | if (name === "gamutInfo") {
32 | if (this.gamutInfo) {
33 | this.style.setProperty("--gamut-level", this.gamutInfo.level);
34 | this.style.setProperty("--gamut-label", `"${this.gamutInfo.label}"`);
35 | this.style.setProperty("--gamut-id", `"${this.gamutInfo.id}"`);
36 | }
37 | else {
38 | this.style.removeProperty("--gamut-level");
39 | this.style.removeProperty("--gamut-label");
40 | this.style.removeProperty("--gamut-id");
41 | }
42 | }
43 | }
44 |
45 | static props = {
46 | color: {
47 | get type () {
48 | return Self.Color;
49 | },
50 | },
51 | gamuts: {
52 | type: Array,
53 | default: "srgb, p3, rec2020, prophoto",
54 | parse (gamuts) {
55 | if (!gamuts) {
56 | return [];
57 | }
58 |
59 | if (typeof gamuts === "string") {
60 | gamuts = gamuts.trim().split(/\s*,\s*/);
61 | }
62 | else if (!Array.isArray(gamuts) && typeof gamuts === "object") {
63 | // Object
64 | return Object.entries(gamuts).map(([id, label]) => ({ id, label }));
65 | }
66 |
67 | let ret = gamuts.map((gamut, level) => {
68 | if (gamut?.id && "label" in gamut) {
69 | // Already in the correct format
70 | return gamut;
71 | }
72 |
73 | gamut = gamut.trim().split(/\s*:\s*/);
74 | let id = gamut[0];
75 | let label = gamut[1] ?? Self.Color.spaces[id]?.name ?? id;
76 | return { id, label, level };
77 | });
78 |
79 | if (!ret.find(gamut => gamut.id === "none")) {
80 | ret.push({
81 | id: "none",
82 | get label () {
83 | return ret[this.level - 1].label + "+";
84 | },
85 | level: ret.length,
86 | });
87 | }
88 |
89 | return ret;
90 | },
91 | stringify (gamuts) {
92 | return gamuts.map(({ id, label }) => `${id}: ${label}`).join(", ");
93 | },
94 | },
95 | gamutInfo: {
96 | get () {
97 | if (!this.color) {
98 | return null;
99 | }
100 |
101 | return this.gamuts?.find(
102 | gamut => gamut.id === "none" || this.color?.inGamut(gamut.id),
103 | );
104 | },
105 | },
106 | gamut: {
107 | type: String,
108 | get () {
109 | return this.gamutInfo?.id;
110 | },
111 | },
112 | };
113 |
114 | static events = {
115 | gamutchange: {
116 | propchange: "gamut",
117 | },
118 | };
119 | };
120 |
121 | Self.define();
122 |
123 | export default Self;
124 |
--------------------------------------------------------------------------------
/src/common/util.js:
--------------------------------------------------------------------------------
1 | export async function wait (ms) {
2 | if (ms === undefined) {
3 | return new Promise(resolve => requestAnimationFrame(resolve));
4 | }
5 |
6 | return new Promise(resolve => setTimeout(resolve, ms));
7 | }
8 |
9 | export function defer (executor) {
10 | let res, rej;
11 |
12 | let promise = new Promise((resolve, reject) => {
13 | res = resolve;
14 | rej = reject;
15 |
16 | executor?.(res, rej);
17 | });
18 |
19 | promise.resolve = res;
20 | promise.reject = rej;
21 |
22 | return promise;
23 | }
24 |
25 | /**
26 | * Wait for all promises to resolve. Supports dynamically adding promises to the list after the initial call.
27 | * @param {Promise[] | Set} promises
28 | * @returns {Promise}
29 | */
30 | export async function dynamicAll (promises) {
31 | let all = new Set([...promises]);
32 | let unresolved = new Set();
33 |
34 | for (let promise of promises) {
35 | if (promise?.then) {
36 | unresolved.add(promise);
37 | promise.then(() => {
38 | // Remove the promise from the list
39 | unresolved.delete(promise);
40 | });
41 | }
42 | }
43 |
44 | return Promise.all(unresolved).then(resolved => {
45 | // Check if the array has new items
46 | for (let promise of promises) {
47 | if (!all.has(promise)) {
48 | all.add(promise);
49 |
50 | if (promise?.then) {
51 | unresolved.add(promise);
52 | }
53 | }
54 | }
55 |
56 | if (unresolved.size > 0) {
57 | return dynamicAll(unresolved).then(r => [...resolved, ...r]);
58 | }
59 |
60 | return resolved;
61 | });
62 | }
63 |
64 | /**
65 | * Compute the ideal step for a range, to be used as a default in spinners and sliders
66 | * @param {number} min
67 | * @param {number} max
68 | * @param {options} options
69 | */
70 | export function getStep (min, max, { minSteps = 100, maxStep = 1 } = {}) {
71 | let range = Math.abs(max - min);
72 | let step = range / minSteps;
73 |
74 | // Find nearest power of 10 that is < step
75 | step = 10 ** Math.floor(Math.log10(step));
76 |
77 | return step > maxStep ? maxStep : step;
78 | }
79 |
80 | export function sortObject (obj, fn) {
81 | if (!obj) {
82 | return obj;
83 | }
84 |
85 | return Object.fromEntries(Object.entries(obj).sort(fn));
86 | }
87 |
88 | export function mapObject (obj, fn) {
89 | if (!obj) {
90 | return obj;
91 | }
92 |
93 | return Object.fromEntries(Object.entries(obj).map(fn));
94 | }
95 |
96 | export function pick (obj, properties) {
97 | if (!properties || !obj) {
98 | return obj;
99 | }
100 |
101 | return Object.fromEntries(Object.entries(obj).filter(([key]) => properties.includes(key)));
102 | }
103 |
104 | export function getType (value) {
105 | if (value === null || value === undefined) {
106 | return value + "";
107 | }
108 |
109 | return Object.prototype.toString.call(value).slice(8, -1);
110 | }
111 |
112 | /**
113 | * Template tag that does nothing. Useful for importing under different names (e.g. `css`) for syntax highlighting.
114 | * @param {string[]} strings
115 | * @param {...any} values
116 | * @returns {string}
117 | */
118 | export function noOpTemplateTag (strings, ...values) {
119 | return strings.reduce((acc, string, i) => acc + string + (values[i] ?? ""), "");
120 | }
121 |
--------------------------------------------------------------------------------
/src/gamut-badge/gamut-badge.css:
--------------------------------------------------------------------------------
1 | :host {
2 | --_color-green: var(--color-green, yellowgreen);
3 | --_color-yellow: var(--color-yellow, gold);
4 | --_color-orange: var(--color-orange, orange);
5 | --_color-red: var(--color-red, red);
6 | --_color-red-dark: var(--color-red-dark, #a00);
7 | --_color-invalid: var(--color-invalid, hsl(220 10% 60%));
8 |
9 | /* Low-level scales, i.e. between pairs of our core colors */
10 | --color-scale-1: color-mix(
11 | in oklch,
12 | var(--_color-green),
13 | var(--_color-yellow) var(--progress-1)
14 | );
15 | --color-scale-2: color-mix(
16 | in oklch,
17 | var(--_color-yellow),
18 | var(--_color-orange) var(--progress-2)
19 | );
20 | --color-scale-3: color-mix(in oklch, var(--_color-orange), var(--_color-red) var(--progress-3));
21 |
22 | /* Recursive scales: their only purpose is to select one of the low-level scales, and will only ever have 0%/100% positions
23 | For N colors there are N-2 + N-3 + ... + 1 = (N-1)(N-2) / 2 of these
24 | */
25 | --color-scale-12: color-mix(
26 | in oklch,
27 | var(--color-scale-1),
28 | var(--color-scale-2) var(--progress-12)
29 | );
30 | --color-scale-23: color-mix(
31 | in oklch,
32 | var(--color-scale-2),
33 | var(--color-scale-3) var(--progress-23)
34 | );
35 | --color-scale-123: color-mix(
36 | in oklch,
37 | var(--color-scale-12),
38 | var(--color-scale-23) var(--progress-123)
39 | );
40 |
41 | --gamut-progress: calc(var(--gamut-level) / (var(--gamut-count) - 1));
42 | --progress-ext: calc(var(--gamut-progress) * 300%);
43 |
44 | --progress-123: clamp(0%, (var(--progress-ext) - 150%) * infinity, 100%);
45 | --progress-12: clamp(0%, (var(--progress-ext) - 150% + 75%) * infinity, 100%);
46 | --progress-23: clamp(0%, (var(--progress-ext) - 150% - 75%) * infinity, 100%);
47 |
48 | --progress-1: clamp(0%, var(--progress-ext), 100%);
49 | --progress-2: calc(clamp(100%, var(--progress-ext), 200%) - 100%);
50 | --progress-3: calc(clamp(200%, var(--progress-ext), 300%) - 200%);
51 |
52 | --color-level-infinity: var(--_color-red-dark);
53 | --color: var(--color-scale-123, var(--_color-invalid));
54 |
55 | display: inline-flex;
56 | align-items: center;
57 | justify-content: center;
58 | border-radius: 0.2em;
59 | color: white;
60 | background-color: var(--color);
61 | font-weight: bold;
62 | padding-inline: 0.4em;
63 | line-height: 1.4;
64 |
65 | /* See https://lea.verou.me/blog/2024/contrast-color/ */
66 | --l: clamp(0, (l / var(--l-threshold, 0.7) - 1) * -infinity, 1);
67 | color: oklch(from var(--color) var(--l) 0 h);
68 | }
69 |
70 | :host([gamut="none"]) {
71 | background-color: var(--color-level-infinity);
72 | }
73 |
74 | #label {
75 | &::before {
76 | content: var(--gamut-label, "N/A");
77 | }
78 |
79 | @supports not (color: color(from red xyz-d65 y y y)) {
80 | /* https://miunau.com/posts/dynamic-text-contrast-in-css/ */
81 | filter: invert(1) grayscale(1) brightness(1.3) contrast(9000);
82 | mix-blend-mode: luminosity;
83 | }
84 |
85 | @supports (color: color(from red xyz-d65 y y y)) {
86 | /* https://lea.verou.me/blog/2024/contrast-color/ */
87 | --y-threshold: 0.36;
88 | --y: clamp(0, (var(--y-threshold) - y) * infinity, 1);
89 |
90 | color: color(from var(--color) xyz-d65 var(--y) var(--y) var(--y));
91 | }
92 | }
93 |
94 | @property --color {
95 | syntax: "";
96 | inherits: true;
97 | initial-value: transparent;
98 | }
99 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
2 | Based on u*,v* UCS diagram
3 |
33 |
34 |
128 |
129 |
130 | <
131 | /
132 | >
133 |
134 |
135 |
136 |
137 |
138 |
--------------------------------------------------------------------------------
/src/channel-picker/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | ## Usage
4 |
5 | ### Basic usage
6 |
7 | ```html
8 |
9 | ```
10 |
11 | If no color channel is provided (via the `value` attribute/property),
12 | the default `oklch.l` will be used:
13 |
14 | ```html
15 |
16 | ```
17 |
18 | You can hide the `color-space` part with CSS to show only the coordinates of the specified space:
19 |
20 | ```html
21 |
22 |
23 |
28 | ```
29 |
30 | ### Events
31 |
32 | You can listen to the `valuechange` event to get the current value (the `value` property). When a new color space is selected,
33 | the channel will be either preserved (if it is in the new space) or reset to the first available one:
34 |
35 | ```html
36 |
37 |
38 | ```
39 |
40 | ### Dynamic
41 |
42 | All properties are reactive and can be set programmatically:
43 |
44 | ```html
45 | Switch to P3 Blue
46 |
47 | ```
48 |
49 | `` plays nicely with other color elements, like [``](../channel-slider):
50 |
51 | ```html
52 |
53 |
54 |
55 |
60 |
61 |
70 | ```
71 |
72 | ## Reference
73 |
74 | ### Attributes & Properties
75 |
76 | | Attribute | Property | Property type | Default value | Description |
77 | |-----------|----------|---------------|---------------|----------------------------------|
78 | | `value` | `value` | `string` | `oklch.l` | The current value of the picker. |
79 |
80 | ### Getters
81 |
82 | These properties are read-only.
83 |
84 | | Property | Type | Description |
85 | |----------|------|-------------|
86 | | `selectedSpace` | `ColorSpace` | Color space object corresponding to the space picker current value. |
87 | | `selectedChannel` | `object` | The current channel metadata.|
88 |
89 | ### Events
90 |
91 | | Name | Description |
92 | |-----------------|--------------------------------------------------------------------------------|
93 | | `input` | Fired when the color space or channel changes due to user action. |
94 | | `change` | Fired when the color space or channel changes due to user action. |
95 | | `valuechange` | Fired when the value changes for any reason, and once during initialization. |
96 |
97 | ### Parts
98 |
99 | | Name | Description |
100 | |----------------|------------------------------------------------------|
101 | | `color-space` | The internal [``](../space-picker/) element. |
102 | | `color-space-base` | The internal `` element of [``](../space-picker/). |
103 | | `color-channel-base` | The internal `` element. |
104 |
--------------------------------------------------------------------------------
/src/common/color-element.js:
--------------------------------------------------------------------------------
1 | import NudeElement from "../../node_modules/nude-element/src/Element.js";
2 | import { getType, defer, wait, dynamicAll, noOpTemplateTag as css } from "./util.js";
3 |
4 | const baseGlobalStyles = css`
5 | @keyframes fade-in {
6 | from {
7 | opacity: 0;
8 | }
9 | }
10 |
11 | :state(color-element) {
12 | &:state(loading) {
13 | content-visibility: hidden;
14 | opacity: 0;
15 |
16 | &,
17 | * {
18 | transition-property: opacity !important;
19 | }
20 | }
21 |
22 | &:not(:state(loading)) {
23 | animation: fade-in 300ms both;
24 | }
25 | }
26 | `;
27 |
28 | const Self = class ColorElement extends NudeElement {
29 | static url = import.meta.url;
30 | // TODO make lazy
31 | static Color;
32 | static all = {};
33 | static dependencies = new Set();
34 |
35 | static globalStyles = [{ css: baseGlobalStyles }];
36 |
37 | constructor () {
38 | super();
39 |
40 | let Self = this.constructor;
41 |
42 | if (Self.shadowTemplate !== undefined) {
43 | this.attachShadow({ mode: "open" });
44 | this.shadowRoot.innerHTML = Self.shadowTemplate;
45 | }
46 |
47 | this._internals ??= this.attachInternals?.();
48 | if (this._internals.states) {
49 | this._internals.states.add("color-element");
50 |
51 | this._internals.states.add("loading");
52 | Self.whenReady.then(() => {
53 | this._internals.states.delete("loading");
54 | });
55 | }
56 | }
57 |
58 | static init () {
59 | let wasInitialized = super.init();
60 |
61 | if (!wasInitialized) {
62 | return wasInitialized;
63 | }
64 |
65 | if (this.fetchedStyles) {
66 | this.ready.push(...this.fetchedStyles);
67 | }
68 |
69 | if (this.fetchedGlobalStyles) {
70 | this.ready.push(...this.fetchedGlobalStyles);
71 | }
72 |
73 | this.ready[0].resolve();
74 |
75 | return wasInitialized;
76 | }
77 |
78 | static ready = [defer()];
79 | static whenReady = dynamicAll(this.ready);
80 |
81 | static async define () {
82 | // Overwrite static properties, otherwise they will be shared across subclasses
83 | this.ready = [defer()];
84 | this.whenReady = dynamicAll(this.ready);
85 |
86 | if (!Object.hasOwn(this, "dependencies")) {
87 | this.dependencies = new Set();
88 | }
89 |
90 | Self.all[this.tagName] = this;
91 | let colorTags = Object.keys(Self.all);
92 |
93 | if (this.shadowTemplate) {
94 | // TODO find dependencies
95 | let colorTagRegex = RegExp(`(?<=)(${colorTags.join("|")})(?=>)`, "g");
96 | (this.shadowTemplate.match(colorTagRegex) ?? []).forEach(tag => {
97 | this.dependencies ??= new Set();
98 | this.dependencies.add(tag);
99 | });
100 | }
101 |
102 | if (this.dependencies.size > 0) {
103 | let whenDefined = [...this.dependencies].map(tag =>
104 | customElements.whenDefined(tag).then(Class => Class.whenReady));
105 | this.ready.push(...whenDefined);
106 | }
107 |
108 | // Give other code a chance to overwrite Self.Color
109 | await wait();
110 |
111 | if (!Self.Color) {
112 | let specifier;
113 |
114 | try {
115 | // Is already loaded? (e.g. via an import map, or if we're in Node)
116 | import.meta.resolve("colorjs.io");
117 | specifier = "colorjs.io";
118 | }
119 | catch (e) {
120 | // specifier = "../../node_modules/colorjs.io/dist/color.js";
121 | specifier = "https://colorjs.io/dist/color.js";
122 | }
123 |
124 | Self.Color = import(specifier).then(module => module.default);
125 | }
126 |
127 | // We can't just use top level await, see https://bugs.webkit.org/show_bug.cgi?id=242740
128 | if (getType(Self.Color) === "Promise") {
129 | let ColorPending = Self.Color;
130 | let Color = await ColorPending;
131 |
132 | if (Self.Color === ColorPending) {
133 | // Hasn't changed
134 | Self.Color = Color;
135 | }
136 | }
137 |
138 | customElements.define(this.tagName, this);
139 | }
140 | };
141 |
142 | export default Self;
143 |
--------------------------------------------------------------------------------
/src/color-scale/color-scale.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 | import "../color-swatch/color-swatch.js";
3 |
4 | const Self = class ColorScale extends ColorElement {
5 | static tagName = "color-scale";
6 | static url = import.meta.url;
7 | static dependencies = new Set(["color-swatch"]);
8 | static styles = "./color-scale.css";
9 | static shadowTemplate = `
10 |
11 | `;
12 |
13 | constructor () {
14 | super();
15 |
16 | this._el = {
17 | slot: this.shadowRoot.querySelector("slot"),
18 | swatches: this.shadowRoot.getElementById("swatches"),
19 | };
20 | }
21 |
22 | connectedCallback () {
23 | super.connectedCallback?.();
24 | this._el.swatches.addEventListener("colorchange", this, { capture: true });
25 | }
26 |
27 | disconnectedCallback () {
28 | this.#swatches = [];
29 | this._el.swatches.removeEventListener("colorchange", this, { capture: true });
30 | }
31 |
32 | handleEvent (event) {
33 | this.dispatchEvent(new event.constructor(event.type, { ...event }));
34 | }
35 |
36 | propChangedCallback ({ name, prop, detail: change }) {
37 | if (name === "computedColors") {
38 | // Re-render swatches
39 | this.render();
40 | }
41 | }
42 |
43 | #swatches = [];
44 |
45 | render () {
46 | let colors = this.computedColors;
47 |
48 | if (!colors) {
49 | return;
50 | }
51 |
52 | let colorCount = colors.length;
53 |
54 | let i = 0;
55 | let newSwatches = [];
56 | for (let { name, color } of colors) {
57 | let swatch = (this.#swatches[i] = this._el.swatches.children[i]);
58 |
59 | if (!swatch) {
60 | this.#swatches[i] = swatch = document.createElement("color-swatch");
61 | swatch.setAttribute("size", "large");
62 | swatch.setAttribute("part", "color-swatch");
63 | swatch.setAttribute("exportparts", "swatch, info, gamut, label: swatch-label");
64 | newSwatches.push(swatch);
65 | }
66 |
67 | swatch.color = color;
68 | swatch.label = name;
69 | if (this.info) {
70 | swatch.info = this.info;
71 | }
72 | i++;
73 | }
74 |
75 | if (newSwatches.length > 0) {
76 | this._el.swatches.append(...newSwatches);
77 | }
78 | else if (colorCount < this._el.swatches.children.length) {
79 | // Remove but keep them around in this.#swatches
80 | [...this._el.swatches.children].slice(colorCount).forEach(child => child.remove());
81 | }
82 | }
83 |
84 | static props = {
85 | colors: {
86 | type: {
87 | is: Object,
88 | // Support overriding the Color object
89 | get values () {
90 | return ColorScale.Color;
91 | },
92 | defaultKey: (v, i) => v,
93 | },
94 | },
95 | space: {
96 | default: "oklch",
97 | parse (value) {
98 | let ColorSpace = ColorScale.Color.Space;
99 | if (value instanceof ColorSpace || value === null || value === undefined) {
100 | return value;
101 | }
102 |
103 | value += "";
104 |
105 | return ColorSpace.get(value);
106 | },
107 | stringify (value) {
108 | return value?.id;
109 | },
110 | },
111 | steps: {
112 | type: Number,
113 | default: 0,
114 | },
115 | computedColors: {
116 | get () {
117 | if (!this.colors) {
118 | return null;
119 | }
120 |
121 | let colors = Object.entries(this.colors).map(([name, color]) => ({ name, color }));
122 |
123 | if (this.steps > 0) {
124 | // Insert intermediate steps
125 | let tessellated = [];
126 |
127 | for (let i = 1; i < colors.length; i++) {
128 | let start = colors[i - 1];
129 | let end = colors[i];
130 | let steps = ColorScale.Color.steps(start.color, end.color, {
131 | space: this.space,
132 | steps: this.steps + 2,
133 | });
134 |
135 | steps.shift();
136 | steps.pop();
137 | steps = steps.map(color => ({ name: color + "", color }));
138 |
139 | tessellated.push(start, ...steps);
140 |
141 | if (i === colors.length - 1) {
142 | // Only add the last color at the end
143 | // In all other iterations, it’s the same as the start of the next pair
144 | tessellated.push(end);
145 | }
146 | }
147 |
148 | colors = tessellated;
149 | }
150 |
151 | return colors;
152 | },
153 | additionalDependencies: ["info"],
154 | },
155 | info: {},
156 | };
157 |
158 | static events = {
159 | colorschange: {
160 | propchange: "computedColors",
161 | },
162 | };
163 | };
164 |
165 | Self.define();
166 |
167 | export default Self;
168 |
--------------------------------------------------------------------------------
/src/gamut-badge/README.md:
--------------------------------------------------------------------------------
1 |
2 | # <gamut-badge>
3 |
4 | Gamut indicator. Used internally by ``
5 |
6 | ## Usage
7 |
8 | Static (only read once):
9 | ```html
10 |
11 | ```
12 |
13 | Invalid color:
14 | ```html
15 |
16 | ```
17 | Missing color:
18 | ```html
19 |
20 | ```
21 |
22 | Dynamic:
23 | ```html
24 |
25 |
26 | ```
27 |
28 | ## Demo
29 |
50 |
54 |
55 |
80 |
81 | No label:
82 |
83 |
84 |
85 | Default display:
86 |
87 |
88 | ## Reference
89 |
90 | ### Attributes & Properties
91 |
92 | | Attribute | Property | Property type | Default value | Description |
93 | |-----------|----------|---------------|---------------|-------------|
94 | | `gamuts` | `gamuts` | `string` | `Array` | `object` | `["srgb", "p3", "rec2020", "prophoto"]` | A list of gamuts to use. |
95 | | `color` | `color` | `Color` | `string` | - | The current color value. |
96 |
97 | ### Getters
98 |
99 | These properties are read-only.
100 |
101 | | Property | Type | Description |
102 | |----------|------|-------------|
103 | | `gamut` | `string` | The id of the current gamut (e.g. `srgb`). |
104 | | `gamutLabel` | `string` | The label of the current gamut (e.g. `sRGB`). |
105 | | `gamutInfo` | `object` | Metadata about the current gamut (label, id, level). |
106 |
107 | ### Events
108 |
109 | | Name | Description |
110 | |------|-------------|
111 | | `gamutchange` | Fired when the gamut changes for any reason, and once during initialization. |
112 |
113 | ### Slots
114 |
115 | | Slot | Description |
116 | |------|-------------|
117 | | _(default)_ | Custom content |
118 |
119 | ### CSS variables
120 |
121 | | Variable | Type | Default value | Description |
122 | |----------|------|---------------|-------------|
123 | | `--color-green` | `` | | Starting color of the background color scale. Used when the color is within the first gamut. |
124 | | `--color-yellow` | `` | | Yellow color to be used at around 33.3% of the color scale Will be used for the second gamut if there are four total. |
125 | | `--color-orange` | `` | | Orange color to be used at around 66.6% of the color scale. Will be used for the third gamut if there are four total. |
126 | | `--color-red` | `` | | Red color to be used as the last stop of the color scale. Used when the color is within the last gamut. |
127 | | `--color-red-dark` | `` | | Dark red background color of gamut indicator. Used when the provided color fits none of the specified gamuts. |
128 | | `--color-invalid` | `` | | Background color of gamut indicator when the provided color is invalid. |
129 |
130 | #### Output-only CSS variables
131 |
132 | These variables are set by the component.
133 | You can write CSS that reacts to them, but you should not set them yourself unless you *really* know what you’re doing.
134 |
135 | | Variable | Type | Default value | Description |
136 | |----------|------|---------------|-------------|
137 | | `--gamut-color` | `` | | Background color of gamut indicator. Will override the color that depends on the actual gamut, so you should rarely use this directly. |
138 | | `--gamut-level` | `` | - | The index of the gamut the current color fits in, starting from 0. You can use this in styling, but don’t overwrite it. |
139 |
140 | ### CSS Parts
141 |
142 | | Part | Description |
143 | |------|-------------|
144 | | `label` | The label of the gamut indicator. Does not apply if the element has content. |
145 |
146 |
--------------------------------------------------------------------------------
/src/channel-picker/channel-picker.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 | import "../space-picker/space-picker.js";
3 | import * as dom from "../common/dom.js";
4 |
5 | const Self = class ChannelPicker extends ColorElement {
6 | static tagName = "channel-picker";
7 | static url = import.meta.url;
8 | static styles = "./channel-picker.css";
9 | static shadowTemplate = `
10 |
11 | `;
12 |
13 | constructor () {
14 | super();
15 |
16 | this._el = dom.named(this);
17 |
18 | // We need to start listening for this event as soon as the is created
19 | this._el.space_picker.addEventListener("spacechange", this);
20 |
21 | // We need to render the picker as soon as possible so as not to choke on invalid initial values
22 | this.#render();
23 | }
24 |
25 | connectedCallback () {
26 | super.connectedCallback?.();
27 |
28 | this._el.picker.addEventListener("input", this);
29 | }
30 |
31 | disconnectedCallback () {
32 | super.disconnectedCallback?.();
33 |
34 | this._el.space_picker.removeEventListener("spacechange", this);
35 | this._el.picker.removeEventListener("input", this);
36 | }
37 |
38 | get selectedSpace () {
39 | return this._el.space_picker.selectedSpace;
40 | }
41 |
42 | get selectedChannel () {
43 | return this.selectedSpace.coords?.[this._el.picker.value];
44 | }
45 |
46 | /**
47 | * Previously selected channels for each space.
48 | * Keys are space IDs. Values are coords.
49 | */
50 | #history = {};
51 |
52 | #render () {
53 | let space = this.selectedSpace;
54 | let coords = space.coords;
55 |
56 | if (space && !coords) {
57 | console.warn(`Color space "${space.name}" has no coordinates.`);
58 | return;
59 | }
60 |
61 | this._el.picker.innerHTML = Object.entries(coords)
62 | .map(([id, coord]) => `${coord.name} `)
63 | .join("\n");
64 |
65 | let [prevSpace, prevChannel] = this.value?.split(".") ?? [];
66 | if (prevSpace && prevChannel) {
67 | let prevChannelName = this._el.space_picker.spaces[prevSpace].coords[prevChannel].name;
68 | let currentChannelName = coords[prevChannel]?.name;
69 | if (prevChannelName === currentChannelName) {
70 | // Preserve the channel if it exists in the new space and has the same name ("b" in "oklab" is not the same as "b" in "p3")
71 | this._el.picker.value = prevChannel;
72 | }
73 | else if (this.#history?.[space.id]) {
74 | // Otherwise, try to restore the last channel used
75 | this._el.picker.value = this.#history[space.id];
76 | }
77 | }
78 | }
79 |
80 | handleEvent (event) {
81 | if (event.type === "spacechange") {
82 | this.#render();
83 | }
84 |
85 | if ([this._el.space_picker, this._el.picker].includes(event.target)) {
86 | let value = `${this._el.space_picker.value}.${this._el.picker.value}`;
87 | if (value !== this.value) {
88 | this.value = value;
89 | }
90 | }
91 | }
92 |
93 | propChangedCallback ({ name, prop, detail: change }) {
94 | if (name === "value" && this.value) {
95 | let [space, channel] = (this.value + "").split(".");
96 |
97 | let currentSpace = this._el.space_picker.value;
98 | let currentCoord = this._el.picker.value;
99 | let currentValue = `${currentSpace}.${currentCoord}`;
100 |
101 | if (!space || !channel) {
102 | console.warn(
103 | `Invalid value "${this.value}". Expected format: "space.channel". Falling back to "${currentValue}".`,
104 | );
105 | this.value = currentValue;
106 | }
107 | else {
108 | let spaces = Object.keys(this._el.space_picker.spaces);
109 |
110 | if (!spaces.includes(space)) {
111 | console.warn(
112 | `No "${space}" color space found. Choose one of the following: ${spaces.join(", ")}. Falling back to "${currentSpace}".`,
113 | );
114 | this.value = currentValue;
115 | }
116 | else {
117 | if (currentSpace !== space) {
118 | this._el.space_picker.value = space;
119 | }
120 |
121 | if (currentCoord && currentCoord !== channel) {
122 | let coords = Object.keys(this.selectedSpace.coords ?? {});
123 |
124 | if (coords.includes(channel)) {
125 | this._el.picker.value = channel;
126 | }
127 | else {
128 | currentCoord = coords.includes(currentCoord) ? currentCoord : coords[0];
129 |
130 | let message = `Color space "${space}" has no coordinate "${channel}".`;
131 | if (coords.length) {
132 | message += ` Choose one of the following: ${coords.join(", ")}.`;
133 | }
134 | message += ` Falling back to "${currentCoord}".`;
135 | console.warn(message);
136 | this.value = `${space}.${currentCoord}`;
137 | channel = currentCoord;
138 | }
139 | }
140 |
141 | this.#history[space] = channel;
142 | }
143 | }
144 | }
145 | }
146 |
147 | static props = {
148 | value: {
149 | default: "oklch.l",
150 | },
151 | };
152 |
153 | static events = {
154 | change: {
155 | from () {
156 | return [this._el.space_picker, this._el.picker];
157 | },
158 | },
159 | input: {
160 | from () {
161 | return [this._el.space_picker, this._el.picker];
162 | },
163 | },
164 | valuechange: {
165 | propchange: "value",
166 | },
167 | };
168 | };
169 |
170 | Self.define();
171 |
172 | export default Self;
173 |
--------------------------------------------------------------------------------
/src/color-picker/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | ## Usage
4 |
5 | ### Basic usage
6 |
7 | ```html
8 |
9 | ```
10 |
11 | Color spaces not supported by the browser also work:
12 |
13 | ```html
14 |
15 | ```
16 |
17 | 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.
18 |
19 | ```html
20 |
21 | ```
22 |
23 | ### The `alpha` attribute
24 |
25 | Colors with the alpha channel are also supported. Add the `alpha` boolean attribute to show the alpha channel:
26 |
27 | ```html
28 |
29 | ```
30 |
31 | ### Slots
32 |
33 | ```html
34 |
35 | Element content goes into the swatch
36 |
37 | ```
38 |
39 | You can use your component instead of the default color swatch:
40 |
41 | ```html
42 |
44 |
45 |
46 | ```
47 |
48 | or your own form element instead of the default space picker:
49 |
50 | ```html
51 |
52 |
53 |
54 | Lab
55 | Oklab
56 | ProPhoto
57 |
58 |
59 |
60 | ```
61 |
62 | ### Events
63 |
64 | As with other components, you can listen to the `colorchange` event:
65 |
66 | ```html
67 |
69 |
70 |
71 | ```
72 |
73 | ### Dynamic
74 |
75 | All attributes are reactive:
76 |
77 | ```html
78 |
79 |
80 | Polar Spaces
81 |
82 |
83 | OKLCh
84 |
85 |
86 | HWB
87 |
88 |
89 | HSL
90 |
91 |
92 |
93 |
94 |
98 |
99 |
104 | ```
105 |
106 | ```html
107 |
108 | Alpha channel
109 |
110 |
111 | ```
112 |
113 | ## Reference
114 |
115 | ### Slots
116 |
117 | | Name | Description |
118 | |------|-------------|
119 | | (default) | The color picker's main content. Goes into the swatch. |
120 | | `color-space` | An element to display (and if writable, also set) the current color space. If not provided, a [``](../space-picker/) is used. |
121 | | `swatch` | An element used to provide a visual preview of the current color. |
122 |
123 | ### Attributes & Properties
124 |
125 | | Attribute | Property | Property type | Default value | Description |
126 | |-----------|----------|---------------|---------------|-------------|
127 | | `space` | `spaceId` | `string` | `oklch` | The color space to use for interpolation. |
128 | | – | `space` | `ColorSpace` | `OKLCh` | Color space object corresponding to the `space` attribute. |
129 | | `color` | `color` | `Color` | `string` | `oklch(50% 50% 180)` | The current color value. |
130 | | `alpha` | `alpha` | `boolean` | `undefined` | `undefined` | Whether to show the alpha channel slider or not. |
131 |
132 | ### Events
133 |
134 | | Name | Description |
135 | |------|-------------|
136 | | `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. |
137 | | `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. |
138 | | `colorchange` | Fired when the color changes for any reason, and once during initialization. |
139 |
140 | ### CSS variables
141 |
142 | The styling of `` is fully customizable via CSS variables provided by the [``](../color-slider/#css-variables) and [``](../color-swatch/#css-variables).
143 |
144 | ### Parts
145 |
146 | | Name | Description |
147 | |------|-------------|
148 | | `color-space` | The default [``](../space-picker/) element, used if the `color-space` slot has no slotted elements. |
149 | | `color-space-base` | The internal `` element of the default [``](../space-picker/) element. |
150 | | `swatch` | The default [``](../color-swatch/) element, used if the `swatch` slot has no slotted elements. |
151 |
--------------------------------------------------------------------------------
/src/space-picker/space-picker.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 |
3 | const Self = class SpacePicker extends ColorElement {
4 | static tagName = "space-picker";
5 | static url = import.meta.url;
6 | static styles = "./space-picker.css";
7 | static shadowTemplate = ` `;
8 |
9 | constructor () {
10 | super();
11 |
12 | this._el = {};
13 | this._el.picker = this.shadowRoot.querySelector("#picker");
14 | }
15 |
16 | connectedCallback () {
17 | super.connectedCallback?.();
18 | this._el.picker.addEventListener("input", this);
19 | }
20 |
21 | disconnectedCallback () {
22 | super.disconnectedCallback?.();
23 | this._el.picker.removeEventListener("input", this);
24 | }
25 |
26 | handleEvent (event) {
27 | if (event.target === this._el.picker && event.target.value !== this.value) {
28 | this.value = event.target.value;
29 | }
30 | }
31 |
32 | propChangedCallback ({ name, prop, detail: change }) {
33 | if (name === "spaces") {
34 | if (!this.groups) {
35 | this._el.picker.innerHTML = Object.entries(this.spaces)
36 | .map(
37 | ([id, space]) =>
38 | `${this.getSpaceLabel(space)} `,
39 | )
40 | .join("\n");
41 | }
42 | else {
43 | let groups = this.groups;
44 |
45 | // Remove empty groups
46 | groups = Object.entries(groups).filter(([type, spaces]) => {
47 | if (Object.keys(spaces).length === 0) {
48 | console.warn(
49 | `Removed empty group of color spaces with the label "${type}."`,
50 | );
51 | return false;
52 | }
53 |
54 | return true;
55 | });
56 |
57 | if (!groups.length) {
58 | console.warn(
59 | "All provided groups of color spaces are empty. Falling back to default grouping.",
60 | );
61 | groups = [["All spaces", this.spaces]];
62 | }
63 |
64 | this._el.picker.innerHTML = groups
65 | .map(
66 | ([type, spaces]) => `
67 |
68 | ${Object.entries(spaces)
69 | .map(
70 | ([id, space]) =>
71 | `${this.getSpaceLabel(space)} `,
72 | )
73 | .join("\n")}
74 |
75 | `,
76 | )
77 | .join("\n");
78 | }
79 |
80 | this._el.picker.value = this.value;
81 | }
82 |
83 | if (name === "value") {
84 | let value = this.value;
85 |
86 | if (value) {
87 | if (!(value in this.spaces)) {
88 | let spaces = Object.keys(this.spaces);
89 | let firstSpace = spaces[0];
90 | let currentSpace = this._el.picker.value;
91 | let fallback = spaces.includes(currentSpace) ? currentSpace : firstSpace;
92 |
93 | console.warn(
94 | `No color space found with id = "${value}". Choose one of the following: ${spaces.join(", ")}. Falling back to "${fallback}".`,
95 | );
96 | this.value = value = fallback;
97 | }
98 |
99 | if (this._el.picker.value !== value) {
100 | this._el.picker.value = value;
101 | }
102 | }
103 | }
104 | }
105 |
106 | static props = {
107 | value: {
108 | default () {
109 | if (this.groups) {
110 | let groups = this.groups;
111 | let firstGroup = Object.values(groups)[0];
112 |
113 | return firstGroup && Object.keys(firstGroup)[0];
114 | }
115 | else {
116 | return Object.keys(this.spaces)[0];
117 | }
118 | },
119 | },
120 |
121 | selectedSpace: {
122 | get () {
123 | let value = this.value;
124 | if (value === undefined || value === null) {
125 | return;
126 | }
127 |
128 | return this.spaces[value];
129 | },
130 | },
131 |
132 | spaces: {
133 | type: {
134 | is: Object,
135 | get values () {
136 | return Self.Color.Space;
137 | },
138 | defaultValue: (id, index) => {
139 | try {
140 | return Self.Color.Space.get(id);
141 | }
142 | catch (e) {
143 | console.error(e);
144 | }
145 | },
146 | },
147 | default: () => Self.Color.spaces,
148 | convert (value) {
149 | // Replace non-existing spaces with { id, name: id }
150 | for (let id in value) {
151 | if (!value[id]) {
152 | value[id] = { id, name: id };
153 | }
154 | }
155 |
156 | return value;
157 | },
158 | stringify (value) {
159 | return Object.entries(value)
160 | .map(([id, space]) => id)
161 | .join(", ");
162 | },
163 | },
164 |
165 | groupBy: {
166 | type: {
167 | is: Function,
168 | arguments: ["space"],
169 | },
170 | reflect: false,
171 | },
172 |
173 | groups: {
174 | get () {
175 | if (!this.groupBy) {
176 | return;
177 | }
178 |
179 | let ret = {};
180 | for (let [id, space] of Object.entries(this.spaces)) {
181 | let group = this.groupBy(space);
182 | if (group) {
183 | (ret[group] ??= {})[id] = space;
184 | }
185 | }
186 |
187 | return ret;
188 | },
189 | },
190 |
191 | getSpaceLabel: {
192 | type: {
193 | is: Function,
194 | arguments: ["space"],
195 | },
196 | default () {
197 | return space => space.name;
198 | },
199 | reflect: false,
200 | },
201 | };
202 |
203 | static events = {
204 | change: {
205 | from () {
206 | return this._el.picker;
207 | },
208 | },
209 | input: {
210 | from () {
211 | return this._el.picker;
212 | },
213 | },
214 | valuechange: {
215 | propchange: "value",
216 | },
217 | spacechange: {
218 | propchange: "selectedSpace",
219 | },
220 | };
221 |
222 | static formAssociated = {
223 | like: el => el._el.picker,
224 | role: "combobox",
225 | changeEvent: "change",
226 | };
227 | };
228 |
229 | Self.define();
230 |
231 | export default Self;
232 |
--------------------------------------------------------------------------------
/src/space-picker/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | ## Usage
4 |
5 | ### Basic usage
6 |
7 | ```html
8 |
9 | ```
10 |
11 | If no color space is provided (via the `value` attribute/property),
12 | the first one will be used:
13 |
14 | ```html
15 |
16 | ```
17 |
18 | You can specify what color spaces to use:
19 | ```html
20 |
21 | ```
22 |
23 | Unknown color spaces also work:
24 | ```html
25 |
26 | ```
27 |
28 | ### Custom labels
29 |
30 | Do you need the picker to show something other than the default color space names, such as color space ids?
31 | Simply define the `getSpaceLabel()` method on the picker instance, and you are done.
32 | The method takes a color space object as an argument and returns a string that will be used as the space label.
33 |
34 | ```html
35 |
36 |
37 |
40 | ```
41 |
42 | ### Grouping the color spaces
43 |
44 | You can group the color spaces the way you like by specifying the `groupBy` property. Its value is a function
45 | accepting a color space as an argument and returning the name of a group the color space should be added to:
46 |
47 | ```html
48 |
49 |
55 | ```
56 |
57 | ### Events
58 |
59 | You can listen to the `spacechange` event to get either the id of the current color space (the `value` property)
60 | or the color space object itself (the `selectedSpace` property):
61 |
62 | ```html
63 |
64 |
65 | ```
66 |
67 | ### Dynamic
68 |
69 | All properties are reactive and can be set programmatically:
70 | ```html
71 | Switch to OKLCh
72 |
73 | ```
74 |
75 | `` plays nicely with other color elements:
76 | ```html
77 |
78 | Space:
79 |
80 |
81 |
84 |
85 | ```
86 |
87 | ## Reference
88 |
89 | ### Attributes & Properties
90 |
91 | | Attribute | Property | Property type | Default value | Description |
92 | |-----------|-----------|-------------------------------------|-----------------------------------------|-----------------------------------------------------------------------------------------------------------|
93 | | `value` | `value` | `string` | The first color space in `this.spaces`. | The current value of the picker. |
94 | | `spaces` | `spaces` | `string` | `Array` | All known color spaces. | Comma-separated list of color spaces to use. |
95 | | — | `groupBy` | `Function` | — | Function to group the color spaces. Takes a color space object as an argument and returns the group name. |
96 | | – | `getSpaceLabel` | `Function` | `space => space.name` | Function to get the label for a color space. Takes a color space object as an argument and returns its label. |
97 |
98 | ### Getters
99 |
100 | These properties are read-only.
101 |
102 | | Property | Type | Description |
103 | |-----------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
104 | | `selectedSpace` | `ColorSpace` | Color space object corresponding to the picker current value. |
105 | | `groups` | `Object` | Object containing the color spaces grouped by the `groupBy()` function. Keys are group names, values are objects with space ids as keys, and corresponding color space objects are values. |
106 |
107 |
108 | ### Events
109 |
110 | | Name | Description |
111 | |---------------|------------------------------------------------------------------------------|
112 | | `input` | Fired when the space changes due to user action. |
113 | | `change` | Fired when the space changes due to user action. |
114 | | `valuechange` | Fired when the value changes for any reason, and once during initialization. |
115 | | `spacechange` | Fired when the space changes for any reason, and once during initialization. |
116 |
117 | ### Parts
118 |
119 | | Name | Description |
120 | |----------|----------------------------------|
121 | | `base` | The internal `` element. |
122 |
--------------------------------------------------------------------------------
/src/color-scale/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | Display a list of colors.
4 |
5 | ## Features
6 |
7 | - Specify colors directly, via interpolation in any color space, or a mix of both
8 | - Display color coordinates, in any color space (or multiple!)
9 | - Coming soon: display deltas between consecutive colors
10 |
11 | ## Examples
12 |
13 | ### Basic usage
14 |
15 | Colors via attribute:
16 |
17 | ```html
18 |
19 | ```
20 |
21 | You can also give them optional names:
22 |
23 | ```html
24 |
37 | ```
38 |
39 | You can only specify your core colors, and insert steps via interpolation:
40 |
41 | ```html
42 |
43 | ```
44 |
45 | If you have more than 2 colors listed, this will insert steps between each pair.
46 |
47 | ### Customizing the color swatches
48 |
49 | Under the hood, `` generates and uses a series of [``](../color-swatch/) elements.
50 |
51 | You can specify the `info` attribute to show additional information about the colors, and it will be passed to the generated ` instances:
52 |
53 | ```html
54 |
56 | ```
57 |
58 | You can also create compact color scales, by simply setting `--details-style: compact`:
59 |
60 | ```html
61 |
64 | ```
65 |
66 | Issue: How to make them focusable??
67 |
68 |
74 |
75 | ## Reference
76 |
77 | ### Attributes & Properties
78 |
79 | | Attribute | Property | Property type | Default value | Description |
80 | | --------- | -------- | ------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
81 | | `colors` | `colors` | `{ [string]: Color }` | `string` | - | Object or comma-separated string defining the colors and their optional names. |
82 | | `space` | `space` | `ColorSpace` | `string` | `oklch` | The color space to use for interpolation. |
83 | | `steps` | `steps` | `number` | `0` | Number of interpolated steps to insert between each pair of colors. |
84 | | `info` | `info` | `string` | - | Comma-separated list of coords of the colors to be shown. Passed to generated [``](../color-swatch/) elements. |
85 |
86 | ### Getters
87 |
88 | These properties are read-only.
89 |
90 | | Property | Type | Description |
91 | | ---------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
92 | | `computedColors` | `Array<{name: string, color: Color}>` | `null` | The array of color objects after processing interpolation and steps. Returns `null` if `this.colors` is not defined. |
93 |
94 | ### CSS variables
95 |
96 | The styling of `` is fully customizable via CSS variables provided by the [``](../color-swatch/#css-variables) elements it generates.
97 |
98 | | Name | Type | Description |
99 | | ----------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
100 | | `--details-style` | `compact` | `normal` (default) | Controls how color information is displayed. Inherited by child [``](../color-swatch/) elements. |
101 |
102 | ### Parts
103 |
104 | | Name | Description |
105 | | -------------- | --------------------------------------------------------------------------------------------------- |
106 | | `color-swatch` | Each individual [``](../color-swatch/) element generated for the colors in the scale. |
107 |
108 | The component also exports parts from its child `` elements: `swatch`, `info`, `gamut`, and `swatch-label` (exported as `label`). See the [`` parts table](../color-swatch/#parts) for details.
109 |
110 | ### Events
111 |
112 | | Name | Description |
113 | | -------------- | ------------------------------------------------------------------------------------------------------------------ |
114 | | `colorschange` | Fired when the computed colors change for any reason, and once during initialization. |
115 | | `colorchange` | Fired when any individual color swatch changes (bubbled from child [``](../color-swatch/) elements). |
116 |
--------------------------------------------------------------------------------
/src/color-slider/color-slider.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: flex;
3 | position: relative;
4 | }
5 |
6 | .color-slider,
7 | .slider-tooltip {
8 | --transparency-cell-size: 1.5em;
9 | --_transparency-cell-size: var(--transparency-cell-size, 0.5em);
10 | --_transparency-background: var(--transparency-background, transparent);
11 | --_transparency-darkness: var(--transparency-darkness, 5%);
12 | --_transparency-grid: var(
13 | --transparency-grid,
14 | repeating-conic-gradient(
15 | transparent 0 25%,
16 | rgb(0 0 0 / var(--_transparency-darkness)) 0 50%
17 | )
18 | 0 0 / calc(2 * var(--_transparency-cell-size)) calc(2 * var(--_transparency-cell-size))
19 | content-box border-box var(--_transparency-background)
20 | );
21 |
22 | --_slider-color-stops: var(--slider-color-stops, transparent 0% 100%);
23 | --_slider-gradient: var(
24 | --slider-gradient,
25 | linear-gradient(to right var(--in-space,), var(--_slider-color-stops))
26 | );
27 | --_slider-height: var(--slider-height, 2.2em);
28 |
29 | --_slider-thumb-width: var(--slider-thumb-width, 1em);
30 | --_slider-thumb-height-offset: var(--slider-thumb-height-offset, 2px);
31 | --_slider-thumb-height: var(
32 | --slider-thumb-height,
33 | calc(var(--_slider-height) + var(--_slider-thumb-height-offset))
34 | );
35 | --_slider-thumb-radius: var(--slider-thumb-radius, 0.16em);
36 | --_slider-thumb-background: var(--slider-thumb-background, var(--color, transparent));
37 | --_slider-thumb-border: var(--slider-thumb-border, 1px solid oklab(0.2 0 0));
38 | --_slider-thumb-border-active: var(--slider-thumb-border-active, 2px solid oklab(0.4 0 0));
39 | --_slider-thumb-scale-active: var(--slider-thumb-scale-active, 1.1);
40 |
41 | --_tooltip-background: var(--tooltip-background, hsl(0 0 0 / 80%));
42 | --_tooltip-border-radius: var(--tooltip-border-radius, 0.3em);
43 | --_tooltip-pointer-height: var(--tooltip-pointer-height, 0.3em);
44 | --_tooltip-pointer-angle: var(--tooltip-pointer-angle, 90deg);
45 | }
46 |
47 | .color-slider {
48 | @supports (background: linear-gradient(in oklab, red, tan)) {
49 | --in-space: in var(--color-space, oklab) var(--hue-interpolation,);
50 | }
51 |
52 | display: block;
53 | margin: 0;
54 | width: 100%;
55 | -moz-appearance: none;
56 | -webkit-appearance: none;
57 | background: var(--_slider-gradient), var(--_transparency-grid);
58 | background-origin: border-box;
59 | background-clip: border-box;
60 | height: var(--_slider-height);
61 | border-radius: 0.3em;
62 | border: 1px solid rgb(0 0 0 / 8%);
63 |
64 | &::-webkit-slider-thumb {
65 | -webkit-appearance: none;
66 | box-sizing: content-box;
67 | width: var(--_slider-thumb-width);
68 | height: var(--_slider-thumb-height);
69 | border-radius: var(--_slider-thumb-radius);
70 | border: var(--_slider-thumb-border);
71 | box-shadow: 0 0 0 1px white;
72 | background: var(--color, transparent);
73 | transition:
74 | 200ms,
75 | 0s background;
76 | }
77 |
78 | &::-moz-range-thumb {
79 | box-sizing: content-box;
80 | width: var(--_slider-thumb-width);
81 | height: var(--_slider-thumb-height);
82 | border-radius: var(--_slider-thumb-radius);
83 | border: var(--_slider-thumb-border);
84 | box-shadow: 0 0 0 1px white;
85 | background: var(--color, transparent);
86 | transition:
87 | 200ms,
88 | 0s background;
89 | }
90 |
91 | &::-moz-range-thumb:active {
92 | border: var(--_slider-thumb-border-active);
93 | scale: var(--_slider-thumb-scale-active);
94 | }
95 |
96 | &::-moz-range-track {
97 | background: none;
98 | }
99 | }
100 |
101 | /* For some reason, the &::-webkit-slider-thumb:active rule (previously used inside the above rule) doesn't work 🤷♂️ */
102 | /* DO NOT MOVE IT BACK! :) */
103 | .color-slider::-webkit-slider-thumb:active {
104 | border: var(--_slider-thumb-border-active);
105 | scale: var(--_slider-thumb-scale-active);
106 | }
107 |
108 | .slider-tooltip {
109 | position: absolute;
110 | left: clamp(
111 | -20%,
112 | 100% * var(--progress) - (var(--progress) - 0.5) * var(--_slider-thumb-width) / 2
113 | /* center on slider thumb */,
114 | 100%
115 | );
116 | bottom: calc(100% + 3px);
117 | translate: -50%;
118 | transform-origin: bottom;
119 | display: flex;
120 | padding-block: 0.3em;
121 | padding-inline: 0.4em;
122 | border: var(--_tooltip-pointer-height) solid transparent;
123 | border-radius: calc(var(--_tooltip-border-radius) + var(--_tooltip-pointer-height));
124 | text-align: center;
125 | color: white;
126 | background:
127 | conic-gradient(
128 | from calc(-1 * var(--_tooltip-pointer-angle) / 2) at bottom,
129 | var(--_tooltip-background) var(--_tooltip-pointer-angle),
130 | transparent 0
131 | )
132 | border-box bottom / 100% var(--_tooltip-pointer-height) no-repeat,
133 | var(--_tooltip-background) padding-box;
134 | color-scheme: dark;
135 | transition:
136 | visibility 0s 200ms,
137 | opacity 200ms,
138 | scale 200ms,
139 | width 100ms,
140 | left 200ms cubic-bezier(0.17, 0.67, 0.49, 1.48);
141 |
142 | &::after {
143 | content: var(--tooltip-suffix);
144 | }
145 |
146 | input {
147 | all: unset;
148 |
149 | &:where([type="number"]) {
150 | --content-width: calc(var(--value-length) * 1ch);
151 | width: calc(var(--content-width, 2ch) + 1.2em);
152 | min-width: calc(2ch + 1.2em);
153 | box-sizing: content-box;
154 |
155 | &::-webkit-textfield-decoration-container {
156 | gap: 0.2em;
157 | }
158 |
159 | @container style(--tooltip-suffix) {
160 | &::-webkit-textfield-decoration-container {
161 | flex-flow: row-reverse;
162 | }
163 | }
164 |
165 | /* Don’t auto hide the spin buttons */
166 | &::-webkit-inner-spin-button {
167 | opacity: 1;
168 | }
169 | }
170 |
171 | @supports (field-sizing: content) {
172 | field-sizing: content;
173 | width: auto;
174 | }
175 |
176 | /* Prevent input from moving all over the place as we type */
177 | &:focus {
178 | transition-delay: 0.5s;
179 | }
180 | }
181 |
182 | &:not(:is(:focus-within, :hover) > *, .color-slider:is(:focus, :hover) + *, :focus, :hover) {
183 | visibility: hidden;
184 | opacity: 0;
185 | scale: 0.5;
186 | }
187 | }
188 |
189 | :host(:not([tooltip])) .slider-tooltip {
190 | display: none;
191 | }
192 |
193 | :host([tooltip="progress"]) .slider-tooltip {
194 | --tooltip-suffix: "%";
195 | }
196 |
--------------------------------------------------------------------------------
/src/color-swatch/color-swatch.css:
--------------------------------------------------------------------------------
1 | :host {
2 | --_transparency-cell-size: var(--transparency-cell-size, 0.5em);
3 | --_transparency-background: var(--transparency-background, transparent);
4 | --_transparency-darkness: var(--transparency-darkness, 5%);
5 | --_transparency-grid: var(
6 | --transparency-grid,
7 | repeating-conic-gradient(
8 | transparent 0 25%,
9 | rgb(0 0 0 / var(--_transparency-darkness)) 0 50%
10 | )
11 | 0 0 / calc(2 * var(--_transparency-cell-size)) calc(2 * var(--_transparency-cell-size))
12 | content-box border-box var(--_transparency-background)
13 | );
14 |
15 | position: relative;
16 | display: inline-flex;
17 | gap: 0.3em;
18 | width: var(--color-swatch-width, fit-content);
19 | margin: 0.3em;
20 | border-radius: var(--color-swatch-radius, 0.2rem);
21 | }
22 |
23 | :host([size="large"]) {
24 | flex-flow: column;
25 | inline-size: min(11em, 100%);
26 | min-block-size: 6em;
27 | contain: inline-size;
28 | container-name: color-swatch;
29 | container-type: inline-size;
30 | }
31 |
32 | slot {
33 | all: inherit;
34 | display: contents;
35 | }
36 |
37 | #gamut {
38 | font-size: 80%;
39 |
40 | &:is(:host([size="large"]) *) {
41 | position: absolute;
42 | top: 0;
43 | right: 0;
44 | margin: 0.5rem;
45 | }
46 |
47 | &:not(:host([size="large"]) *) {
48 | @container style(--details-style: compact) {
49 | position: absolute;
50 | font-size: 50%;
51 | top: 0;
52 | right: 0;
53 | margin: 0.2rem;
54 | }
55 |
56 | &:is(.static *) {
57 | align-self: baseline;
58 | }
59 | }
60 |
61 | &[style*="--gamut-level: 0"] {
62 | display: none;
63 | }
64 | }
65 |
66 | [part="info"] {
67 | margin: 0;
68 | display: inline-flex;
69 | display: none;
70 | gap: 0.5em;
71 |
72 | &:is(:host([size="large"]) &) {
73 | display: grid;
74 | grid-template-columns: max-content auto;
75 | gap: 0.1em 0.2em;
76 | font-size: max(9px, 80%);
77 | justify-content: start;
78 |
79 | .coord {
80 | display: contents;
81 | }
82 | }
83 |
84 | .coord {
85 | display: flex;
86 | gap: 0.2em;
87 |
88 | dd {
89 | margin: 0;
90 | font-weight: bold;
91 | font-variant-numeric: tabular-nums;
92 | }
93 | }
94 | }
95 |
96 | [part="details"] {
97 | display: flex;
98 | flex-flow: inherit;
99 | gap: inherit;
100 |
101 | /* Prevent flex items from overflowing */
102 | min-inline-size: 0;
103 |
104 | &.static {
105 | &:is(:host([size="large"]) *) {
106 | background: canvas;
107 | }
108 | }
109 |
110 | &:not(:host([size="large"]) *) {
111 | align-items: baseline;
112 | }
113 |
114 | @container color-swatch (width <= 5rem) {
115 | font-size: 80%;
116 | }
117 |
118 | @container style(--details-style: compact) {
119 | --_border-color: var(
120 | --border-color,
121 | color-mix(in oklab, buttonborder 20%, oklab(none none none / 0%))
122 | );
123 | --_pointer-height: var(--pointer-height, 0.5em);
124 | --_transition-duration: var(--transition-duration, 400ms);
125 | --_details-popup-width: var(--details-popup-width, max-content);
126 |
127 | position: absolute;
128 | left: 50%;
129 | z-index: 2;
130 | translate: -50% 0;
131 | bottom: 100%;
132 | margin-bottom: calc(var(--_pointer-height) * 0.8);
133 | width: var(--_details-popup-width);
134 | background: canvas;
135 | border: 1px solid var(--_border-color);
136 | padding: 0.6em 1em;
137 | border-radius: 0.2rem;
138 | box-shadow: 0 0.05em 1em -0.7em black;
139 | transition: var(--_transition-duration) allow-discrete;
140 | transition-property: all, display;
141 | transition-delay: 0s, var(--_transition-duration);
142 | transform-origin: 50% calc(100% + var(--_pointer-height));
143 |
144 | &[popover] {
145 | /* Make the triangle pointer visible */
146 | overflow: visible;
147 |
148 | /* Bring the popover back on the screen */
149 | position: fixed;
150 | inset: unset;
151 |
152 | /* And position it relative to the parent swatch */
153 | left: var(--_popover-left);
154 | top: var(--_popover-top);
155 | translate: -50% -100%;
156 | }
157 |
158 | /* Triangle pointer */
159 | &::before {
160 | content: "";
161 | position: absolute;
162 | top: 100%;
163 | left: 50%;
164 | translate: -50% -50%;
165 | aspect-ratio: 1;
166 | height: calc(var(--_pointer-height) * sqrt(2));
167 | background: inherit;
168 | border: inherit;
169 | rotate: -45deg;
170 | clip-path: polygon(0 0, 0 100%, 100% 100%);
171 | }
172 |
173 | /*
174 | More straightforward selector:
175 | &:not(:is(:host(:hover), :host(:focus-within), :host(:active), :host(:target), :host([open])) *)
176 | doesn't work in Safari!
177 | See https://bugs.webkit.org/show_bug.cgi?id=296577
178 | */
179 | &:is(:host(:not(:hover):not(:focus-within):not(:active):not(:target):not([open])) *),
180 | &:is(:host([open="false"]) *) {
181 | display: none;
182 | opacity: 0;
183 | scale: 0;
184 | }
185 | }
186 | }
187 |
188 | [part="color"] {
189 | display: flex;
190 | gap: 0.2em;
191 | }
192 |
193 | [part="label"] {
194 | overflow: hidden;
195 | white-space: nowrap;
196 | text-overflow: ellipsis;
197 | }
198 |
199 | slot:not([name]) {
200 | &::slotted(input) {
201 | display: flex;
202 | box-sizing: border-box;
203 | min-width: 10ch;
204 | font: inherit;
205 | }
206 |
207 | &:is(:host([size="large"]) *)::slotted(input) {
208 | width: 100%;
209 | }
210 | }
211 |
212 | [part="color-wrapper"],
213 | slot[name="swatch"]::slotted(*),
214 | #swatch {
215 | border-radius: inherit;
216 | }
217 |
218 | slot[name="swatch"]::slotted(*),
219 | #swatch {
220 | flex: 1;
221 | display: flex;
222 | flex-flow: column;
223 | align-items: center;
224 | justify-content: center;
225 | padding: 0.5em;
226 | display: flex;
227 | flex-flow: column;
228 | flex: 1;
229 | background: linear-gradient(var(--color) 0 100%), var(--_transparency-grid);
230 |
231 | &:is(:host([size="large"]) *) {
232 | min-block-size: 3em;
233 | }
234 |
235 | &:not(:host([size="large"]) *) {
236 | display: flex;
237 | min-inline-size: 2em;
238 | min-block-size: 1em;
239 | font-size: 65%;
240 | flex: 1;
241 | }
242 | }
243 |
244 | slot[name="swatch-content"] {
245 | /* See https://lea.verou.me/blog/2024/contrast-color/ */
246 | --l: clamp(0, (var(--l-threshold, 0.7) / l - 1) * infinity, 1);
247 | color: oklch(from var(--color) var(--l) 0 h);
248 | }
249 |
--------------------------------------------------------------------------------
/src/channel-slider/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | A [``](../color-slider) for a specific channel, intended for color picking.
4 |
5 | ## Usage
6 |
7 | This is a higher level component than `` for the cases where you want to control a single channel of a color space.
8 | It offers many conveniences for these cases:
9 | - It takes care of applying the right `min`, `max`, and `step` values to the slider
10 | - It automatically generates the start and end colors
11 | - It can provide an editable tooltip as a tooltip that both shows and edits the current value
12 | - Already includes a suitable label
13 |
14 | ### Static
15 |
16 | Basic example:
17 |
18 | ```html
19 |
20 | ```
21 |
22 | The alpha channel is also supported:
23 |
24 | ```html
25 |
26 | ```
27 |
28 | In most cases you’d also want to set a color to set the other channels and the initial value:
29 |
30 | ```html
31 |
32 | ```
33 |
34 | This will automatically use the whole reference range of that component in the specified color space,
35 | and use the current value of the component as the starting value (unless `value` is also specified).
36 |
37 | ---
38 |
39 | The color does not actually need to be in the same color space, it will be converted if needed:
40 |
41 | ```html
42 |
43 | ```
44 |
45 | Colors and color spaces not supported by the browser also work:
46 |
47 | ```html
48 |
49 | ```
50 |
51 |
52 | If you don’t want to show the whole range you can also specify `min` and `max` attributes.
53 |
54 | ```html
55 |
56 | ```
57 |
58 | ### Dynamic
59 |
60 | You can listen to the `colorchange` event and grab the `color` property to get the current color value.
61 | Here we are using a [``](../color-swatch/) to not just display the CSS code but also the actual color:
62 |
63 | ```html
64 |
66 |
67 | ```
68 |
69 | All attributes are reactive:
70 |
71 | ```html
72 |
73 | Space:
74 |
75 | oklch
76 | oklab
77 | okhsl
78 | lab
79 | lch
80 | hsl
81 | srgb
82 |
83 |
84 |
85 | Channel:
86 |
87 | l
88 | c
89 | h
90 | alpha
91 |
92 |
93 |
94 |
96 |
97 |
114 | ```
115 |
116 |
117 | ## Reference
118 |
119 | ### Slots
120 |
121 | | Name | Description |
122 | |------|-------------|
123 | | _(default)_ | The channel slider's label. |
124 |
125 | ### Attributes & Properties
126 |
127 | | Attribute | Property | Property type | Default value | Description |
128 | |-----------|----------|---------------|---------------|-------------|
129 | | `space` | `space` | `ColorSpace` | `string` | `oklch` | The color space to use for interpolation. |
130 | | `channel` | `channel` | `string` | `h` | The component to use for the gradient. |
131 | | `min` | `min` | `number` | `this.refRange[0]` | The minimum value for the slider. |
132 | | `max` | `max` | `number` | `this.refRange[1]` | The maximum value for the slider. |
133 | | `step` | `step` | `number` | Computed automatically based on `this.min` and `this.max`. | The granularity that the slider's current value must adhere to. |
134 | | `value` | `value` | `number` | `(this.min + this.max) / 2` | The current value of the slider. |
135 | | `color` | `color` | `Color` | `string` | `oklch(50% 50% 180)` | The current color value. |
136 |
137 | ### Getters
138 |
139 | These properties are read-only.
140 |
141 | | Property | Type | Description |
142 | |----------|------|-------------|
143 | | `minColor` | `Color` | The color corresponding to the minimum value of the slider. |
144 | | `maxColor` | `Color` | The color corresponding to the maximum value of the slider. |
145 | | `stops` | `Array` | 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`. |
146 | | `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. |
147 | | `channelName` | `string` | The name of the channel (e.g. `Hue` or `Alpha`). |
148 |
149 |
150 | ### Events
151 |
152 | | Name | Description |
153 | |------|-------------|
154 | | `input` | Fired when the color changes due to user action. |
155 | | `change` | Fired when the color changes due to user action. |
156 | | `valuechange` | Fired when the value changes for any reason, and once during initialization. |
157 | | `colorchange` | Fired when the color changes for any reason, and once during initialization. |
158 |
159 | ### Parts
160 |
161 | | Name | Description |
162 | |------|-------------|
163 | | `color_slider` | The internal `` element. |
164 | | `slider` | The `slider` part of the internal [``](../color-slider/#parts) element. |
165 | | `label` | The internal `` element used as a wrapper around the default slot and the slider. |
--------------------------------------------------------------------------------
/src/color-swatch/color-swatch.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 | import "../gamut-badge/gamut-badge.js";
3 |
4 | let importIncrementable;
5 |
6 | const Self = class ColorSwatch extends ColorElement {
7 | static tagName = "color-swatch";
8 | static url = import.meta.url;
9 | static dependencies = new Set(["gamut-badge"]);
10 | static styles = "./color-swatch.css";
11 | static shadowTemplate = `
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
`;
25 |
26 | constructor () {
27 | super();
28 |
29 | this._el = {
30 | wrapper: this.shadowRoot.querySelector("#wrapper"),
31 | label: this.shadowRoot.querySelector("[part=label]"),
32 | colorWrapper: this.shadowRoot.querySelector("[part=color]"),
33 | };
34 |
35 | this._slots = {
36 | default: this.shadowRoot.querySelector("slot:not([name])"),
37 | };
38 |
39 | this.#updateStatic();
40 | this._slots.default.addEventListener("slotchange", evt => this.#updateStatic());
41 | }
42 |
43 | #updateStatic () {
44 | let previousInput = this._el.input;
45 | let input = (this._el.input = this.querySelector("input"));
46 |
47 | this.static = !input;
48 |
49 | // This should eventually be a custom state
50 | this._el.wrapper.classList.toggle("static", this.static);
51 |
52 | if (input && input !== previousInput) {
53 | importIncrementable ??= import("https://incrementable.verou.me/incrementable.mjs").then(
54 | m => m.default,
55 | );
56 | importIncrementable?.then(Incrementable => new Incrementable(input));
57 |
58 | input.addEventListener("input", evt => {
59 | this.value = evt.target.value;
60 | });
61 | }
62 | }
63 |
64 | get gamut () {
65 | return this._el.gamutIndicator.gamut;
66 | }
67 |
68 | get swatchTextContent () {
69 | // Children that are not assigned to another slot
70 | return [...this.childNodes]
71 | .filter(n => !n.slot)
72 | .map(n => n.textContent)
73 | .join("")
74 | .trim();
75 | }
76 |
77 | propChangedCallback ({ name, prop, detail: change }) {
78 | let input = this._el.input;
79 |
80 | if (name === "gamuts") {
81 | if (this.gamuts === "none") {
82 | this._el.gamutIndicator?.remove();
83 | this._el.gamutIndicator = null;
84 | }
85 | else if (this.gamuts) {
86 | if (!this._el.gamutIndicator) {
87 | this._el.gamutIndicator = Object.assign(document.createElement("gamut-badge"), {
88 | id: "gamut",
89 | part: "gamut",
90 | exportparts: "label: gamutLabel",
91 | gamuts: this.gamuts,
92 | color: this.color,
93 | });
94 |
95 | this.shadowRoot.append(this._el.gamutIndicator);
96 |
97 | this._el.gamutIndicator.addEventListener("gamutchange", evt => {
98 | let gamut = this._el.gamutIndicator.gamut;
99 | this.setAttribute("gamut", gamut);
100 | this.dispatchEvent(
101 | new CustomEvent("gamutchange", {
102 | detail: gamut,
103 | }),
104 | );
105 | });
106 | }
107 | else {
108 | this._el.gamutIndicator.gamuts = this.gamuts;
109 | }
110 | }
111 | }
112 |
113 | if (name === "value") {
114 | if (input && (!input.value || input.value !== this.value)) {
115 | input.value = this.value;
116 | }
117 | }
118 |
119 | if (name === "label") {
120 | if (this.label.length && this.label !== this.swatchTextContent) {
121 | this._el.label.textContent = this.label;
122 | this._el.label.title = this.label;
123 | }
124 | else {
125 | this._el.label.textContent = "";
126 | this._el.label.title = "";
127 | }
128 | }
129 |
130 | if (name === "color") {
131 | let isValid = this.color !== null || !this.value;
132 |
133 | input?.setCustomValidity(isValid ? "" : "Invalid color");
134 |
135 | if (this._el.gamutIndicator) {
136 | this._el.gamutIndicator.color = this.color;
137 | }
138 |
139 | let colorString = this.color?.display();
140 | this.style.setProperty("--color", colorString);
141 | }
142 |
143 | if (name === "colorInfo") {
144 | if (!this.colorInfo) {
145 | return;
146 | }
147 |
148 | this._el.info ??= Object.assign(document.createElement("dl"), { part: "info" });
149 | if (!this._el.info.parentNode) {
150 | this._el.colorWrapper.after(this._el.info);
151 | }
152 |
153 | let info = [];
154 | for (let coord of this.info) {
155 | let [label, channel] = Object.entries(coord)[0];
156 |
157 | let value = this.colorInfo[channel];
158 | if (value === undefined) {
159 | continue;
160 | }
161 |
162 | value = typeof value === "number" ? Number(value.toPrecision(4)) : value;
163 |
164 | info.push(`
${label} ${value} `);
165 | }
166 |
167 | this._el.info.innerHTML = info.join("\n");
168 | }
169 | }
170 |
171 | static props = {
172 | size: {},
173 | open: {},
174 | gamuts: {
175 | default: "srgb, p3, rec2020: P3+, prophoto: PP",
176 | },
177 | value: {
178 | type: String,
179 | default () {
180 | if (this._el.input) {
181 | return this._el.input.value;
182 | }
183 |
184 | return this.swatchTextContent;
185 | },
186 | reflect: {
187 | from: true,
188 | },
189 | },
190 | label: {
191 | type: String,
192 | default () {
193 | return this.swatchTextContent;
194 | },
195 | convert (value) {
196 | return value.trim();
197 | },
198 | },
199 | color: {
200 | get type () {
201 | return ColorSwatch.Color;
202 | },
203 | get () {
204 | if (!this.value) {
205 | return null;
206 | }
207 |
208 | return ColorSwatch.Color.get(this.value);
209 | },
210 | set (value) {
211 | this.value = ColorSwatch.Color.get(value)?.display();
212 | },
213 | reflect: false,
214 | },
215 | info: {
216 | type: {
217 | is: Array,
218 | values: {
219 | is: Object,
220 | defaultKey: (coord, i) => ColorSwatch.Color.Space.resolveCoord(coord)?.name,
221 | },
222 | },
223 | default: [],
224 | reflect: {
225 | from: true,
226 | },
227 | },
228 | colorInfo: {
229 | get () {
230 | if (!this.info.length || !this.color) {
231 | return;
232 | }
233 |
234 | let ret = {};
235 | for (let coord of this.info) {
236 | let [label, channel] = Object.entries(coord)[0];
237 | try {
238 | ret[channel] = this.color.get(channel);
239 | }
240 | catch (e) {
241 | console.error(e);
242 | }
243 | }
244 |
245 | return ret;
246 | },
247 | },
248 | };
249 |
250 | static events = {
251 | colorchange: {
252 | propchange: "color",
253 | },
254 | valuechange: {
255 | propchange: "value",
256 | },
257 | };
258 | };
259 |
260 | Self.define();
261 |
262 | export default Self;
263 |
--------------------------------------------------------------------------------
/src/color-picker/color-picker.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 | import "../channel-slider/channel-slider.js";
3 | import "../color-swatch/color-swatch.js";
4 | import "../space-picker/space-picker.js";
5 | import * as dom from "../common/dom.js";
6 |
7 | const Self = class ColorPicker extends ColorElement {
8 | static tagName = "color-picker";
9 | static url = import.meta.url;
10 | static dependencies = new Set(["channel-slider"]);
11 | static styles = "./color-picker.css";
12 | static shadowTemplate = `
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | `;
23 |
24 | constructor () {
25 | super();
26 |
27 | this._el = dom.named(this);
28 | this._slots = {
29 | color_space: this.shadowRoot.querySelector("slot[name=color-space]"),
30 | };
31 | }
32 |
33 | connectedCallback () {
34 | super.connectedCallback?.();
35 | this._el.sliders.addEventListener("input", this);
36 | this._el.swatch.addEventListener("input", this);
37 | this._slots.color_space.addEventListener("input", this);
38 | }
39 |
40 | disconnectedCallback () {
41 | this._el.sliders.removeEventListener("input", this);
42 | this._el.swatch.removeEventListener("input", this);
43 | this._slots.color_space.removeEventListener("input", this);
44 | }
45 |
46 | handleEvent (event) {
47 | let source = event.target;
48 |
49 | if (this._el.sliders.contains(source)) {
50 | // From sliders
51 | let coords = [...this._el.sliders.children].map(el => el.value);
52 | let alpha = this.color.alpha;
53 | if (coords.length > 3) {
54 | alpha = coords.pop() / 100;
55 | }
56 | this.color = new Self.Color(this.space, coords, alpha);
57 | }
58 | else if (this._el.swatch.contains(source)) {
59 | // From swatch
60 | if (!this._el.swatch.color) {
61 | // Invalid color, or still typing
62 | return;
63 | }
64 | this.color = this._el.swatch.color;
65 | }
66 | else if (
67 | this._el.space_picker.contains(source) ||
68 | this._slots.color_space.assignedElements().includes(source)
69 | ) {
70 | this.spaceId = event.target.value;
71 | }
72 |
73 | this.dispatchEvent(new event.constructor(event.type, { ...event }));
74 | }
75 |
76 | propChangedCallback ({ name, prop, detail: change }) {
77 | if (name === "space" || name === "alpha") {
78 | let space = this.space;
79 |
80 | if (this.color.space !== space) {
81 | this.color = this.color.to(space);
82 | }
83 |
84 | let i = 0;
85 | let channels = [...Object.keys(this.space.coords)];
86 | if (this.alpha) {
87 | channels.push("alpha");
88 | }
89 | for (let channel of channels) {
90 | let slider = this._el.sliders.children[i++];
91 |
92 | if (slider) {
93 | slider.space = space;
94 | slider.channel = channel;
95 | }
96 | else {
97 | this._el.sliders.insertAdjacentHTML(
98 | "beforeend",
99 | ` `,
100 | );
101 | }
102 | }
103 |
104 | if (this._el.sliders.children.length > channels.length) {
105 | // Remove the slider for alpha
106 | this._el.sliders.children[channels.length].remove();
107 | }
108 |
109 | for (let slider of this._el.sliders.children) {
110 | slider.color = this.color;
111 | }
112 | }
113 |
114 | if (name === "color") {
115 | for (let slider of this._el.sliders.children) {
116 | slider.color = this.color;
117 | }
118 |
119 | if (!this._el.swatch.color || !this.color.equals(this._el.swatch.color)) {
120 | // Avoid typing e.g. "red" and having it replaced with "rgb(100% 0% 0%)" under your caret
121 | prop.applyChange(this._el.swatch, change);
122 | }
123 | }
124 | }
125 |
126 | static props = {
127 | spaceId: {
128 | default: "oklch",
129 | convert (value) {
130 | if (value === null || value === undefined) {
131 | return value;
132 | }
133 | else if (value instanceof Self.Color.Space) {
134 | return value.id;
135 | }
136 |
137 | return value + "";
138 | },
139 | changed ({ parsedValue, source, ...change }) {
140 | if (!parsedValue && source !== "default") {
141 | // Something went wrong. We should always have a value. Falling back to the current space
142 | this.spaceId = this.space.id;
143 | return;
144 | }
145 |
146 | if (this.props.space && this.props.space.id !== parsedValue) {
147 | // The space object we have in the cache is outdated. We need to delete it so that the space getter returns the updated one
148 | delete this.props.space;
149 | }
150 | },
151 | reflect: {
152 | from: "space",
153 | },
154 | },
155 |
156 | space: {
157 | get () {
158 | return this._el.space_picker.selectedSpace;
159 | },
160 | set: true,
161 | changed ({ parsedValue, ...change }) {
162 | if (parsedValue === undefined) {
163 | // this.spaceId changed
164 | if (this._el.space_picker.value !== this.spaceId) {
165 | this._el.space_picker.value = this.spaceId;
166 | }
167 |
168 | return;
169 | }
170 | else if (!parsedValue) {
171 | // Something went wrong. We should always have a value. Falling back to the current space
172 | this.space = this._el.space_picker.selectedSpace;
173 | return;
174 | }
175 |
176 | parsedValue =
177 | parsedValue instanceof Self.Color.Space ? parsedValue.id : parsedValue;
178 | if (this.spaceId !== parsedValue) {
179 | this._el.space_picker.value = parsedValue;
180 | this.spaceId = parsedValue;
181 | }
182 | },
183 | dependencies: ["spaceId"],
184 | defaultProp: "spaceId",
185 | reflect: false,
186 | },
187 |
188 | defaultColor: {
189 | get type () {
190 | return Self.Color;
191 | },
192 | convert (color) {
193 | return color.to(this.space);
194 | },
195 | default () {
196 | let coords = [];
197 | for (let channel in this.space.coords) {
198 | let spec = this.space.coords[channel];
199 | let range = spec.refRange ?? spec.range;
200 | coords.push((range[0] + range[1]) / 2);
201 | }
202 |
203 | return new Self.Color(this.space, coords);
204 | },
205 | reflect: {
206 | from: "color",
207 | },
208 | },
209 |
210 | color: {
211 | get type () {
212 | return Self.Color;
213 | },
214 | defaultProp: "defaultColor",
215 | reflect: false,
216 | },
217 |
218 | alpha: {
219 | parse (value) {
220 | if (value === undefined || value === null) {
221 | return;
222 | }
223 |
224 | if (value === false || value === "false") {
225 | return false;
226 | }
227 |
228 | if (value === "" || value === "alpha" || value === true || value === "true") {
229 | // Boolean attribute
230 | return true;
231 | }
232 | },
233 | reflect: {
234 | from: true,
235 | },
236 | },
237 | };
238 |
239 | static events = {
240 | change: {
241 | from () {
242 | return [this._el.space_picker, this._el.sliders, this._el.swatch];
243 | },
244 | },
245 | input: {
246 | from () {
247 | return [this._el.space_picker, this._el.sliders, this._el.swatch];
248 | },
249 | },
250 | colorchange: {
251 | propchange: "color",
252 | },
253 | };
254 | };
255 |
256 | Self.define();
257 |
258 | export default Self;
259 |
--------------------------------------------------------------------------------
/src/channel-slider/channel-slider.js:
--------------------------------------------------------------------------------
1 | import "../color-slider/color-slider.js";
2 | import * as dom from "../common/dom.js";
3 | import ColorElement from "../common/color-element.js";
4 | import { getStep } from "../common/util.js";
5 |
6 | const Self = class ChannelSlider extends ColorElement {
7 | static tagName = "channel-slider";
8 | static url = import.meta.url;
9 | static styles = "./channel-slider.css";
10 | static shadowTemplate = `
11 |
12 |
13 |
14 |
15 |
16 |
17 | `;
18 |
19 | constructor () {
20 | super();
21 |
22 | this._el = dom.named(this);
23 | this._el.slot = this.shadowRoot.querySelector("slot");
24 | }
25 |
26 | connectedCallback () {
27 | super.connectedCallback?.();
28 |
29 | this._el.slider.addEventListener("input", this);
30 | this._el.slot.addEventListener("input", this);
31 | }
32 |
33 | disconnectedCallback () {
34 | this._el.slider.removeEventListener("input", this);
35 | this._el.slot.removeEventListener("input", this);
36 | }
37 |
38 | handleEvent (event) {
39 | if (event.type === "input") {
40 | this.value = event.target.value;
41 | }
42 | }
43 |
44 | colorAt (value) {
45 | let color = this.defaultColor.clone();
46 |
47 | if (this.channel === "alpha") {
48 | color.alpha = value / 100;
49 | }
50 | else if (this.channel in color.space.coords) {
51 | color.set(this.channel, value);
52 | }
53 |
54 | return color;
55 | }
56 |
57 | colorAtProgress (progress) {
58 | // Map progress to min - max range
59 | let value = this.min + progress * (this.max - this.min);
60 | return this.colorAt(value);
61 | }
62 |
63 | get minColor () {
64 | return this.colorAt(this.min);
65 | }
66 |
67 | get maxColor () {
68 | return this.colorAt(this.max);
69 | }
70 |
71 | get stops () {
72 | return [
73 | this.minColor,
74 | this.colorAtProgress(0.25),
75 | this.colorAtProgress(0.5),
76 | this.colorAtProgress(0.75),
77 | this.maxColor,
78 | ];
79 | }
80 |
81 | get progress () {
82 | return this._el.slider.progress;
83 | }
84 |
85 | progressAt (p) {
86 | return this._el.slider.progressAt(p);
87 | }
88 |
89 | propChangedCallback ({ name, prop, detail: change }) {
90 | if (["space", "min", "max", "step", "value", "defaultValue"].includes(name)) {
91 | prop.applyChange(this._el.slider, change);
92 |
93 | if (["min", "max", "step", "value", "defaultValue"].includes(name)) {
94 | prop.applyChange(this._el.spinner, change);
95 |
96 | if (name === "value" && this.value !== undefined) {
97 | this._el.spinner.value = Number(this.value.toPrecision(4));
98 |
99 | if (!CSS.supports("field-sizing", "content")) {
100 | let valueStr = this._el.spinner.value;
101 | this._el.spinner.style.setProperty("--value-length", valueStr.length);
102 | }
103 | }
104 | }
105 | }
106 |
107 | if (
108 | name === "defaultColor" ||
109 | name === "space" ||
110 | name === "channel" ||
111 | name === "min" ||
112 | name === "max"
113 | ) {
114 | this._el.slider.stops = this.stops;
115 |
116 | if (name === "space" || name === "channel" || name === "min" || name === "max") {
117 | this._el.channel_info.innerHTML = `${this.channelName} (${this.min} – ${this.max}) `;
118 | }
119 | }
120 | }
121 |
122 | get channelName () {
123 | return this.channelSpec?.name ?? this.channel;
124 | }
125 |
126 | static props = {
127 | space: {
128 | default: "oklch",
129 | parse (value) {
130 | if (value instanceof Self.Color.Space || value === null || value === undefined) {
131 | return value;
132 | }
133 |
134 | value += "";
135 |
136 | return Self.Color.Space.get(value);
137 | },
138 | stringify (value) {
139 | return value?.id;
140 | },
141 | },
142 | channel: {
143 | type: String,
144 | default () {
145 | return Object.keys(this.space.coords)[0];
146 | },
147 | // get () {
148 | // let value = this.props.channel;
149 | // let space = this.space;
150 | // console.log(this.props, value, space);
151 |
152 | // if (!space || space.coords[value]) {
153 | // return value;
154 | // }
155 |
156 | // return Object.keys(this.space.coords)[0];
157 | // },
158 | // set: true,
159 | // reflect: true,
160 | },
161 | channelSpec: {
162 | get () {
163 | if (this.channel === "alpha") {
164 | return {
165 | name: "Alpha",
166 | };
167 | }
168 |
169 | let channelSpec = this.space?.coords[this.channel];
170 |
171 | if (!channelSpec && this.space) {
172 | channelSpec = Object.values(this.space.coords)[0];
173 | console.warn(
174 | `Unknown channel ${this.channel} in space ${this.space}. Using first channel (${channelSpec.name}) instead.`,
175 | );
176 | }
177 |
178 | return channelSpec;
179 | },
180 | },
181 | refRange: {
182 | get () {
183 | let channelSpec = this.channelSpec;
184 | return channelSpec?.refRange ?? channelSpec?.range ?? [0, 100];
185 | },
186 | },
187 | min: {
188 | type: Number,
189 | default () {
190 | return this.refRange[0];
191 | },
192 | },
193 | max: {
194 | type: Number,
195 | default () {
196 | return this.refRange[1];
197 | },
198 | },
199 | step: {
200 | type: Number,
201 | default () {
202 | return getStep(this.max, this.min, { minSteps: 100 });
203 | },
204 | },
205 |
206 | defaultValue: {
207 | type: Number,
208 | default () {
209 | if (this.channel === "alpha") {
210 | return this.defaultColor.alpha * 100;
211 | }
212 | else if (this.channel in this.defaultColor.space.coords) {
213 | return this.defaultColor.get(this.channel);
214 | }
215 | else {
216 | let firstChannel = Object.keys(this.defaultColor.space.coords)[0];
217 | return this.defaultColor.get(firstChannel);
218 | }
219 | },
220 | reflect: {
221 | from: "value",
222 | },
223 | },
224 | value: {
225 | type: Number,
226 | defaultProp: "defaultValue",
227 | reflect: false,
228 | },
229 |
230 | defaultColor: {
231 | get type () {
232 | return Self.Color;
233 | },
234 | convert (color) {
235 | return color.to(this.space);
236 | },
237 | default () {
238 | let coords = [];
239 | for (let channel in this.space.coords) {
240 | let spec = this.space.coords[channel];
241 | let range = spec.refRange ?? spec.range ?? [0, 100];
242 | coords.push((range[0] + range[1]) / 2);
243 | }
244 |
245 | return new Self.Color(this.space, coords);
246 | },
247 | reflect: {
248 | from: "color",
249 | },
250 | },
251 | color: {
252 | get type () {
253 | return Self.Color;
254 | },
255 | get () {
256 | return this.colorAt(this.value);
257 | },
258 | dependencies: ["defaultColor", "space", "channel", "value"],
259 | set (value) {
260 | this.defaultColor = value;
261 | this.value = this.defaultValue;
262 | },
263 | },
264 | };
265 |
266 | static events = {
267 | change: {
268 | from () {
269 | return this._el.slider;
270 | },
271 | },
272 | input: {
273 | from () {
274 | return this._el.slider;
275 | },
276 | },
277 | valuechange: {
278 | propchange: "value",
279 | },
280 | colorchange: {
281 | propchange: "color",
282 | },
283 | };
284 |
285 | static formAssociated = {
286 | like: el => el._el.slider,
287 | role: "slider",
288 | valueProp: "value",
289 | changeEvent: "valuechange",
290 | };
291 | };
292 |
293 | Self.define();
294 |
295 | export default Self;
296 |
--------------------------------------------------------------------------------
/src/color-chart/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | Display lists of colors as a scatterplot or line chart.
4 |
5 | ## Features
6 |
7 | - Plot any coordinate, in any color space
8 | - Customize both X and Y axes independently
9 |
10 | ## Examples
11 |
12 | ### Basic usage
13 |
14 | Plotting a single color scale:
15 |
16 | ```html
17 |
18 |
19 |
20 | ```
21 |
22 | _(Colors courtesy of Tailwind)_
23 |
24 | ### Default X coordinate
25 |
26 | By default, the other coordinate would be the index of the color in the list, but you can specify it explicitly:
27 |
28 | ```html
29 |
30 |
31 |
32 | ```
33 |
34 | You can also specify a whole label, and if it contains a number, the number will become the default X coordinate:
35 |
36 | ```html
37 |
38 |
39 |
40 |
41 |
42 | ```
43 |
44 | ### Explicit X coordinate
45 |
46 | You can also specify the X coordinate explicitly.
47 |
48 | ```html
49 |
50 |
51 |
52 | ```
53 |
54 | ### Custom X and Y axis ranges
55 |
56 | You can customize the ranges of both X and Y axes independently:
57 |
58 | ```html
59 |
60 |
61 |
62 | ```
63 |
64 | This can be useful when plotting data that changes dynamically, so that the axes are always the same size.
65 |
66 | ### Plotting hues { #hues}
67 |
68 | Since hues are angles, there are often multiple ways they can be plotted.
69 | E.g. A hue of `10` could also be plotted as `-350` or `370`.
70 | `` will automatically shift the hue based on the other hues in the same scale to produce a nicer result.
71 |
72 | ```html
73 |
74 |
75 |
76 | ```
77 |
78 | ```html
79 |
80 |
81 |
82 |
83 | ```
84 |
85 | ### The `info` attribute
86 |
87 | You can use the `info` attribute to show information about the color scale points. Currently, the only type of information supported is color coords (in any color space), but more will be added in the future.
88 |
89 | The format of this attribute is analogous to the one of [``](../color-swatch/#the-info-attribute).
90 |
91 | ```html
92 |
93 |
94 |
95 |
96 |
97 | ```
98 |
99 | Reactively changing the Y coordinate:
100 |
101 | ```html
102 |
103 | Switch to "HWB Whiteness"
104 |
105 |
106 |
107 |
108 |
109 |
110 | ```
111 |
112 | Reactively setting/changing the colors:
113 | ```html
114 | Colors:
115 |
116 | None
117 | Yellow
118 | Orange
119 | Red
120 |
121 |
122 |
123 |
124 |
125 | ```
126 |
127 | ## Reference
128 |
129 | ### Attributes & Properties
130 |
131 | | Attribute | Property | Property type | Default value | Description |
132 | |-----------|----------|---------------|---------------|-------------|
133 | | `x` | `x` | `string` | `null` | The coord to plot on the X axis, if any |
134 | | `xmin` | `xMin` | `number` or `"coord"` or `"auto"` | - | The minimum value of the X axis. Defaults to the minimum value of the X coord. |
135 | | `xmax` | `xMax` | `number` or `"coord"` or `"auto"` | - | The maximum value of the X axis. Defaults to the maximum value of the X coord. |
136 | | `y` | `y` | `string` | `"oklch.l"` | The coord to plot on the Y axis, if any |
137 | | `ymin` | `yMin` | `number` or `"coord"` or `"auto"` | - | The minimum value of the Y axis. Defaults to the minimum value of the Y coord. |
138 | | `ymax` | `yMax` | `number` or `"coord"` or `"auto"` | - | The maximum value of the Y axis. Defaults to the maximum value of the Y coord. |
139 | | `info` | `info` | `string` | - | Comma-separated list of coords of the color point to be shown in the tooltip. |
140 |
141 | ### Events
142 |
143 | | Name | Description |
144 | |------|-------------|
145 |
146 | ### CSS variables
147 |
148 | | Name | Type | Description |
149 | |------|------|-------------|
150 | | `--color-scale-type` | `discrete` or `normal` | Whether to draw lines between consecutive points. Works on individual color swatches (to prevent drawing a line to the *next* point), entire color scales, or the entire chart. |
151 |
152 | ### Parts
153 |
154 | | Name | Description |
155 | |------|-------------|
156 | | `color-channel` | The default [``](../channel-picker/) element, used if the `color-channel` slot has no slotted elements. |
157 | | `axis` | The axis line |
158 | | `ticks` | The container of ticks |
159 | | `tick` | A tick mark |
160 | | `label` | A label on the axis |
161 |
--------------------------------------------------------------------------------
/src/color-slider/color-slider.js:
--------------------------------------------------------------------------------
1 | import ColorElement from "../common/color-element.js";
2 | import { getStep } from "../common/util.js";
3 |
4 | let supports = {
5 | inSpace: CSS?.supports("background", "linear-gradient(in oklab, red, tan)"),
6 | fieldSizing: CSS?.supports("field-sizing", "content"),
7 | };
8 |
9 | const Self = class ColorSlider extends ColorElement {
10 | static tagName = "color-slider";
11 | static url = import.meta.url;
12 | static styles = "./color-slider.css";
13 | static shadowTemplate = `
14 |
15 |
16 |
17 |
18 | `;
19 |
20 | constructor () {
21 | super();
22 |
23 | this._el = {
24 | slider: this.shadowRoot.querySelector(".color-slider"),
25 | spinner: this.shadowRoot.querySelector("input[type=number]"),
26 | };
27 | }
28 |
29 | connectedCallback () {
30 | super.connectedCallback?.();
31 |
32 | this._el.slider.addEventListener("input", this);
33 | this._el.spinner.addEventListener("input", this);
34 | }
35 |
36 | disconnectedCallback () {
37 | this._el.slider.removeEventListener("input", this);
38 | this._el.spinner.removeEventListener("input", this);
39 | }
40 |
41 | handleEvent (event) {
42 | if (event.type === "input") {
43 | if (this.tooltip === "progress" && event.target === this._el.spinner) {
44 | // Convert to value
45 | let value = this._el.spinner.value;
46 | this.value = this.valueAt(value / 100);
47 | }
48 | else {
49 | this.value = event.target.value;
50 | }
51 |
52 | this.dispatchEvent(new event.constructor(event.type, { ...event }));
53 | }
54 | }
55 |
56 | propChangedCallback ({ name, prop, detail: change }) {
57 | if (["min", "max", "step", "value", "defaultValue"].includes(name)) {
58 | prop.applyChange(this._el.slider, change);
59 |
60 | let value = change.value;
61 | if (this.tooltip === "progress") {
62 | if (name === "value" || name === "defaultValue") {
63 | value = +(this.progress * 100).toPrecision(4);
64 | }
65 | else {
66 | // Spinner values when tooltip is "progress"
67 | value = { min: 1, max: 100, step: 1 }[name];
68 | }
69 | }
70 | prop.applyChange(this._el.spinner, { ...change, value: +(+value).toPrecision(4) });
71 | }
72 |
73 | if (name === "stops") {
74 | // FIXME will fail if there are none values
75 | let stops = this.stops;
76 | let supported = stops.every(color => CSS.supports("color", color));
77 |
78 | // CSS does not support (yet?) a raw hue interpolation,
79 | // so we need to fake it with tessellateStops() in cases of polar space and far-apart stops.
80 | let farApart = false;
81 | let space = this.space;
82 | if (space.isPolar) {
83 | for (let i = 1; i < stops.length; i++) {
84 | // Even though space is polar, color stops might be in non-polar spaces
85 | let first = stops[i - 1].to(space);
86 | let second = stops[i].to(space);
87 |
88 | let firstHue = first.get("h");
89 | let secondHue = second.get("h");
90 |
91 | if (Math.abs(firstHue - secondHue) >= 180) {
92 | farApart = true;
93 | break;
94 | }
95 | }
96 | }
97 |
98 | if (!supported || farApart) {
99 | stops = this.tessellateStops({ steps: 3 });
100 | }
101 |
102 | stops = stops.map(color => color.display()).join(", ");
103 |
104 | this.style.setProperty("--slider-color-stops", stops);
105 | }
106 | else if (name === "space" && supports.inSpace) {
107 | let space = this.space;
108 | let spaceId = space.id;
109 | let supported = CSS.supports("background", `linear-gradient(in ${spaceId}, red, tan)`);
110 |
111 | if (!supported) {
112 | spaceId = this.space.isPolar ? "oklch" : "oklab";
113 | }
114 |
115 | this.style.setProperty("--color-space", spaceId);
116 | }
117 | else if (name === "color" || name === "defaultColor") {
118 | let color = this.color;
119 |
120 | if (color) {
121 | let displayedColor = color.display();
122 | this.style.setProperty("--color", displayedColor);
123 | }
124 | }
125 | else if (name === "value" || name === "min" || name === "max") {
126 | this.style.setProperty("--progress", this.progress);
127 |
128 | if (name === "value" && !supports.fieldSizing) {
129 | let valueStr = this.value + "";
130 | this._el.spinner.style.setProperty("--value-length", valueStr.length);
131 | }
132 | }
133 | else if (name === "tooltip") {
134 | if (change.value !== undefined) {
135 | let values = this;
136 | if (change.value === "progress") {
137 | values = {
138 | min: 1,
139 | max: 100,
140 | step: 1,
141 | value: +(this.progress * 100).toPrecision(4),
142 | };
143 | }
144 |
145 | ["min", "max", "step", "value"].forEach(name => {
146 | this._el.spinner[name] = values[name];
147 | });
148 | }
149 | }
150 | }
151 |
152 | tessellateStops (options = {}) {
153 | let stops = this.stops;
154 | let tessellated = [];
155 |
156 | for (let i = 1; i < stops.length; i++) {
157 | let start = stops[i - 1];
158 | let end = stops[i];
159 | let steps = start.steps(end, { space: this.space, ...options });
160 | tessellated.push(...steps);
161 |
162 | if (i < stops.length - 1) {
163 | tessellated.pop();
164 | }
165 | }
166 |
167 | return tessellated;
168 | }
169 |
170 | get progress () {
171 | return this.progressAt(this.value);
172 | }
173 |
174 | progressAt (value) {
175 | return (value - this.min) / (this.max - this.min);
176 | }
177 |
178 | valueAt (progress) {
179 | return this.min + progress * (this.max - this.min);
180 | }
181 |
182 | colorAt (value) {
183 | let progress = this.progressAt(value);
184 | return this.colorAtProgress(progress);
185 | }
186 |
187 | colorAtProgress (progress) {
188 | let bands = this.scales?.length;
189 |
190 | if (bands <= 0) {
191 | return null;
192 | }
193 |
194 | // FIXME the values outside of [0, 1] should be scaled
195 | if (progress >= 1) {
196 | return this.scales.at(-1)(progress);
197 | }
198 | else if (progress <= 0) {
199 | return this.scales[0](progress);
200 | }
201 |
202 | let band = 1 / bands;
203 | let scaleIndex = Math.max(0, Math.min(Math.floor(progress / band), bands - 1));
204 | let scale = this.scales[scaleIndex];
205 | let color = scale((progress % band) * bands);
206 |
207 | return color;
208 | }
209 |
210 | static props = {
211 | min: {
212 | type: Number,
213 | default: 0,
214 | },
215 | max: {
216 | type: Number,
217 | default: 1,
218 | },
219 | step: {
220 | type: Number,
221 | default () {
222 | return getStep(this.max, this.min, { minSteps: 100 });
223 | },
224 | },
225 | stops: {
226 | type: {
227 | is: Array,
228 | get values () {
229 | return Self.Color;
230 | },
231 | },
232 | default: el => [],
233 | },
234 | defaultValue: {
235 | type: Number,
236 | default () {
237 | return (this.min + this.max) / 2;
238 | },
239 | reflect: {
240 | from: "value",
241 | },
242 | },
243 | value: {
244 | type: Number,
245 | defaultProp: "defaultValue",
246 | reflect: false,
247 | },
248 |
249 | space: {
250 | default () {
251 | return this.stops[0]?.space ?? "oklab";
252 | },
253 | parse (value) {
254 | if (value instanceof Self.Color.Space || value === null || value === undefined) {
255 | return value;
256 | }
257 |
258 | value += "";
259 |
260 | return Self.Color.Space.get(value);
261 | },
262 | stringify (value) {
263 | return value?.id;
264 | },
265 | },
266 |
267 | color: {
268 | get type () {
269 | return Self.Color;
270 | },
271 | get () {
272 | return this.colorAt(this.value);
273 | },
274 | dependencies: ["scales", "value"],
275 | },
276 | scales: {
277 | get () {
278 | let stops = this.stops;
279 | let scales = [];
280 |
281 | for (let i = 1; i < stops.length; i++) {
282 | let start = stops[i - 1];
283 | let end = stops[i];
284 | let range = start.range(end, { space: this.space });
285 | scales.push(range);
286 | }
287 |
288 | return scales;
289 | },
290 | dependencies: ["stops", "space"],
291 | },
292 |
293 | tooltip: {
294 | type: String,
295 | },
296 | };
297 |
298 | static events = {
299 | change: {
300 | from () {
301 | return this._el.slider;
302 | },
303 | },
304 | valuechange: {
305 | propchange: "value",
306 | },
307 | colorchange: {
308 | propchange: "color",
309 | },
310 | };
311 |
312 | static formAssociated = {
313 | like: el => el._el.slider,
314 | role: "slider",
315 | valueProp: "value",
316 | changeEvent: "valuechange",
317 | };
318 | };
319 |
320 | Self.define();
321 |
322 | export default Self;
323 |
--------------------------------------------------------------------------------
/src/color-slider/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | Creates a slider with a gradient background, primarily intended for color picking.
4 |
5 | ## Usage
6 |
7 | There are many ways to use this component, depending on what you need.
8 | E.g. if all you need is styling sliders with arbitrary gradients you don’t even need a component,
9 | you can just [use the CSS file](#css-only-usage) and a few classes and CSS variables to style regular HTML sliders.
10 |
11 | The actual component does a lot more:
12 | - It provides a `color` property with the actual color value
13 | - It takes care of even displaying colors in unsupported color spaces
14 | - Editable tooltip showing the current value or progress _(optional)_
15 | - Convenient events like `colorchange` and `valuechange` that fire even when the value changes programmatically
16 |
17 | Basic example:
18 |
19 | ```html
20 |
22 | ```
23 |
24 | You can listen to the `colorchange` event and grab the `color` property to get the current color value:
25 |
26 | ```html
27 |
30 |
31 | ```
32 |
33 | In fact, you can combine it with a [``](../color-inline/) or [``](../color-swatch/) element to display the color in a more visual way:
34 |
35 | ```html
36 |
39 |
40 | ```
41 |
42 | Colors and color spaces not supported by the browser also work:
43 |
44 | ```html
45 |
46 | ```
47 |
48 | You can set the `value` attribute to specify an initial color other than the midpoint:
49 |
50 | ```html
51 |
55 |
56 | ```
57 |
58 | You can use a different min and max value and it’s just linearly mapped to the stops:
59 |
60 | ```html
61 |
65 |
66 | ```
67 |
68 | You can add an editable tooltip by simply using the `tooltip` attribute:
69 |
70 | ```html
71 |
76 |
77 | ```
78 |
79 | By default, the tooltip will show the slider value as a number.
80 | If you want to show the progress instead, you can specify `"progress"` as the attribute value:
81 |
82 | ```html
83 |
88 |
89 | ```
90 |
91 | All properties are reactive and can be set programmatically:
92 |
93 | ```html
94 | Random color
95 |
98 |
99 | ```
100 |
101 | You can style it to look quite different:
102 |
103 | ```html
104 |
113 |
115 | ```
116 |
117 |
118 | ### CSS-only usage
119 |
120 | If you just want the styling of `` and not any of the API (or are fine dealing with the lower level details on your own),
121 | you *can* just use the CSS file:
122 |
123 | ```css
124 | @import url("https://elements.colorjs.io/src/color-slider/color-slider.css");
125 | ```
126 |
127 | This is perfect for when the gradient is more of a visual aid than a functional part of your UI,
128 | e.g. when picking a temperature:
129 |
130 | ```html
131 |
134 |
135 | Temperature:
136 |
138 |
139 | ```
140 |
141 | Then use a `color-slider` class on your slider element, and use [CSS variables](#css-variables) to set the gradient (either directly via `--slider-gradient` or generated via `--slider-color-stops` + `--color-space`).
142 |
143 | ## Reference
144 |
145 | ### Slots
146 |
147 | | Name | Description |
148 | |------|-------------|
149 | | _(default)_ | Content placed after the color slider. |
150 | | `tooltip` | An element used as a tooltip. |
151 |
152 | ### Attributes & Properties
153 |
154 | | Attribute | Property | Property type | Default value | Description |
155 | |-----------|----------|---------------|---------------|-------------|
156 | | `space` | `space` | `ColorSpace` | `string` | `oklch` | The color space to use for interpolation. |
157 | | `color` | `color` | `Color` | `string` | `oklch(50% 50% 180)` | The current color value. |
158 | | `stops` | `stops` | `String` | `Array` | - | Comma-separated list of color stops. |
159 | | `min` | `min` | `number` | 0 | The minimum value for the slider. |
160 | | `max` | `max` | `number` | 1 | The maximum value for the slider. |
161 | | `step` | `step` | `number` | Computed automatically based on `this.min` and `this.max`. | The granularity that the slider's current value must adhere to. |
162 | | `value` | `value` | `number` | `(this.min + this.max) / 2` | The current value of the slider. |
163 |
164 | ### CSS variables
165 |
166 | If you’re using the component, these are mostly set automatically.
167 | If you’re only using the CSS file, you should set these yourself.
168 |
169 | | Variable | Type | Description |
170 | |----------|---------------|-------------|
171 | | `--slider-color-stops` | `#` | Comma-separated list of color stops. |
172 | | `--color-space` | `` | The color space to use for interpolation. |
173 | | `--hue-interpolation` | `[shorter` | `longer` | `increasing` | `decreasing] hue` | The hue interpolation method to use. |
174 | | `--transparency-grid` | `` | Gradient used as a background for transparent parts of the slider. |
175 | | `--transparency-cell-size` | `` | The size of the cells of the transparency gradient. |
176 | | `--transparcency-background` | `` | The background color of the transparency gradient. |
177 | | `--transparency-darkness` | `` | The opacity of the black color used for dark parts of the transparency gradient. |
178 | | `--slider-gradient` | `` | The gradient to use as the background. |
179 | | `--slider-height` | `` | Height of the slider track. |
180 | | `--slider-thumb-width` | `` | Width of the slider thumb. |
181 | | `--slider-thumb-height` | `` | Height of the slider thumb. |
182 | | `--slider-thumb-height-offset` | `` | Offset the thumb height from the track height. |
183 | | `--slider-thumb-radius` | `` | Radius of the slider thumb. |
184 | | `--slider-thumb-background` | `` | Background color of the slider thumb. |
185 | | `--slider-thumb-border` | `` || `` || `` | Border of the slider thumb. |
186 | | `--slider-thumb-border-active` | `` || `` || `` | Border of the slider thumb in active state. |
187 | | `--slider-thumb-scale-active` | `` | Scale transform applied to the slider thumb in active state. |
188 | | `--tooltip-background` | `` | Background color of the tooltip. |
189 | | `--tooltip-border-radius` | `` | Border radius of the tooltip. |
190 | | `--tooltip-pointer-height` | `` | Height of the tooltip pointer triangle. |
191 | | `--tooltip-pointer-angle` | `` | Angle of the tooltip pointer triangle. |
192 |
193 | ### Getters
194 |
195 | These properties are read-only.
196 |
197 | | Property | Type | Description |
198 | |----------|------|-------------|
199 | | `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. |
200 |
201 |
202 | ### Events
203 |
204 | | Name | Description |
205 | |------|-------------|
206 | | `input` | Fired when the color changes due to user action. |
207 | | `change` | Fired when the color changes due to user action. |
208 | | `valuechange` | Fired when the value changes for any reason, and once during initialization. |
209 | | `colorchange` | Fired when the color changes for any reason, and once during initialization. |
210 |
211 | ### Parts
212 |
213 | | Name | Description |
214 | |------|-------------|
215 | | `slider` | The internal ` ` element. |
216 | | `spinner` | The default `tooltip` slot content (an ` ` element). Please note that if an element is slotted in the `tooltip` slot, this will not match anyhing. |
217 |
218 | ## Planned features
219 |
220 | - Discrete scales & steps
--------------------------------------------------------------------------------
/src/color-swatch/README.md:
--------------------------------------------------------------------------------
1 | # ``
2 |
3 | ## Examples
4 |
5 | ### Basic usage
6 |
7 |
52 |
53 | You can use a `--details-style: compact` CSS property to only show the details on user interaction:
54 |
55 | ```html
56 | oklch(70% 0.25 138)
57 | oklch(70% 0.25 138)
58 | ```
59 |
60 | Warning: This is not keyboard accessible by default.
61 | To make the element focusable and also show the popup when it is focused, you need to add `tabindex="0"` to your element:
62 |
63 | ```html
64 | oklch(70% 0.25 138)
65 | ```
66 |
67 | By default, the popup will be shown when the element is hovered, focused, `:active`, or the target of the URL hash.
68 | To circumvent user interaction and force the popup to be open use the `open` attribute.
69 | You can also use `open="false"` to force it to be closed regardless of interaction:
70 |
71 | ```html
72 |
73 | oklch(70% 0.25 138)
74 | oklch(70% 0.25 138)
75 | oklch(70% 0.25 138)
76 |
77 | ```
78 |
79 | ### The `value` attribute
80 |
81 | You can provide the color via the `value` attribute,
82 | which can be more convenient when you have slotted content.
83 |
84 | In that case, the content of the element is merely presentational
85 | (unless it’s an ` `).
86 | If you don’t specify any content, no text will be shown.
87 |
88 |
117 |
118 | You can also use this as a property when creating color swatches dynamically:
119 |
120 | ```html
121 |
122 |
129 | ```
130 |
131 | ### The `label` attribute
132 |
133 | You can provide the color label via the `label` attribute.
134 |
135 |
180 |
181 | If the attribute's value matches the element's content, no additional text with the label will be shown.
182 |
183 | ```html
184 | Turquoise
185 | ```
186 |
187 | If used as a property and is not defined via the `label` attribute, its value is that of the element text content.
188 |
189 | ### The `info` attribute
190 |
191 | You can use the `info` attribute to show information about the color.
192 | Currently, the only type of information supported is color coords (in any color space), but more will be added in the future.
193 |
194 | ```html
195 |
196 | oklch(70% 0.25 138)
197 |
198 | ```
199 |
200 | 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),
201 | but you can customize this by adding a label before the description of the data:
202 |
203 | ```html
204 |
205 | oklch(70% 0.25 138)
206 |
207 | ```
208 |
209 |
210 | The `info` attribute plays quite nicely with the `--details-style: compact` style:
211 |
212 | ```html
213 | oklch(70% 0.25 138)
214 | ```
215 |
216 | ### With slot content
217 |
218 | Before and after:
219 |
220 | ```html
221 |
222 | Accent color:
223 |
224 |
225 | ```
226 |
227 | ```html
228 |
229 | Accent color:
230 | oklch(70% 0.25 138)
231 |
232 | ```
233 |
234 | ```html
235 |
236 | Accent color:
237 |
238 | Tip: Pick a bright medium color.
239 |
240 | ```
241 |
242 | Adding text within the default swatch:
243 |
244 | ```html
245 |
246 | Some text
247 |
248 |
249 | ```
250 |
251 | 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/)).
252 |
253 | ----
254 |
255 | Replacing the whole swatch with a custom element:
256 |
257 | ```html
258 |
259 | Some text
260 |
261 |
262 | ```
263 |
264 |
280 |
281 | ### Events
282 |
283 | ```html
284 |
285 |
286 |
287 |
288 | ```
289 |
290 | ### Update via JS
291 |
292 | #### Static
293 |
294 | ```html
295 | oklch(70% 0.25 138)
296 | Change color
297 | ```
298 |
299 | ### Editable
300 |
301 | ```html
302 |
303 |
304 |
305 | Change color
306 | ```
307 |
308 | ## Reference
309 |
310 | ### Attributes & Properties
311 |
312 | | Attribute | Property | Property type | Default value | Description |
313 | |-----------|----------|---------------|---------------|-------------|
314 | | `color` | `color` | `Color` | `string` | - | The current color value. |
315 | | `info` | `info` | `string` | - | Comma-separated list of coords of the current color to be shown. |
316 | | `value` | `value` | `string` | - | The current value of the swatch. |
317 | | `label` | `label` | `string` | - | The label of the swatch (e.g., color name). Defaults to the element text content. |
318 | | `size` | - | `large` | - | The size of the swatch. Currently, it is used only to make a large swatch. |
319 | | `property` | `property` | `string` | - | CSS property to bind to. |
320 | | `scope` | `scope` | `string` | `:root` | CSS selector to use as the scope for the specified CSS property. |
321 | | `gamuts` | `gamuts` | `string` | `srgb, p3, rec2020: P3+, prophoto: PP` | Comma-separated list of gamuts to be used by the gamut indicator. |
322 | | `open` | `open` | | `null` | Force the details popup open or closed. |
323 |
324 | ### Getters
325 |
326 | These properties are read-only.
327 |
328 | | Name | Type | Description |
329 | |----------|------|-------------|
330 | | `gamut` | `string` | The id of the current gamut (e.g. `srgb`). |
331 |
332 | ### CSS variables
333 |
334 | | Name | Type | Description |
335 | |----------|---------------|-------------|
336 | | `--details-style` | `compact` | `normal` (default) | |
337 | | `--transparency-grid` | `` | Gradient used as a background for transparent parts of the swatch. |
338 | | `--transparency-cell-size` | `` | The size of the cells of the transparency gradient. |
339 | | `--transparcency-background` | `` | The background color of the transparency gradient. |
340 | | `--transparency-darkness` | `` | The opacity of the black color used for dark parts of the transparency gradient. |
341 |
342 | ### Parts
343 |
344 | | Name | Description |
345 | |------|-------------|
346 | | `swatch` | The swatch used to render the color. |
347 | | `details` | Wrapper around all non-swatch content (color name, info, etc) |
348 | | `label` | The label of the swatch |
349 | | `color-wrapper` | Wrapper around the color name itself |
350 | | `gamut` | Gamut indicator |
351 | | `info` | Any info generateed by the `info` attribute |
352 |
353 | ### Events
354 |
355 | | Name | Description |
356 | |------|-------------|
357 | | `valuechange` | Fired when the value changes for any reason, and once during initialization. |
358 | | `colorchange` | Fired when the color changes for any reason, and once during initialization. |
359 | | `gamutchange` | Fired when the gamut changes for any reason, and once during initialization. |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import stylistic from "@stylistic/eslint-plugin";
3 |
4 | export default [
5 | {
6 | languageOptions: {
7 | ecmaVersion: 2022,
8 | sourceType: "module",
9 | globals: {
10 | ...globals.browser,
11 | },
12 | },
13 | plugins: {
14 | "@stylistic": stylistic,
15 | },
16 | rules: {
17 | /**
18 | * ESLint rules: https://eslint.org/docs/latest/rules/
19 | * Based off of: https://github.com/eslint/eslint/blob/v8.54.0/packages/js/src/configs/eslint-recommended.js
20 | */
21 | // Enforce curly braces for all control statements
22 | // https://eslint.org/docs/latest/rules/curly
23 | curly: 1,
24 |
25 | // Require `super()` calls in constructors
26 | // https://eslint.org/docs/latest/rules/constructor-super
27 | "constructor-super": 1,
28 |
29 | // Enforce “for” loop update clause moving the counter in the right direction
30 | // https://eslint.org/docs/latest/rules/for-direction
31 | "for-direction": 1,
32 |
33 | // Enforce `return` statements in getters
34 | // https://eslint.org/docs/latest/rules/getter-return
35 | "getter-return": 1,
36 |
37 | // Disallow using an async function as a Promise executor
38 | // https://eslint.org/docs/latest/rules/no-async-promise-executor
39 | "no-async-promise-executor": 1,
40 |
41 | // Disallow `let`/const`/function`/`class` in `case`/`default` clauses
42 | // https://eslint.org/docs/latest/rules/no-case-declarations
43 | "no-case-declarations": 1,
44 |
45 | // Disallow reassigning class members
46 | // https://eslint.org/docs/latest/rules/no-class-assign
47 | "no-class-assign": 1,
48 |
49 | // Disallow comparing against -0
50 | // https://eslint.org/docs/latest/rules/no-compare-neg-zero
51 | "no-compare-neg-zero": 1,
52 |
53 | // Disallow reassigning `const` variables
54 | // https://eslint.org/docs/latest/rules/no-const-assign
55 | "no-const-assign": 1,
56 |
57 | // Disallow constant expressions in conditions
58 | // https://eslint.org/docs/latest/rules/no-constant-condition
59 | "no-constant-condition": 1,
60 |
61 | // Disallow control characters in regular expressions
62 | // https://eslint.org/docs/latest/rules/no-control-regex
63 | "no-control-regex": 1,
64 |
65 | // Disallow the use of `debugger`
66 | // https://eslint.org/docs/latest/rules/no-debugger
67 | "no-debugger": 1,
68 |
69 | // Disallow deleting variables
70 | // https://eslint.org/docs/latest/rules/no-delete-var
71 | "no-delete-var": 1,
72 |
73 | // Disallow duplicate arguments in `function` definitions
74 | // https://eslint.org/docs/latest/rules/no-dupe-args
75 | "no-dupe-args": 1,
76 |
77 | // Disallow duplicate class members
78 | // https://eslint.org/docs/latest/rules/no-dupe-class-members
79 | "no-dupe-class-members": 1,
80 |
81 | // Disallow duplicate conditions in if-else-if chains
82 | // https://eslint.org/docs/latest/rules/no-dupe-else-if
83 | "no-dupe-else-if": 1,
84 |
85 | // Disallow duplicate keys in object literals
86 | // https://eslint.org/docs/latest/rules/no-dupe-keys
87 | "no-dupe-keys": 1,
88 |
89 | // Disallow duplicate case labels
90 | // https://eslint.org/docs/latest/rules/no-duplicate-case
91 | "no-duplicate-case": 1,
92 |
93 | // Disallow empty character classes in regular expressions
94 | // https://eslint.org/docs/latest/rules/no-empty-character-class
95 | "no-empty-character-class": 1,
96 |
97 | // Disallow empty destructuring patterns
98 | // https://eslint.org/docs/latest/rules/no-empty-pattern
99 | "no-empty-pattern": 1,
100 |
101 | // Disallow reassigning exceptions in `catch` clauses
102 | // https://eslint.org/docs/latest/rules/no-ex-assign
103 | "no-ex-assign": 1,
104 |
105 | // Disallow unnecessary boolean casts
106 | // https://eslint.org/docs/latest/rules/no-extra-boolean-cast
107 | "no-extra-boolean-cast": 1,
108 |
109 | // Disallow fallthrough of `case` statements
110 | // unless marked with a comment that matches `/falls?\s?through/i` regex
111 | // https://eslint.org/docs/latest/rules/no-fallthrough
112 | "no-fallthrough": 1,
113 |
114 | // Disallow reassigning `function` declarations
115 | // https://eslint.org/docs/latest/rules/no-func-assign
116 | "no-func-assign": 1,
117 |
118 | // Disallow assignments to native objects or read-only global variables
119 | // https://eslint.org/docs/latest/rules/no-global-assign
120 | "no-global-assign": 1,
121 |
122 | // Disallow assigning to imported bindings
123 | // https://eslint.org/docs/latest/rules/no-import-assign
124 | "no-import-assign": 1,
125 |
126 | // Disallow invalid regular expression strings in `RegExp` constructors
127 | // https://eslint.org/docs/latest/rules/no-invalid-regexp
128 | "no-invalid-regexp": 1,
129 |
130 | // Disallow whitespace that is not `tab` or `space` except in string literals
131 | // https://eslint.org/docs/latest/rules/no-irregular-whitespace
132 | "no-irregular-whitespace": 1,
133 |
134 | // Disallow characters which are made with multiple code points in character class syntax
135 | // https://eslint.org/docs/latest/rules/no-misleading-character-class
136 | "no-misleading-character-class": 1,
137 |
138 | // Disallow `new` operators with the `Symbol` object
139 | // https://eslint.org/docs/latest/rules/no-new-symbol
140 | "no-new-symbol": 1,
141 |
142 | // Disallow `\8` and `\9` escape sequences in string literals
143 | // https://eslint.org/docs/latest/rules/no-nonoctal-decimal-escape
144 | "no-nonoctal-decimal-escape": 1,
145 |
146 | // Disallow calling global object properties as functions
147 | // https://eslint.org/docs/latest/rules/no-obj-calls
148 | "no-obj-calls": 1,
149 |
150 | // Disallow octal literals
151 | // https://eslint.org/docs/latest/rules/no-octal
152 | "no-octal": 1,
153 |
154 | // Disallow calling some `Object.prototype` methods directly on objects
155 | // https://eslint.org/docs/latest/rules/no-prototype-builtins
156 | "no-prototype-builtins": 1,
157 |
158 | // Disallow multiple spaces in regular expressions
159 | // https://eslint.org/docs/latest/rules/no-regex-spaces
160 | "no-regex-spaces": 1,
161 |
162 | // Disallow assignments where both sides are exactly the same
163 | // https://eslint.org/docs/latest/rules/no-self-assign
164 | "no-self-assign": 1,
165 |
166 | // Disallow identifiers from shadowing restricted names
167 | // https://eslint.org/docs/latest/rules/no-shadow-restricted-names
168 | "no-shadow-restricted-names": 1,
169 |
170 | // Disallow `this`/`super` before calling `super()` in constructors
171 | // https://eslint.org/docs/latest/rules/no-this-before-super
172 | "no-this-before-super": 1,
173 |
174 | // Disallow the use of undeclared variables unless mentioned in `/*global */` comments
175 | // https://eslint.org/docs/latest/rules/no-undef
176 | // TODO: At-risk; subject to change.
177 | "no-undef": 1,
178 |
179 | // Disallow confusing multiline expressions
180 | // https://eslint.org/docs/latest/rules/no-unexpected-multiline
181 | // TODO: At-risk; subject to change.
182 | "no-unexpected-multiline": 1,
183 |
184 | // Disallow unreachable code after `return`, `throw`, `continue`, and `break` statements
185 | // https://eslint.org/docs/latest/rules/no-unreachable
186 | "no-unreachable": 1,
187 |
188 | // Disallow control flow statements in `finally` blocks
189 | // https://eslint.org/docs/latest/rules/no-unsafe-finally
190 | "no-unsafe-finally": 1,
191 |
192 | // Disallow negating the left operand of relational operators
193 | // https://eslint.org/docs/latest/rules/no-unsafe-negation
194 | "no-unsafe-negation": 1,
195 |
196 | // Disallow use of optional chaining in contexts where the `undefined` value is not allowed
197 | // https://eslint.org/docs/latest/rules/no-unsafe-optional-chaining
198 | "no-unsafe-optional-chaining": 1,
199 |
200 | // Disallow unused labels
201 | // https://eslint.org/docs/latest/rules/no-unused-labels
202 | "no-unused-labels": 1,
203 |
204 | // Disallow useless backreferences in regular expressions
205 | // https://eslint.org/docs/latest/rules/no-useless-backreference
206 | "no-useless-backreference": 1,
207 |
208 | // Disallow unnecessary calls to `.call()` and `.apply()`
209 | // https://eslint.org/docs/latest/rules/no-useless-call
210 | "no-useless-call": 1,
211 |
212 | // Disallow unnecessary `catch` clauses
213 | // https://eslint.org/docs/latest/rules/no-useless-catch
214 | "no-useless-catch": 1,
215 |
216 | // Disallow unnecessary escape characters
217 | // https://eslint.org/docs/latest/rules/no-useless-escape
218 | "no-useless-escape": 1,
219 |
220 | // Disallow `with` statements
221 | // https://eslint.org/docs/latest/rules/no-with
222 | "no-with": 1,
223 |
224 | // Require generator functions to contain `yield`
225 | // https://eslint.org/docs/latest/rules/require-yield
226 | "require-yield": 1,
227 |
228 | // Require calls to `isNaN()` when checking for `NaN`
229 | // https://eslint.org/docs/latest/rules/use-isnan
230 | "use-isnan": 1,
231 |
232 | // Enforce comparing `typeof` expressions against valid strings
233 | // https://eslint.org/docs/latest/rules/valid-typeof
234 | "valid-typeof": 1,
235 |
236 | /**
237 | * ESLint Stylistic rules: https://eslint.style/packages/default#rules
238 | */
239 | // Enforce a space before and after `=>` in arrow functions
240 | // https://eslint.style/rules/default/arrow-spacing
241 | "@stylistic/arrow-spacing": 1,
242 |
243 | // Enforce consistent brace style for blocks
244 | // https://eslint.style/rules/default/brace-style
245 | "@stylistic/brace-style": [1, "stroustrup"],
246 |
247 | // Enforce trailing commas unless closing `]` or `}` is on the same line
248 | // https://eslint.style/rules/default/comma-dangle
249 | "@stylistic/comma-dangle": [1, "always-multiline"],
250 |
251 | // Enforce no space before and one or more spaces after a comma
252 | // https://eslint.style/rules/default/comma-spacing
253 | "@stylistic/comma-spacing": 1,
254 |
255 | // Require newline at the end of files
256 | // https://eslint.style/rules/default/eol-last
257 | "@stylistic/eol-last": 1,
258 |
259 | // Enforce consistent indentation
260 | // https://eslint.style/rules/default/indent
261 | "@stylistic/indent": [1, "tab", { SwitchCase: 1, outerIIFEBody: 0 }],
262 |
263 | // Enforce consistent spacing before and after keywords
264 | // https://eslint.style/rules/default/keyword-spacing
265 | "@stylistic/keyword-spacing": 1,
266 |
267 | // Disallow unnecessary semicolons
268 | // https://eslint.style/rules/default/no-extra-semi
269 | "@stylistic/no-extra-semi": 1,
270 |
271 | // Disallow mixed spaces and tabs for indentation
272 | // https://eslint.style/rules/default/no-mixed-spaces-and-tabs
273 | "@stylistic/no-mixed-spaces-and-tabs": [1, "smart-tabs"],
274 |
275 | // Disallow trailing whitespace at the end of lines
276 | // https://eslint.style/rules/default/no-trailing-spaces
277 | "@stylistic/no-trailing-spaces": 1,
278 |
279 | // Enforce the consistent use of double quotes
280 | // https://eslint.style/rules/default/quotes
281 | "@stylistic/quotes": [
282 | 1,
283 | "double",
284 | { avoidEscape: true, allowTemplateLiterals: true },
285 | ],
286 |
287 | // Require semicolons instead of ASI
288 | // https://eslint.style/rules/default/semi
289 | "@stylistic/semi": 1,
290 |
291 | // Enforce at least one space before blocks
292 | // https://eslint.style/rules/default/space-before-blocks
293 | "@stylistic/space-before-blocks": 1,
294 |
295 | // Enforce a space before `function` definition opening parenthesis
296 | // https://eslint.style/rules/default/space-before-function-paren
297 | "@stylistic/space-before-function-paren": 1,
298 |
299 | // Require spaces around infix operators (e.g. `+`, `=`, `?`, `:`)
300 | // https://eslint.style/rules/default/space-infix-ops
301 | "@stylistic/space-infix-ops": 1,
302 |
303 | // Enforce a space after unary word operators (`new`, `delete`, `typeof`, `void`, `yield`)
304 | // https://eslint.style/rules/default/space-unary-ops
305 | "@stylistic/space-unary-ops": 1,
306 |
307 | // Enforce whitespace after the `//` or `/*` in a comment
308 | // https://eslint.style/rules/default/spaced-comment
309 | "@stylistic/spaced-comment": [
310 | 1,
311 | "always",
312 | { block: { exceptions: ["*"] } },
313 | ],
314 | },
315 | },
316 | ];
317 |
--------------------------------------------------------------------------------
/src/color-chart/color-chart.js:
--------------------------------------------------------------------------------
1 | import "../color-scale/color-scale.js";
2 | import "../channel-picker/channel-picker.js";
3 | import ColorElement from "../common/color-element.js";
4 |
5 | const Self = class ColorChart extends ColorElement {
6 | static tagName = "color-chart";
7 | static url = import.meta.url;
8 | static styles = "./color-chart.css";
9 | static globalStyles = "./color-chart-global.css";
10 | static shadowTemplate = `
11 |
12 |
13 |
14 |
19 |
23 | `;
27 | static dependencies = new Set(["color-scale"]);
28 |
29 | constructor () {
30 | super();
31 |
32 | this._el = {
33 | slot: this.shadowRoot.querySelector("slot:not([name])"),
34 | channel_picker: this.shadowRoot.getElementById("channel_picker"),
35 | chart: this.shadowRoot.getElementById("chart"),
36 | xTicks: this.shadowRoot.querySelector("#x_axis .ticks"),
37 | yTicks: this.shadowRoot.querySelector("#y_axis .ticks"),
38 | xLabel: this.shadowRoot.querySelector("#x_axis .label"),
39 | yLabel: this.shadowRoot.querySelector("#y_axis .label"),
40 | };
41 |
42 | this._slots = {
43 | color_channel: this.shadowRoot.querySelector("slot[name=color-channel]"),
44 | };
45 | }
46 |
47 | connectedCallback () {
48 | super.connectedCallback();
49 | this._el.chart.addEventListener("colorschange", this, { capture: true });
50 | this._slots.color_channel.addEventListener("input", this);
51 | }
52 |
53 | disconnectedCallback () {
54 | this._el.chart.removeEventListener("colorschange", this, { capture: true });
55 | this._slots.color_channel.removeEventListener("input", this);
56 | }
57 |
58 | handleEvent (evt) {
59 | let source = evt.target;
60 |
61 | if (source.tagName === "COLOR-SCALE" && evt.name === "computedColors") {
62 | // TODO check if changed
63 | this.render(evt);
64 | }
65 |
66 | if (
67 | this._el.channel_picker === source ||
68 | this._slots.color_channel.assignedElements().includes(source)
69 | ) {
70 | this.y = source.value;
71 | }
72 | }
73 |
74 | series = new WeakMap();
75 |
76 | bounds = { x: { min: Infinity, max: -Infinity }, y: { min: Infinity, max: -Infinity } };
77 |
78 | // Follow the provided min/max values, if any
79 | get force () {
80 | const ctx = this;
81 |
82 | return {
83 | x: {
84 | get min () {
85 | return !isNaN(ctx.xMin);
86 | },
87 | get max () {
88 | return !isNaN(ctx.xMax);
89 | },
90 | },
91 | y: {
92 | get min () {
93 | return !isNaN(ctx.yMin);
94 | },
95 | get max () {
96 | return !isNaN(ctx.yMax);
97 | },
98 | },
99 | };
100 | }
101 |
102 | render (evt) {
103 | this.renderScales(evt);
104 | this.renderAxis("x");
105 | this.renderAxis("y");
106 | }
107 |
108 | /**
109 | * (Re)render one of the axes
110 | * @param {string} axis "x" or "y"
111 | */
112 | renderAxis (axis) {
113 | axis = axis.toLowerCase();
114 |
115 | let min = this[`${axis}MinAsNumber`];
116 | if (isNaN(min) || !isFinite(min)) {
117 | // auto, undefined, etc
118 | min = this.bounds[axis].min;
119 | }
120 |
121 | let max = this[`${axis}MaxAsNumber`];
122 | if (isNaN(max) || !isFinite(max)) {
123 | // auto, undefined, etc
124 | max = this.bounds[axis].max;
125 | }
126 |
127 | if (isFinite(min) && isFinite(max)) {
128 | const axisData = getAxis({
129 | min,
130 | max,
131 | initialSteps: 10,
132 | force: this.force[axis],
133 | });
134 |
135 | this._el.chart.style.setProperty(`--min-${axis}`, axisData.min);
136 | this._el.chart.style.setProperty(`--max-${axis}`, axisData.max);
137 | this._el.chart.style.setProperty(`--steps-${axis}`, axisData.steps);
138 |
139 | const tickElements = Array(axisData.steps)
140 | .fill()
141 | .map(
142 | (_, i) =>
143 | `${+(axisData.min + i * axisData.step).toPrecision(15)}
`,
144 | );
145 |
146 | let ticksEl = this._el[`${axis}Ticks`];
147 | ticksEl.innerHTML =
148 | axis === "y" ? tickElements.toReversed().join("\n") : tickElements.join("\n");
149 | }
150 |
151 | let resolved = this[`${axis}Resolved`];
152 | let labelEl = this._el[`${axis}Label`];
153 | // Set axis label if we have a custom coordinate
154 | labelEl.textContent = resolved ? this.space.name + " " + resolved.name : "";
155 | }
156 |
157 | /** (Re)render all scales */
158 | renderScales (evt) {
159 | let colorScales = this.querySelectorAll("color-scale");
160 |
161 | if (colorScales.length === 0 || evt.name === "computedColors") {
162 | this.bounds = {
163 | x: { min: Infinity, max: -Infinity },
164 | y: { min: Infinity, max: -Infinity },
165 | };
166 |
167 | if (colorScales.length === 0) {
168 | return;
169 | }
170 | }
171 |
172 | for (let colorScale of colorScales) {
173 | let scale = this.series.get(colorScale);
174 |
175 | if (
176 | !scale ||
177 | !evt ||
178 | evt.target === colorScale ||
179 | evt.target.nodeName !== "COLOR-SCALE"
180 | ) {
181 | scale = this.renderScale(colorScale);
182 |
183 | if (scale) {
184 | if (scale.x.min < this.bounds.x.min) {
185 | this.bounds.x.min = scale.x.min;
186 | }
187 | if (scale.x.max > this.bounds.x.max) {
188 | this.bounds.x.max = scale.x.max;
189 | }
190 | if (scale.y.min < this.bounds.y.min) {
191 | this.bounds.y.min = scale.y.min;
192 | }
193 | if (scale.y.max > this.bounds.y.max) {
194 | this.bounds.y.max = scale.y.max;
195 | }
196 | }
197 | }
198 | }
199 | }
200 |
201 | /** (Re)render one scale */
202 | renderScale (colorScale) {
203 | if (!colorScale.computedColors) {
204 | // Not yet initialized
205 | return;
206 | }
207 |
208 | let ret = {
209 | element: colorScale,
210 | swatches: new WeakMap(),
211 | x: { min: Infinity, max: -Infinity, values: new WeakMap() },
212 | y: { min: Infinity, max: -Infinity, values: new WeakMap() },
213 | colors: colorScale.computedColors?.slice() ?? [],
214 | };
215 |
216 | colorScale.style.setProperty("--color-count", ret.colors.length);
217 |
218 | // Helper function to calculate coordinates for an axis
219 | const calculateAxisCoords = axis => {
220 | const resolved = this[`${axis}Resolved`];
221 | if (!resolved) {
222 | return null;
223 | }
224 |
225 | const coords = ret.colors.map(({ color }) => color.to(this.space).get(this[axis]));
226 | const min = this[`${axis}MinAsNumber`];
227 | const max = this[`${axis}MaxAsNumber`];
228 |
229 | if (resolved.type === "angle") {
230 | // First, normalize
231 | return { coords: normalizeAngles(coords), min, max };
232 | }
233 |
234 | return { coords, min, max };
235 | };
236 |
237 | const yAxis = calculateAxisCoords("y");
238 | const xAxis = calculateAxisCoords("x");
239 |
240 | let index = 0;
241 | for (let { name, color } of ret.colors) {
242 | let swatch = colorScale._el.swatches.children[index];
243 | ret.colors[index] = color = color.to(this.space);
244 | ret.swatches.set(color, swatch);
245 |
246 | let x;
247 | if (xAxis) {
248 | // Use the calculated X coordinate from the specified coordinate
249 | x = xAxis.coords[index];
250 | }
251 | else {
252 | // It's not always possible to use the last number in the color label as the X-coord;
253 | // for example, the number "9" can't be interpreted as the X-coord in the "#90caf9" label.
254 | // It might cause bugs with color order (see https://github.com/color-js/elements/issues/103).
255 | // We expect the valid X-coord to be the only number in the color label (e.g., 50)
256 | // or separated from the previous text with a space (e.g., Red 50 or Red / 50).
257 | x = name.match(/(?:^|\s)-?\d*\.?\d+$/)?.[0];
258 | if (x !== undefined) {
259 | // Transform `Label / X-coord` to `Label`
260 | // (there should be at least one space before and after the slash so the number is treated as an X-coord)
261 | let label = name.slice(0, -x.length).trim();
262 | if (label.endsWith("/")) {
263 | name = label.slice(0, -1).trim();
264 | }
265 |
266 | swatch.label = name;
267 |
268 | x = Number(x);
269 | }
270 | else {
271 | x = index;
272 | }
273 | }
274 |
275 | let y = yAxis.coords[index];
276 |
277 | ret.x.values.set(color, x);
278 | ret.y.values.set(color, y);
279 |
280 | const yOutOfRange =
281 | (isFinite(yAxis.min) && y < yAxis.min) || (isFinite(yAxis.max) && y > yAxis.max);
282 | const xOutOfRange = xAxis
283 | ? (isFinite(xAxis.min) && x < xAxis.min) || (isFinite(xAxis.max) && x > xAxis.max)
284 | : false;
285 | const outOfRange = yOutOfRange || xOutOfRange;
286 |
287 | if (!outOfRange) {
288 | // Only swatches that are in range participate in the min/max calculation
289 | ret.x.min = Math.min(ret.x.min, x);
290 | ret.x.max = Math.max(ret.x.max, x);
291 | ret.y.min = Math.min(ret.y.min, y);
292 | ret.y.max = Math.max(ret.y.max, y);
293 | }
294 |
295 | swatch.style.setProperty("--x", x);
296 | swatch.style.setProperty("--y", y);
297 | swatch.style.setProperty("--index", index);
298 |
299 | if (
300 | HTMLElement.prototype.hasOwnProperty("popover") &&
301 | !swatch._el.wrapper.hasAttribute("popover")
302 | ) {
303 | // The Popover API is supported
304 | let popover = swatch._el.wrapper;
305 | popover.setAttribute("popover", "");
306 |
307 | // We need these for the popover to be correctly activated and positioned,
308 | // otherwise, it won't be on the top layer
309 | swatch.addEventListener("pointerenter", evt => {
310 | // Position the popover relative to the parent swatch
311 | // (instead of the center of the viewport by default)
312 | let rect = swatch.getBoundingClientRect();
313 | popover.style.setProperty("--_popover-left", rect.left + rect.width / 2 + "px");
314 | popover.style.setProperty("--_popover-top", rect.top - rect.height / 2 + "px");
315 |
316 | popover.showPopover();
317 | });
318 | swatch.addEventListener("pointerleave", evt => popover.hidePopover());
319 | }
320 |
321 | index++;
322 | }
323 |
324 | // Sort colors by X (ascending)
325 | ret.colors.sort((a, b) => ret.x.values.get(a) - ret.x.values.get(b));
326 |
327 | let prevColor;
328 | for (let color of ret.colors) {
329 | let swatch = ret.swatches.get(color);
330 |
331 | if (prevColor !== undefined) {
332 | prevColor.style.setProperty(
333 | "--next-color",
334 | swatch.style.getPropertyValue("--color"),
335 | );
336 | prevColor.style.setProperty("--next-x", ret.x.values.get(color));
337 | prevColor.style.setProperty("--next-y", ret.y.values.get(color));
338 | }
339 |
340 | prevColor = swatch;
341 | }
342 |
343 | if (prevColor !== undefined) {
344 | // When we update colors, and we have fewer colors than before,
345 | // we need to make sure the last swatch is not connected to the non-existent next swatch
346 | ["--next-color", "--next-x", "--next-y"].forEach(prop =>
347 | prevColor.style.removeProperty(prop));
348 | }
349 |
350 | this.series.set(colorScale, ret);
351 |
352 | return ret;
353 | }
354 |
355 | propChangedCallback (evt) {
356 | let { name, prop, detail: change } = evt;
357 |
358 | if (name.startsWith("x") || name.startsWith("y")) {
359 | let axis = name[0];
360 |
361 | if (!/^[xy](?:Resolved|(?:Min|Max)AsNumber)$/.test(name)) {
362 | return;
363 | }
364 |
365 | this.render(evt);
366 | }
367 |
368 | if (name === "info") {
369 | for (let colorScale of this.children) {
370 | colorScale.info = this.info;
371 | }
372 | }
373 | }
374 |
375 | static props = {
376 | y: {
377 | default: "oklch.l",
378 | changed (change) {
379 | this.bounds.y = { min: Infinity, max: -Infinity };
380 | },
381 | convert (value) {
382 | // Try setting the value to the channel picker. The picker will handle possible erroneous values.
383 | this._el.channel_picker.value = value;
384 |
385 | // If the value is not set, that means it's invalid.
386 | // In that case, we are falling back to the picker's current value.
387 | if (this._el.channel_picker.value !== value) {
388 | return this._el.channel_picker.value;
389 | }
390 |
391 | return value;
392 | },
393 | },
394 |
395 | yResolved: {
396 | get () {
397 | return Self.Color.Space.resolveCoord(this.y, "oklch");
398 | },
399 | },
400 |
401 | yMin: {
402 | default: "auto",
403 | changed (change) {
404 | let { value } = change;
405 |
406 | if (value === "auto") {
407 | this.bounds.y.min = Infinity;
408 | }
409 | },
410 | reflect: {
411 | from: "ymin",
412 | },
413 | },
414 |
415 | yMinAsNumber: {
416 | get () {
417 | if (this.yMin === "coord") {
418 | let range = this.yResolved.refRange ?? this.yResolved.range ?? [0, 100];
419 | return range[0];
420 | }
421 | else if (this.yMin === "auto") {
422 | return this.bounds.y.min;
423 | }
424 |
425 | return Number(this.yMin);
426 | },
427 | set (value) {
428 | value = Number(value);
429 |
430 | if (Number.isNaN(value)) {
431 | this.yMin = "auto";
432 | }
433 | else {
434 | this.yMin = value.toString();
435 | }
436 | },
437 | },
438 |
439 | yMax: {
440 | default: "auto",
441 | changed (change) {
442 | let { value } = change;
443 |
444 | if (value === "auto") {
445 | this.bounds.y.max = -Infinity;
446 | }
447 | },
448 | reflect: {
449 | from: "ymax",
450 | },
451 | },
452 |
453 | yMaxAsNumber: {
454 | get () {
455 | if (this.yMax === "coord") {
456 | let range = this.yResolved.refRange ?? this.yResolved.range ?? [0, 100];
457 | return range[1];
458 | }
459 | else if (this.yMax === "auto") {
460 | return this.bounds.y.max;
461 | }
462 |
463 | return Number(this.yMax);
464 | },
465 | set (value) {
466 | value = Number(value);
467 |
468 | if (Number.isNaN(value)) {
469 | this.yMax = "auto";
470 | }
471 | else {
472 | this.yMax = value.toString();
473 | }
474 | },
475 | },
476 |
477 | x: {
478 | default: null,
479 | changed (change) {
480 | this.bounds.x = { min: Infinity, max: -Infinity };
481 | },
482 | },
483 |
484 | xResolved: {
485 | get () {
486 | return this.x ? Self.Color.Space.resolveCoord(this.x, "oklch") : null;
487 | },
488 | },
489 |
490 | xMin: {
491 | default: "auto",
492 | changed (change) {
493 | let { value } = change;
494 |
495 | if (value === "auto") {
496 | this.bounds.x.min = Infinity;
497 | }
498 | },
499 | reflect: {
500 | from: "xmin",
501 | },
502 | },
503 |
504 | xMinAsNumber: {
505 | get () {
506 | let force = this.force;
507 |
508 | if (this.x && this.xMin === "coord") {
509 | let range = this.xResolved?.refRange ?? this.xResolved?.range ?? [0, 100];
510 | return range[0];
511 | }
512 | else if ((!this.x && !force.x.min) || this.xMin === "auto") {
513 | return this.bounds.x.min;
514 | }
515 |
516 | return Number(this.xMin);
517 | },
518 | set (value) {
519 | value = Number(value);
520 |
521 | if (Number.isNaN(value)) {
522 | this.xMin = "auto";
523 | }
524 | else {
525 | this.xMin = value.toString();
526 | }
527 | },
528 | },
529 |
530 | xMax: {
531 | default: "auto",
532 | changed (change) {
533 | let { value } = change;
534 |
535 | if (value === "auto") {
536 | this.bounds.x.max = -Infinity;
537 | }
538 | },
539 | reflect: {
540 | from: "xmax",
541 | },
542 | },
543 |
544 | xMaxAsNumber: {
545 | get () {
546 | let force = this.force;
547 |
548 | if (this.x && this.xMax === "coord") {
549 | let range = this.xResolved?.refRange ?? this.xResolved?.range ?? [0, 100];
550 | return range[1];
551 | }
552 | else if ((!this.x && !force.x.max) || this.xMax === "auto") {
553 | return this.bounds.x.max;
554 | }
555 |
556 | return Number(this.xMax);
557 | },
558 | set (value) {
559 | value = Number(value);
560 |
561 | if (Number.isNaN(value)) {
562 | this.xMax = "auto";
563 | }
564 | else {
565 | this.xMax = value.toString();
566 | }
567 | },
568 | },
569 |
570 | space: {
571 | default: "oklch",
572 | get () {
573 | return this.yResolved.space;
574 | },
575 | },
576 |
577 | info: {},
578 | };
579 | };
580 |
581 | Self.define();
582 |
583 | export default Self;
584 |
585 | function getAxis ({ min, max, initialSteps, force = { min: false, max: false } }) {
586 | let range = max - min;
587 | let step = range / initialSteps;
588 | let magnitude = Math.floor(Math.log10(step));
589 | let base = Math.pow(10, magnitude);
590 | let candidates = [base, base * 2, base * 5, base * 10];
591 |
592 | for (let i = 0; i < candidates.length; i++) {
593 | if (candidates[i] > step) {
594 | step = candidates[i];
595 | break;
596 | }
597 | }
598 |
599 | if (force === true) {
600 | force = { min: true, max: true };
601 | }
602 |
603 | let start = force.min ? min : Math.floor(min / step) * step;
604 | let end = force.max ? max : Math.ceil(max / step) * step;
605 | let steps = Math.round((end - start) / step);
606 |
607 | let ret = { min: start, max: end, step, steps };
608 | for (let prop in ret) {
609 | ret[prop] = +ret[prop].toPrecision(15);
610 | }
611 | return ret;
612 | }
613 |
614 | function normalizeAngles (angles) {
615 | // First, normalize
616 | angles = angles.map(h => ((h % 360) + 360) % 360);
617 |
618 | // Remove top and bottom 25% and find average
619 | let averageHue =
620 | angles
621 | .toSorted((a, b) => a - b)
622 | .slice(angles.length / 4, -angles.length / 4)
623 | .reduce((a, b) => a + b, 0) / angles.length;
624 |
625 | for (let i = 0; i < angles.length; i++) {
626 | let h = angles[i];
627 | let prevHue = angles[i - 1];
628 | let delta = h - prevHue;
629 |
630 | if (Math.abs(delta) > 180) {
631 | let equivalent = [h + 360, h - 360];
632 | // Offset hue to minimize difference in the direction that brings it closer to the average
633 | if (Math.abs(equivalent[0] - averageHue) <= Math.abs(equivalent[1] - averageHue)) {
634 | angles[i] = equivalent[0];
635 | }
636 | else {
637 | angles[i] = equivalent[1];
638 | }
639 | }
640 | }
641 |
642 | return angles;
643 | }
644 |
--------------------------------------------------------------------------------