Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 43 additions & 14 deletions frontend/src/components/forms/ColorPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@
* default-active="brand-b"
* />
*/
import { ref, watch } from "vue";
import { ref, watch, computed } from "vue";

const props = defineProps({
colors: { type: Object, required: true },
defaultActive: { type: String, default: "" },
labels: { type: Boolean, default: true },
groupLabel: { type: String, default: "Color" },
});
const emit = defineEmits(["change"]);

Expand All @@ -47,28 +48,55 @@
}
);

const colorNames = computed(() => Object.keys(props.colors));

const tabbableColor = computed(() => {
if (activeColor.value && colorNames.value.includes(activeColor.value)) {
return activeColor.value;
}
return colorNames.value[0] || "";
});

const onClick = (name) => {
activeColor.value = name;
emit("change", name);
};

const onKeydown = (e) => {
const names = colorNames.value;
const idx = names.indexOf(activeColor.value || names[0]);
let nextIdx;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
nextIdx = (idx + 1) % names.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
nextIdx = (idx - 1 + names.length) % names.length;
}
if (nextIdx !== undefined) {
onClick(names[nextIdx]);
const swatches = e.currentTarget.querySelectorAll('[role="radio"]');
swatches[nextIdx]?.focus();
}
};
</script>

<template>
<span class="cp-wrapper">
<span class="cp-wrapper" role="radiogroup" :aria-label="groupLabel" @keydown="onKeydown">
<div
v-for="(color, name) in colors"
:key="name"
role="radio"
class="swatch"
:class="[name, activeColor === name ? 'active' : '']"
:aria-checked="activeColor === name ? 'true' : 'false'"
:aria-label="name"
:tabindex="tabbableColor === name ? 0 : -1"
@click="onClick(name)"
@keydown.enter.prevent="onClick(name)"
@keydown.space.prevent="onClick(name)"
>
<button
type="button"
class="circle"
:style="{ 'background-color': color }"
@keydown.enter.prevent="onClick(name)"
@keydown.space.prevent="onClick(name)"
/>
<span class="circle" :style="{ 'background-color': color }" />
<span v-if="labels" class="label text-caption">{{ name }}</span>
</div>
</span>
Expand All @@ -92,6 +120,7 @@
padding: 8px;
width: 60px;
transition: var(--transition-bg-color);
outline: none;

&:active {
background-color: var(--blue-400) !important;
Expand All @@ -106,6 +135,11 @@
box-shadow: var(--shadow-strong);
}
}

&:focus-visible {
outline: 2px solid var(--border-action, var(--blue-500));
outline-offset: 2px;
}
}

.swatch.active {
Expand All @@ -129,11 +163,6 @@
border-radius: 16px;
border: var(--border-thin) solid var(--gray-700);
pointer-events: none;

&:focus-visible {
border-color: var(--almost-black);
box-shadow: var(--shadow-strong);
}
}

.label {
Expand Down
136 changes: 132 additions & 4 deletions frontend/src/tests/components/ColorPicker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe("ColorPicker.vue", () => {
swatches.forEach((swatch, idx) => {
const name = Object.keys(colors)[idx];
expect(swatch.classes()).toContain(name);
const circle = swatch.find("button.circle");
const circle = swatch.find(".circle");
expect(circle.exists()).toBe(true);
expect(circle.attributes("style")).toMatch(/background-color:/);
expect(circle.element.style.backgroundColor).toBeTruthy();
Expand Down Expand Up @@ -41,13 +41,13 @@ describe("ColorPicker.vue", () => {

it("emits change and toggles active class on keyboard Enter/Space", async () => {
const wrapper = mount(ColorPicker, { props: { colors } });
const circles = wrapper.findAll("button.circle");
const swatches = wrapper.findAll(".swatch");

await circles[0].trigger("keydown.enter");
await swatches[0].trigger("keydown", { key: "Enter" });
expect(wrapper.emitted("change")?.[0]).toEqual(["red"]);
expect(wrapper.findAll(".swatch")[0].classes()).toContain("active");

await circles[2].trigger("keydown.space");
await swatches[2].trigger("keydown", { key: " " });
expect(wrapper.emitted("change")?.[1]).toEqual(["blue"]);
expect(wrapper.findAll(".swatch")[2].classes()).toContain("active");
});
Expand Down Expand Up @@ -80,4 +80,132 @@ describe("ColorPicker.vue", () => {
expect(swatch.classes()).not.toContain("active");
});
});

describe("ARIA radiogroup semantics", () => {
it("has role=radiogroup on the container with aria-label", () => {
const wrapper = mount(ColorPicker, { props: { colors } });
const container = wrapper.find('[role="radiogroup"]');
expect(container.exists()).toBe(true);
expect(container.attributes("aria-label")).toBe("Color");
});

it("accepts custom aria-label via groupLabel prop", () => {
const wrapper = mount(ColorPicker, {
props: { colors, groupLabel: "Highlight color" },
});
const container = wrapper.find('[role="radiogroup"]');
expect(container.attributes("aria-label")).toBe("Highlight color");
});

it("sets role=radio and aria-checked on each swatch", () => {
const wrapper = mount(ColorPicker, {
props: { colors, defaultActive: "green" },
});
const radios = wrapper.findAll('[role="radio"]');
expect(radios).toHaveLength(3);

expect(radios[0].attributes("aria-checked")).toBe("false");
expect(radios[1].attributes("aria-checked")).toBe("true");
expect(radios[2].attributes("aria-checked")).toBe("false");
});

it("sets aria-label on each radio with the color name", () => {
const wrapper = mount(ColorPicker, { props: { colors } });
const radios = wrapper.findAll('[role="radio"]');
expect(radios[0].attributes("aria-label")).toBe("red");
expect(radios[1].attributes("aria-label")).toBe("green");
expect(radios[2].attributes("aria-label")).toBe("blue");
});

it("uses roving tabindex — only active swatch is tabbable", () => {
const wrapper = mount(ColorPicker, {
props: { colors, defaultActive: "green" },
});
const radios = wrapper.findAll('[role="radio"]');
expect(radios[0].attributes("tabindex")).toBe("-1");
expect(radios[1].attributes("tabindex")).toBe("0");
expect(radios[2].attributes("tabindex")).toBe("-1");
});

it("makes first swatch tabbable when no defaultActive is set", () => {
const wrapper = mount(ColorPicker, { props: { colors } });
const radios = wrapper.findAll('[role="radio"]');
expect(radios[0].attributes("tabindex")).toBe("0");
expect(radios[1].attributes("tabindex")).toBe("-1");
expect(radios[2].attributes("tabindex")).toBe("-1");
});

it("updates aria-checked on click", async () => {
const wrapper = mount(ColorPicker, { props: { colors } });
const swatches = wrapper.findAll(".swatch");

await swatches[2].trigger("click");
await wrapper.vm.$nextTick();

const radios = wrapper.findAll('[role="radio"]');
expect(radios[0].attributes("aria-checked")).toBe("false");
expect(radios[2].attributes("aria-checked")).toBe("true");
});

it("navigates with arrow keys (ArrowRight/ArrowDown moves forward, wraps)", async () => {
const wrapper = mount(ColorPicker, {
props: { colors, defaultActive: "red" },
attachTo: document.body,
});

const container = wrapper.find('[role="radiogroup"]');
await container.trigger("keydown", { key: "ArrowRight" });
await wrapper.vm.$nextTick();

expect(wrapper.emitted("change")?.[0]).toEqual(["green"]);
const radios = wrapper.findAll('[role="radio"]');
expect(radios[1].attributes("aria-checked")).toBe("true");
expect(radios[1].attributes("tabindex")).toBe("0");
expect(radios[1].element).toBe(document.activeElement);

wrapper.unmount();
});

it("navigates with ArrowLeft/ArrowUp (wraps around)", async () => {
const wrapper = mount(ColorPicker, {
props: { colors, defaultActive: "red" },
attachTo: document.body,
});

const container = wrapper.find('[role="radiogroup"]');
await container.trigger("keydown", { key: "ArrowLeft" });
await wrapper.vm.$nextTick();

expect(wrapper.emitted("change")?.[0]).toEqual(["blue"]);
const radios = wrapper.findAll('[role="radio"]');
expect(radios[2].attributes("aria-checked")).toBe("true");
expect(radios[2].element).toBe(document.activeElement);

wrapper.unmount();
});

it("moves focus through multiple arrow presses sequentially", async () => {
const wrapper = mount(ColorPicker, {
props: { colors, defaultActive: "red" },
attachTo: document.body,
});

const container = wrapper.find('[role="radiogroup"]');

await container.trigger("keydown", { key: "ArrowRight" });
await wrapper.vm.$nextTick();
expect(wrapper.findAll('[role="radio"]')[1].element).toBe(document.activeElement);

await container.trigger("keydown", { key: "ArrowRight" });
await wrapper.vm.$nextTick();
expect(wrapper.findAll('[role="radio"]')[2].element).toBe(document.activeElement);

// Wraps to first
await container.trigger("keydown", { key: "ArrowRight" });
await wrapper.vm.$nextTick();
expect(wrapper.findAll('[role="radio"]')[0].element).toBe(document.activeElement);

wrapper.unmount();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ describe("Workspace Input Isolation @regression", () => {
props: { colors },
});

// ColorPicker has keydown handlers on its buttons, but they should be scoped
const buttons = wrapper.findAll("button");
expect(buttons.length).toBeGreaterThan(0);
// ColorPicker has keydown handlers on its radio swatches, but they should be scoped
const radios = wrapper.findAll('[role="radio"]');
expect(radios.length).toBeGreaterThan(0);

// Verify no global keyboard listeners were added
const globalKeyboardListeners = globalListeners.filter(
Expand Down
Loading