├── _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 |
9 | {% for name, description in components -%} 10 | 11 |
12 | A screenshot showcasing the <{{ name }}> color element 13 |
14 |

<{{ name }}>

15 |

{{ description | safe }}

16 |
17 |
18 |
19 | {% endfor %} 20 |
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 | 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 |
9 |
10 | 11 |
`; 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 | 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. | 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(`(?<=)`, "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 |
51 | oklch(% % ) 52 |

53 |

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]) => ``) 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 | 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 | 85 | 88 | 91 |
92 |
93 | 94 | 98 | 99 | 104 | ``` 105 | 106 | ```html 107 | 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 ``; 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 | ``, 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 | ``, 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 | 72 | 73 | ``` 74 | 75 | `` plays nicely with other color elements: 76 | ```html 77 | 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 ` 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 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 `