diff --git a/src/Label/Label.stories.ts b/src/Label/Label.stories.ts index ee24fa1..d55e330 100644 --- a/src/Label/Label.stories.ts +++ b/src/Label/Label.stories.ts @@ -1,4 +1,5 @@ import D9Label from "./Label.vue"; +import D9Input from "../Input/Input.vue"; export default { title: "Forms/Label", @@ -21,3 +22,16 @@ export const WithDescription = (args: Record) => ({ template: '', }); + +export const LinkedToField = (args: Record) => ({ + components: { D9Label, D9Input }, + setup() { + return { args }; + }, + // The help text gets the id "-help": point the field at it with aria-describedby. + template: ` +
+ + +
`, +}); diff --git a/src/Label/Label.vue b/src/Label/Label.vue index ebece5a..b03886e 100644 --- a/src/Label/Label.vue +++ b/src/Label/Label.vue @@ -11,7 +11,8 @@ > {{ description }} diff --git a/src/Select/Select.vue b/src/Select/Select.vue index b2982c6..27b2a6a 100644 --- a/src/Select/Select.vue +++ b/src/Select/Select.vue @@ -5,11 +5,13 @@ @update:model-value="onSelect" :by="by" :disabled="isDisabled" + v-bind="wrapperAttrs()" >
@@ -120,7 +122,14 @@ import { ListboxOptions, } from "@headlessui/vue"; import { Size } from "../types/types"; -import { computed, onMounted, ref, watch, withDefaults } from "vue"; +import { + computed, + onMounted, + ref, + useAttrs, + watch, + withDefaults, +} from "vue"; import { useFormClasses } from "../utils/useFormClasses"; interface SelectOption { @@ -150,8 +159,18 @@ const props = withDefaults( } ); +defineOptions({ inheritAttrs: false }); + const selected = ref(props.modelValue ?? null); +// aria-describedby belongs on the button the screen reader focuses; other attrs stay on the wrapper. +const attrs = useAttrs(); +// a function, not a computed: attrs isn't reactive, so a computed would miss attrs set later +const wrapperAttrs = () => + Object.fromEntries( + Object.entries(attrs).filter(([name]) => name !== "aria-describedby") + ); + const emit = defineEmits<{ (e: "update:modelValue", value: SelectOption): void; }>(); diff --git a/tests/Label.spec.ts b/tests/Label.spec.ts new file mode 100644 index 0000000..a0ae32d --- /dev/null +++ b/tests/Label.spec.ts @@ -0,0 +1,39 @@ +import { mount } from "@vue/test-utils"; +import { describe, expect, it } from "vitest"; +import { D9Label } from "../src"; + +describe("D9Label help text", () => { + it("gets the id -help next to -label", () => { + const wrapper = mount(D9Label, { + props: { id: "name", label: "Name", description: "Full name." }, + }); + + expect(wrapper.find("label").attributes("id")).toBe("name-label"); + expect(wrapper.find("#name-help").text()).toBe("Full name."); + }); + + it("has no help id without an id", () => { + const wrapper = mount(D9Label, { + props: { label: "Name", description: "Full name." }, + }); + + expect(wrapper.find("[id]").exists()).toBe(false); + }); + + it("renders no help element without a description", () => { + const wrapper = mount(D9Label, { props: { id: "name", label: "Name" } }); + + expect(wrapper.find("#name-help").exists()).toBe(false); + }); + + it("uses a text color with at least 4.5:1 contrast on white and grey-50", () => { + const wrapper = mount(D9Label, { + props: { id: "name", label: "Name", description: "Full name." }, + }); + const classes = wrapper.find("#name-help").classes(); + + // grey is slate: 500 is 4.76:1 on white, 4.55:1 on grey-50; 400 is only 2.56 / 2.45 + expect(classes).toContain("text-grey-500"); + expect(classes).not.toContain("text-grey-400"); + }); +}); diff --git a/tests/Select.spec.ts b/tests/Select.spec.ts index b9c9df2..d085856 100644 --- a/tests/Select.spec.ts +++ b/tests/Select.spec.ts @@ -23,4 +23,28 @@ describe("D9Select", () => { expect(wrapper.emitted("update:modelValue")).toBeUndefined(); }); + + it("puts aria-describedby on the button and other attrs on the wrapper", () => { + const wrapper = mount(D9Select, { + props: { options }, + attrs: { "aria-describedby": "x-help", class: "mt-2", "data-a": "1" }, + }); + + expect(wrapper.find("button").attributes("aria-describedby")).toBe( + "x-help" + ); + // the wrapper's opening tag is the first line of the markup + const wrapperTag = wrapper.html().split("\n")[0]; + expect(wrapperTag).toContain('class="mt-2"'); + expect(wrapperTag).toContain('data-a="1"'); + expect(wrapperTag).not.toContain("aria-describedby"); + }); + + it("shows a class set after the first render on the wrapper", async () => { + const wrapper = mount(D9Select, { props: { options } }); + + await wrapper.setProps({ class: "mt-2" }); + + expect(wrapper.html().split("\n")[0]).toContain('class="mt-2"'); + }); });