color-js / elements

WIP
14 stars 1 forks source link

[New WC] Color space dropdown #123

Open DmitrySharabin opened 1 month ago

DmitrySharabin commented 1 month ago

This component automatically gets populated with all color spaces (or a subset, e.g., polar or perceptually uniform, or even a custom subset).

<color-chart>, <color-scale>, and <color-picker> can benefit from it by integrating it and providing an easy way to choose (and dynamically change) the color space. Right now, it requires a lot of plumbing.

LeaVerou commented 1 month ago

Name brainstorming:

Wrt specifying the spaces:

  1. I think a good MVP would be all color spaces
  2. Going slightly beyond MVP, specific ids would be a good escape hatch for making custom things possible
  3. Going beyond that, some way to filter by coordinate name and/or type? :has(h)?
    • And that paves the way for other filters too down the line, e.g. :from(lab) for all spaces that descend from lab
  4. Partial ids? E.g. h*, *h basically encompasses all polar spaces, *lab encompasses both lab and oklab

The component should do double duty and function as a label for the color space, with clear affordances for clicking to change it. An MVP would be to just use a <select> + font: inherit.

DmitrySharabin commented 1 month ago

Name brainstorming:

  • <color-space> (too generic? is it possible we may want to do something else with this name?)
  • <color-space-picker> (too long?)

By analogy with <gamut-badge>, how about <space-picker>?

I already have a working MVP, so if we have a name, it would help.

DmitrySharabin commented 1 month ago

Wrt specifying the spaces:

  1. I think a good MVP would be all color spaces
  2. Going slightly beyond MVP, specific ids would be a good escape hatch for making custom things possible

I came up with this MVP (I use SpacePicker here for brevity; we can change it easily for whatever we want):

import ColorElement from "../common/color-element.js";

const Self = class SpacePicker extends ColorElement {
    static tagName = "space-picker";
    static url = import.meta.url;
    static shadowStyle = true;
    static shadowTemplate = `<select id="picker" part="picker"></select>`;

    constructor () {
        super();

        this._el = {};
        this._el.picker = this.shadowRoot.querySelector("#picker");
    }

    connectedCallback () {
        super.connectedCallback?.();
        this._el.picker.addEventListener("change", this);
    }

    disconnectedCallback () {
        super.disconnectedCallback?.();
        this._el.picker.removeEventListener("change", this);
    }

    handleEvent (event) {
        if (event.type === "change" && event.target === this._el.picker) {
            this.value = this._el.picker.value;
        }

        this.dispatchEvent(new event.constructor(event.type, {...event}));
    }

    propChangedCallback ({name, prop, detail: change}) {
        if (name === "spaces") {
            this._el.picker.innerHTML = Object.entries(this.spaces)
                .map(([id, space]) => `<option value="${id}">${space.name}</option>`)
                .join("\n");
        }

        if (name === "value" || name === "spaces") {
            if (this.value?.id !== this._el.picker.value) {
                if (this.value.id in this.spaces) {
                    this._el.picker.value = this.value.id;
                }
                else {
                    let currentValue = this.spaces[this._el.picker.value];
                    console.warn(`No color space with id = “${ this.value.id }” was found among the specified ones. Using the current one (${ currentValue.id }) instead.`);
                    this.value = currentValue;
                }
            }
        }
    }

    static props = {
        value: {
            default () {
                return Self.Color.Space.get(this._el.picker.value);
            },
            parse (value) {
                if (value instanceof Self.Color.Space || value === null || value === undefined) {
                    return value;
                }

                value += "";

                return Self.Color.Space.get(value);
            },
            stringify (value) {
                return value?.id;
            },
        },

        spaces: {
            type: {
                is: Object,
                get values () {
                    return Self.Color.Space;
                },
                defaultValue: (id, index) => {
                    try {
                        return Self.Color.Space.get(id);
                    }
                    catch (e) {
                        console.error(e);
                    }
                },
            },
            default: () => Self.Color.spaces,
            convert (value) {
                // Drop non-existing spaces
                return Object.fromEntries(Object.entries(value).filter(([id, space]) => space));
            },
            stringify (value) {
                return Object.entries(value).map(([id, space]) => id).join(", ");
            },
        },
    };

    static events = {
        change: {
            from () {
                return this._el.picker;
            },
        },
        spacechange: {
            propchange: "value",
        },
    };

    static formAssociated = {
        like: el => el._el.picker,
        role: "combobox",
        valueProp: "value",
        changeEvent: "spacechange",
    };
};

Self.define();

export default Self;
#picker {
    font: inherit;
    color: inherit;
    background: inherit;
    field-sizing: content;
    cursor: pointer;
}
DmitrySharabin commented 1 month ago

The spaces prop should probably be an array of color spaces (ids). Or maybe not. Hmm. 🤔 I'll try it tomorrow morning and see.

DmitrySharabin commented 4 weeks ago

The spaces prop should probably be an array of color spaces (ids). Or maybe not. Hmm. 🤔 I'll try it tomorrow morning and see.

Nah, it shouldn't. Having an object is more convenient and is aligned with the Color.spaces object.

DmitrySharabin commented 4 weeks ago

Here is a sneak peek of how it works:

https://github.com/user-attachments/assets/6a74a612-4ac5-4636-b26c-0051613a001e

I will send a PR once we decide on the component's name so we can iterate.

LeaVerou commented 4 weeks ago

I think <space-picker> is fine. We can call the other one <channel-picker>, analogously to <channel-slider>. Though alternatively, we could adopt a naming scheme where everything starts with <color-*>, so <gamut-badge> will become <color-gamut-badge>, I'm undecided if the brevity is worth it.

This is fine for an MVP, but eventually I would like to also support some common groupings too, e.g. to show polar & rectangular color spaces in separate <optgroup>s, or perceptually uniform and not, as well as a custom grouping, provided via a JS function. Perhaps the MVP could just have the JS prop (and not reflect it).

DmitrySharabin commented 4 weeks ago

This is fine for an MVP, but eventually I would like to also support some common groupings too, e.g. to show polar & rectangular color spaces in separate <optgroup>s, or perceptually uniform and not, as well as a custom grouping, provided via a JS function.

I was thinking of grouping, too (I examined what you did in your Color Palettes research), but I couldn't think of a default one, so I decided to let it go for now.

Perhaps the MVP could just have the JS prop (and not reflect it).

I want to make sure I get it right: the name of the prop is JS?

LeaVerou commented 4 weeks ago

No lol, what would that mean? What I meant was, the prop would only exist in JS, i.e. it would not reflect. The prop name would probably be something like group or groups or groupBy.

DmitrySharabin commented 4 weeks ago

No lol, what would that mean? What I meant was, the prop would only exist in JS, i.e. it would not reflect. The prop name would probably be something like group or groups or groupBy.

I thought so, but had to make sure 😂

DmitrySharabin commented 2 weeks ago

MVP is alive and available for experiments: https://elements.colorjs.io/src/space-picker/

sidewayss commented 3 days ago

I have two types of working homemade examples here: https://sidewayss.github.io/rAF/apps/color/

The two lists in the form are identical, the one on the right is a clone of the one on the left. The version inside the <color-picker> dialog is straight Colorjs, no CSS names (to open the dialog, click on the swatchy <div> at the right edge of the 2nd or 3rd row, labeled Start: and End:).

If you look at the code that loads the options into the lists:

The ability to include custom spaces depends on the timing of populating the list, especially if you want to sort the list.

Also note that in the two primary lists, the value attribute of each <option> is not set to the Colorjs color space id. That's a detail that is specific to this app, but something else to consider in the design of this new element: are there options for the way it is populated? Otherwise my app's code would continue to roll its own because it couldn't use this new element, except inside the picker dialog.

re names: maybe <color-spaces> or <space-select> since the demo uses a <select>.