DEV: Introduce ColorPaletteEditor component

This commit is contained in:
OsamaSayegh
2025-02-13 13:04:30 +03:00
parent 820c8cb119
commit a1849cd396
8 changed files with 657 additions and 12 deletions

View File

@@ -0,0 +1,186 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import concatClass from "discourse/helpers/concat-class";
import dIcon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
const LIGHT = "light";
const DARK = "dark";
class Color {
@tracked lightValue;
@tracked darkValue;
constructor({ name, lightValue, darkValue }) {
this.name = name;
this.lightValue = lightValue;
this.darkValue = darkValue;
}
get displayName() {
return this.name.replaceAll("_", " ");
}
get description() {
return i18n(`admin.customize.colors.${this.name}.description`);
}
}
const NavTab = <template>
<li>
<a
class={{concatClass "" (if @active "active")}}
tabindex="0"
{{on "click" @action}}
{{on "keydown" @action}}
...attributes
>
{{dIcon @icon}}
<span>{{@label}}</span>
</a>
</li>
</template>;
const Picker = class extends Component {
@action
onInput(event) {
const color = event.target.value.replace("#", "");
if (this.args.showDark) {
this.args.color.darkValue = color;
} else {
this.args.color.lightValue = color;
}
}
@action
onChange(event) {
const color = event.target.value.replace("#", "");
if (this.args.showDark) {
this.args.onDarkChange(color);
this.args.color.darkValue = color;
} else {
this.args.color.lightValue = color;
this.args.onLightChange(color);
}
}
get displayedColor() {
if (this.args.showDark) {
return this.args.color.darkValue;
} else {
return this.args.color.lightValue;
}
}
get activeValue() {
let color;
if (this.args.showDark) {
color = this.args.color.darkValue;
} else {
color = this.args.color.lightValue;
}
if (color) {
return `#${color}`;
}
}
<template>
<input
class="color-palette-editor__input"
type="color"
value={{this.activeValue}}
{{on "input" this.onInput}}
{{on "change" this.onChange}}
/>
{{dIcon "hashtag"}}
<span
class="color-palette-editor__color-code"
>{{this.displayedColor}}</span>
</template>
};
export default class ColorPaletteEditor extends Component {
@tracked selectedMode;
get currentMode() {
return this.selectedMode ?? this.args.initialMode ?? LIGHT;
}
get lightModeActive() {
return this.currentMode === LIGHT;
}
get darkModeActive() {
return this.currentMode === DARK;
}
get colors() {
return this.args.colors.map((color) => {
return new Color({
name: color.name,
lightValue: color.hex,
darkValue: color.dark_hex,
});
});
}
@action
changeMode(newMode, event) {
if (
event.type === "click" ||
(event.type === "keydown" && event.keyCode === 13)
) {
this.selectedMode = newMode;
}
}
<template>
<div class="color-palette-editor">
<div class="nav-pills color-palette-editor__nav-pills">
<NavTab
@active={{this.lightModeActive}}
@action={{fn this.changeMode LIGHT}}
@icon="sun"
@label={{i18n "admin.customize.colors.editor.light"}}
class="light-tab"
/>
<NavTab
@active={{this.darkModeActive}}
@action={{fn this.changeMode DARK}}
@icon="moon"
@label={{i18n "admin.customize.colors.editor.dark"}}
class="dark-tab"
/>
</div>
<div class="color-palette-editor__colors-list">
{{#each this.colors as |color|}}
<div
data-color-name={{color.name}}
class="color-palette-editor__colors-item"
>
<div class="color-palette-editor__color-info">
<div class="color-palette-editor__color-description">
{{color.description}}
</div>
<div class="color-palette-editor__color-name">
{{color.displayName}}
</div>
</div>
<div class="color-palette-editor__picker">
<Picker
@color={{color}}
@showDark={{this.darkModeActive}}
@onLightChange={{fn @onLightColorChange color.name}}
@onDarkChange={{fn @onDarkColorChange color.name}}
/>
</div>
</div>
{{/each}}
</div>
</div>
</template>
}

View File

@@ -0,0 +1,388 @@
import { click, find, render, triggerEvent } from "@ember/test-helpers";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import ColorPaletteEditor from "admin/components/color-palette-editor";
function editor() {
return {
isActiveModeLight() {
return this.lightModeNavPill().classList.contains("active");
},
isActiveModeDark() {
return this.darkModeNavPill().classList.contains("active");
},
lightModeNavPill() {
return this.navPills().querySelector(".light-tab");
},
darkModeNavPill() {
return this.navPills().querySelector(".dark-tab");
},
navPills() {
return find(".color-palette-editor__nav-pills");
},
async switchToLightTab() {
await click(this.lightModeNavPill());
},
async switchToDarkTab() {
await click(this.darkModeNavPill());
},
color(name) {
return {
container() {
return find(
`.color-palette-editor__colors-item[data-color-name="${name}"]`
);
},
displayedValue() {
return this.container().querySelector(
".color-palette-editor__color-code"
).textContent;
},
displayName() {
return this.container()
.querySelector(".color-palette-editor__color-name")
.textContent.trim();
},
description() {
return this.container()
.querySelector(".color-palette-editor__color-description")
.textContent.trim();
},
input() {
return this.container().querySelector(".color-palette-editor__input");
},
async sendInputEvent(value) {
const input = this.input();
input.value = value;
await triggerEvent(input, "input");
},
async sendChangeEvent(value) {
const input = this.input();
input.value = value;
await triggerEvent(input, "change");
},
};
},
};
}
module("Integration | Component | ColorPaletteEditor", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
this.subject = editor();
});
test("switching between light and dark modes", async function (assert) {
const colors = [
{
name: "primary",
hex: "aaaaaa",
dark_hex: "1e3c8a",
},
{
name: "header_background",
hex: "473921",
dark_hex: "f2cca9",
},
];
await render(<template>
<ColorPaletteEditor @colors={{colors}} />
</template>);
assert.true(
this.subject.isActiveModeLight(),
"light mode tab is active by default"
);
assert.false(
this.subject.isActiveModeDark(),
"dark mode tab is not active by default"
);
assert.strictEqual(
this.subject.color("primary").input().value,
"#aaaaaa",
"input for the primary color is showing the light color"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"aaaaaa",
"displayed value for the primary color is showing the light color"
);
assert.strictEqual(
this.subject.color("header_background").input().value,
"#473921",
"input for the header_background color is showing the light color"
);
assert.strictEqual(
this.subject.color("header_background").displayedValue(),
"473921",
"displayed value for the header_background color is showing the light color"
);
await this.subject.switchToDarkTab();
assert.false(
this.subject.isActiveModeLight(),
"light mode tab is now inactive"
);
assert.true(this.subject.isActiveModeDark(), "dark mode tab is now active");
assert.strictEqual(
this.subject.color("primary").input().value,
"#1e3c8a",
"input for the primary color is showing the dark color"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"1e3c8a",
"displayed value for the primary color is showing the dark color"
);
assert.strictEqual(
this.subject.color("header_background").input().value,
"#f2cca9",
"input for the header_background color is showing the dark color"
);
assert.strictEqual(
this.subject.color("header_background").displayedValue(),
"f2cca9",
"displayed value for the header_background color is showing the dark color"
);
});
test("replacing underscores in color name with spaces for display", async function (assert) {
const colors = [
{
name: "my_awesome_color",
hex: "aaaaaa",
dark_hex: "1e3c8a",
},
{
name: "header_background",
hex: "473921",
dark_hex: "f2cca9",
},
];
await render(<template>
<ColorPaletteEditor @colors={{colors}} />
</template>);
assert.strictEqual(
this.subject.color("my_awesome_color").displayName(),
"my awesome color"
);
assert.strictEqual(
this.subject.color("header_background").displayName(),
"header background"
);
});
test("modifying colors", async function (assert) {
const colors = [
{
name: "primary",
hex: "aaaaaa",
dark_hex: "1e3c8a",
},
{
name: "header_background",
hex: "473921",
dark_hex: "f2cca9",
},
];
const lightChanges = [];
const darkChanges = [];
const onLightColorChange = (name, value) => {
lightChanges.push([name, value]);
};
const onDarkColorChange = (name, value) => {
darkChanges.push([name, value]);
};
await render(<template>
<ColorPaletteEditor
@colors={{colors}}
@onLightColorChange={{onLightColorChange}}
@onDarkColorChange={{onDarkColorChange}}
/>
</template>);
await this.subject.color("primary").sendInputEvent("#abcdef");
assert.strictEqual(
this.subject.color("primary").input().value,
"#abcdef",
"the input element for the primary color changes its value for `input` events"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"abcdef",
"displayed value for the primary color updates for `input` events"
);
assert.strictEqual(
lightChanges.length,
0,
"light color change callbacks aren't triggered for `input` events"
);
assert.strictEqual(
darkChanges.length,
0,
"dark color change callbacks aren't triggered for `input` events"
);
await this.subject.color("primary").sendChangeEvent("#fedcba");
assert.strictEqual(
this.subject.color("primary").input().value,
"#fedcba",
"the input element for the primary color changes its value for `change` events"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"fedcba",
"displayed value for the primary color updates for `change` events"
);
assert.deepEqual(
lightChanges,
[["primary", "fedcba"]],
"light color change callbacks are triggered for `change` eventswhen the light color changes"
);
assert.strictEqual(
darkChanges.length,
0,
"dark color change callbacks aren't triggered for `change` events when the light color changes"
);
await this.subject.switchToDarkTab();
assert.strictEqual(
this.subject.color("primary").input().value,
"#1e3c8a",
"the dark color isn't affected by the change to the light color"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"1e3c8a",
"the dark color isn't affected by the change to the light color"
);
lightChanges.length = 0;
darkChanges.length = 0;
await this.subject.color("header_background").sendInputEvent("#776655");
assert.strictEqual(
this.subject.color("header_background").input().value,
"#776655",
"the input element for the header_background color changes its value for `input` events"
);
assert.strictEqual(
this.subject.color("header_background").displayedValue(),
"776655",
"displayed value for the header_background color updates for `input` events"
);
assert.strictEqual(
lightChanges.length,
0,
"light color change callbacks aren't triggered for `input` events"
);
assert.strictEqual(
darkChanges.length,
0,
"dark color change callbacks aren't triggered for `input` events"
);
await this.subject.color("header_background").sendChangeEvent("#99aaff");
assert.strictEqual(
this.subject.color("header_background").input().value,
"#99aaff",
"the input element for the header_background color changes its value for `change` events"
);
assert.strictEqual(
this.subject.color("header_background").displayedValue(),
"99aaff",
"displayed value for the header_background color updates for `change` events"
);
assert.deepEqual(
darkChanges,
[["header_background", "99aaff"]],
"dark color change callbacks are triggered for `change` eventswhen the dark color changes"
);
assert.strictEqual(
lightChanges.length,
0,
"light color change callbacks aren't triggered for `change` events when the dark color changes"
);
await this.subject.switchToLightTab();
assert.strictEqual(
this.subject.color("primary").input().value,
"#fedcba",
"the light color for the primary color is remembered after switching tabs"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"fedcba",
"the light color for the primary color is remembered after switching tabs"
);
assert.strictEqual(
this.subject.color("header_background").input().value,
"#473921",
"the light color for the header_background color remains unchanged"
);
assert.strictEqual(
this.subject.color("header_background").displayedValue(),
"473921",
"the light color for the header_background color remains unchanged"
);
await this.subject.switchToDarkTab();
assert.strictEqual(
this.subject.color("primary").input().value,
"#1e3c8a",
"the dark color for the primary color remains unchanged"
);
assert.strictEqual(
this.subject.color("primary").displayedValue(),
"1e3c8a",
"the dark color for the primary color remains unchanged"
);
assert.strictEqual(
this.subject.color("header_background").input().value,
"#99aaff",
"the dark color for the header_background color is remembered after switching tabs"
);
assert.strictEqual(
this.subject.color("header_background").displayedValue(),
"99aaff",
"the dark color for the header_background color is remembered after switching tabs"
);
});
});

View File

@@ -1242,3 +1242,4 @@ a.inline-editable-field {
@import "common/admin/schema_theme_setting_editor";
@import "common/admin/customize_themes_show_schema";
@import "common/admin/admin_bulk_users_delete_modal";
@import "common/admin/color-palette-editor";

View File

@@ -0,0 +1,61 @@
.color-palette-editor {
&__nav-pills {
display: flex;
margin-bottom: 1em;
li {
flex: 1 1 auto;
a {
width: 100%;
justify-content: center;
}
}
}
&__colors-list {
display: flex;
flex-direction: column;
gap: 1em;
}
&__colors-item {
display: flex;
align-items: flex-start;
gap: 4em;
}
&__color-info {
flex-grow: 1;
}
&__color-name {
color: var(--primary-medium);
font-size: var(--font-down-1);
}
&__picker {
display: flex;
justify-content: center;
align-items: center;
padding: 5px 10px;
gap: 5px;
border: 1px solid var(--primary-low);
.d-icon-hashtag {
color: var(--primary-high);
font-size: var(--font-down-2);
}
}
&__input {
width: 23px;
height: 23px;
}
&__color-code {
font-family: monospace;
font-size: var(--font-up-1);
color: var(--primary-high);
}
}

View File

@@ -206,8 +206,7 @@ input {
&[type="email"],
&[type="url"],
&[type="search"],
&[type="tel"],
&[type="color"] {
&[type="tel"] {
@include appearance-none;
@include form-item-sizing;
display: inline-block;
@@ -223,6 +222,22 @@ input {
}
}
&[type="color"] {
border: var(--d-input-border);
border-radius: var(--d-input-border-radius);
cursor: pointer;
padding: 0;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
border-radius: 0;
}
}
&[type="time"] {
max-width: 140px;
}

View File

@@ -22,14 +22,4 @@
margin-bottom: 0;
height: unset;
}
// Reset webkit/blink default style
input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 0; // Reset webkit specific style
}
}

View File

@@ -6346,6 +6346,9 @@ en:
hover:
name: "hover"
description: "The background-color of elements such as list-items when they are hovered on or have keyboard focus."
editor:
light: "Light"
dark: "Dark"
robots:
title: "Override your site's robots.txt file:"
warning: "This will permanently override any related site settings."

View File

@@ -162,6 +162,7 @@ module SvgSprite
grip-lines
hand-point-right
handshake-angle
hashtag
heart
hourglass-start
house