FEATURE: auto contrast text color for categories (#32015)

This change removes the foreground color category setting to simplify
the category creation and edit process for admins.

Instead we determine the highest contrasting color (either white or
black) based on the background color.

Contrast algorithm is based on:
https://www.w3.org/TR/AERT/#color-contrast

We also implement the value transformer as part of this change, which
allows overriding the category text color.
This commit is contained in:
David Battersby
2025-03-31 11:02:20 +04:00
committed by GitHub
parent d3c2bd015d
commit 15751c89f4
10 changed files with 111 additions and 34 deletions

View File

@ -63,7 +63,7 @@ export default class DStyles extends Component {
css.push(
`.badge-category[data-category-id="${category.id}"] { ` +
`--category-badge-color: var(--category-${category.id}-color); ` +
`--category-badge-text-color: #${category.text_color}; ` +
`--category-badge-text-color: #${category.textColor}; ` +
`}`
);

View File

@ -27,7 +27,6 @@ export default class EditCategoryGeneral extends Component {
customizeTextContentLink = getURL(
"/admin/customize/site_texts?q=uncategorized"
);
foregroundColors = ["FFFFFF", "000000"];
get styleTypes() {
return Object.keys(CATEGORY_STYLE_TYPES).map((key) => ({
@ -306,31 +305,6 @@ export default class EditCategoryGeneral extends Component {
</field.Custom>
</@form.Field>
{{/unless}}
<@form.Field
@name="text_color"
@title={{i18n "category.foreground_color"}}
@format="full"
as |field|
>
<field.Custom>
<div class="category-color-editor">
<div class="colorpicker-wrapper edit-text-color">
<ColorInput
@hexValue={{readonly field.value}}
@ariaLabelledby="foreground-color-label"
@onChangeColor={{fn this.updateColor field}}
/>
<ColorPicker
@colors={{this.foregroundColors}}
@value={{readonly field.value}}
@ariaLabel={{i18n "category.predefined_colors"}}
@onSelectColor={{fn this.updateColor field}}
/>
</div>
</div>
</field.Custom>
</@form.Field>
</@form.Section>
</div>
</template>

View File

@ -40,6 +40,7 @@ export default class EditCategoryTabsController extends Controller {
expandedMenu = false;
parentParams = null;
validators = [];
textColors = ["000000", "FFFFFF"];
@and("showTooltip", "model.cannot_delete_reason") showDeleteReason;
@ -120,6 +121,8 @@ export default class EditCategoryTabsController extends Controller {
}
this.model.setProperties(transientData);
this.setTextColor(this.model.color);
this.set("saving", true);
this.model
@ -184,4 +187,15 @@ export default class EditCategoryTabsController extends Controller {
goBack() {
DiscourseURL.routeTo(this.model.url);
}
@action
setTextColor(backgroundColor) {
const r = parseInt(backgroundColor.substr(0, 2), 16);
const g = parseInt(backgroundColor.substr(2, 2), 16);
const b = parseInt(backgroundColor.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
const color = brightness >= 128 ? this.textColors[0] : this.textColors[1];
this.model.set("text_color", color);
}
}

View File

@ -7,16 +7,16 @@ export default function categoryVariables(category) {
vars += `--category-badge-color: #${category.color};`;
}
if (category.text_color) {
vars += `--category-badge-text-color: #${category.text_color};`;
if (category.textColor) {
vars += `--category-badge-text-color: #${category.textColor};`;
}
if (category.parentCategory?.color) {
vars += `--parent-category-badge-color: #${category.parentCategory.color};`;
}
if (category.parentCategory?.text_color) {
vars += `--parent-category-badge-text-color: #${category.parentCategory.text_color};`;
if (category.parentCategory?.textColor) {
vars += `--parent-category-badge-text-color: #${category.parentCategory.textColor};`;
}
return htmlSafe(vars);

View File

@ -15,6 +15,7 @@ export const VALUE_TRANSFORMERS = Object.freeze([
"category-available-views",
"category-description-text",
"category-display-name",
"category-text-color",
"composer-service-cannot-submit-post",
"header-notifications-avatar-size",
"home-logo-href",

View File

@ -497,6 +497,16 @@ export default class Category extends RestModel {
});
}
get textColor() {
return applyValueTransformer(
"category-text-color",
this.get("text_color"),
{
category: this,
}
);
}
@computed("parent_category_id", "site.categories.[]")
get parentCategory() {
if (this.parent_category_id) {

View File

@ -26,8 +26,6 @@ acceptance("Category Edit", function (needs) {
await fillIn("input.category-name", "testing");
assert.dom(".category-style .badge-category__name").hasText("testing");
await fillIn(".edit-text-color input", "ff0000");
await click(".edit-category-topic-template a");
await fillIn(".d-editor-input", "this is the new topic template");

View File

@ -1,7 +1,9 @@
import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import sinon from "sinon";
import { cloneJSON } from "discourse/lib/object";
import DiscourseURL from "discourse/lib/url";
import { fixturesByUrl } from "discourse/tests/helpers/create-pretender";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { i18n } from "discourse-i18n";
@ -99,6 +101,59 @@ acceptance("Category New", function (needs) {
});
});
acceptance("Category text color", function (needs) {
needs.user();
needs.pretender((server, helper) => {
const category = cloneJSON(fixturesByUrl["/c/11/show.json"]).category;
server.get("/c/testing/find_by_slug.json", () => {
return helper.response(200, {
category: {
...category,
color: "EEEEEE",
text_color: "000000",
},
});
});
});
test("Category text color is set based on contrast", async function (assert) {
await visit("/new-category");
let previewTextColor = document
.querySelector(".category-style .badge-category__wrapper")
.style.getPropertyValue("--category-badge-text-color")
.trim();
assert.strictEqual(
previewTextColor,
"#FFFFFF",
"has the default text color"
);
await fillIn("input.category-name", "testing");
await fillIn(".category-color-editor .hex-input", "EEEEEE");
await click("#save-category");
assert.strictEqual(
currentURL(),
"/c/testing/edit/general",
"it transitions to the category edit route"
);
previewTextColor = document
.querySelector(".category-style .badge-category__wrapper")
.style.getPropertyValue("--category-badge-text-color")
.trim();
assert.strictEqual(
previewTextColor,
"#000000",
"sets the contrast text color"
);
});
});
acceptance("New category preview", function (needs) {
needs.user({ admin: true });

View File

@ -0,0 +1,26 @@
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("category-text-color transformer", function () {
test("applying a value transformation", async function (assert) {
withPluginApi("1.34.0", (api) => {
api.registerValueTransformer("category-text-color", () => "FF0000");
});
await visit("/");
const element = document.querySelector(
"[data-topic-id='11994'] .badge-category__wrapper"
);
assert.strictEqual(
window
.getComputedStyle(element)
.getPropertyValue("--category-badge-text-color"),
"#FF0000",
"it transforms the category text color"
);
});
});

View File

@ -4147,7 +4147,6 @@ en:
background_image_dark: "Dark Category Background Image"
style: "Styles"
background_color: "Color"
foreground_color: "Foreground color"
styles:
type: "Style"
icon: "Icon"