mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
DEV: Introduce ColorPaletteEditor component
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -162,6 +162,7 @@ module SvgSprite
|
||||
grip-lines
|
||||
hand-point-right
|
||||
handshake-angle
|
||||
hashtag
|
||||
heart
|
||||
hourglass-start
|
||||
house
|
||||
|
||||
Reference in New Issue
Block a user