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
4 changes: 4 additions & 0 deletions frontend/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ const preview: Preview = {
parameters: {
layout: "centered",
backgrounds: { disable: true },
// Accessibility gate
a11y: {
test: "error",
},
controls: {
matchers: {
color: /(background|color)$/i,
Expand Down
7 changes: 4 additions & 3 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@vitest/ui": "^4.1.5",
"@vue/test-utils": "^2.4.9",
"@vue/tsconfig": "^0.7.0",
"axe-core": "^4.12.1",
"eslint": "^10.0.3",
"eslint-plugin-vue": "^10.8.0",
"eslint-plugin-vuejs-accessibility": "^2.5.0",
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/v2/lib/data/RTable/RTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ onBeforeUnmount(() => {
<div
class="r-table"
:class="{ 'r-table--mobile-stack': mobileStack }"
role="table"
v-bind="$attrs"
>
<!-- Header row — each column header is a cell `<div>` containing
Expand All @@ -151,7 +152,9 @@ onBeforeUnmount(() => {
icon next to "Type"). The sort button is the only interactive
element so adornments stay focusable / clickable on their own
without nesting buttons. -->
<div class="r-table__scroll" :style="scrollStyle">
<!-- Presentational so rows resolve their role="table" parent (the
scroll/body divs are layout-only). -->
<div class="r-table__scroll" :style="scrollStyle" role="presentation">
<div class="r-table__header" :style="gridStyle" role="row">
<div
v-for="col in columns"
Expand Down Expand Up @@ -198,7 +201,7 @@ onBeforeUnmount(() => {
</div>

<!-- Body — skeletons / real rows / empty state, in that order. -->
<div class="r-table__body">
<div class="r-table__body" role="presentation">
<template v-if="loading">
<div
v-for="i in loadingRows"
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/v2/lib/forms/RCheckbox/RCheckbox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,13 @@ export const Indeterminate: Story = {
args: { label: "Some items selected", indeterminate: true },
};

export const NoLabel: Story = { args: {} };
export const NoLabel: Story = {
render: () => ({
components: { RCheckbox },
setup: () => ({ value: ref(false) }),
template: `<RCheckbox v-model="value" aria-label="Accept" />`,
}),
};

// ── Multi-state ─────────────────────────────────────────────────────

Expand Down
15 changes: 11 additions & 4 deletions frontend/src/v2/lib/forms/RDateField/RDateField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,18 @@ onBeforeUnmount(() => {
</script>

<template>
<!-- combobox role goes here, not on RTextField: with a visible label
RTextField renders a <label>, where the role is disallowed and voids
the input's label association. -->
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->
<div ref="referenceEl" class="r-date-field" @keydown="onFieldKeydown">
<div
ref="referenceEl"
class="r-date-field"
role="combobox"
aria-haspopup="dialog"
:aria-expanded="isOpen"
@keydown="onFieldKeydown"
>
<!-- Single `@click` on the wrapper is enough: native clicks on
every part of the field (input, icon, label well) bubble up
here. We deliberately don't subscribe to `click:append-inner` —
Expand All @@ -425,9 +435,6 @@ onBeforeUnmount(() => {
:append-inner-icon="
clearable && selectedDate ? undefined : 'mdi-calendar'
"
:aria-haspopup="'dialog'"
:aria-expanded="isOpen"
role="combobox"
@click="toggle"
>
<template v-if="clearable && selectedDate" #append-inner>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/v2/lib/forms/RDropzone/RDropzone.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const meta: Meta<typeof RDropzone> = {
title: "Drop files here",
hint: "Drag and drop, or click to browse",
activeTitle: "Release to upload",
inputLabel: "Upload files",
multiple: true,
},
render: (args) => ({
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/v2/lib/forms/RRating/RRating.stories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, userEvent, within } from "storybook/test";
import { ref } from "vue";
import RRating from "./RRating.vue";

Expand Down Expand Up @@ -39,6 +40,38 @@ export const Default: Story = { args: { halfIncrements: true, hover: true } };
export const Readonly: Story = { args: { readonly: true } };
export const Large: Story = { args: { size: "large" } };

// Keyboard: Tab across the star buttons, Enter/Space commits the rating.
export const KeyboardNav: Story = {
name: "Keyboard navigation (play)",
args: { ariaLabel: "Rating" },
render: (args) => ({
components: { RRating },
setup: () => {
const value = ref(0);
return { args, value };
},
template: `<RRating v-bind="args" v-model="value" />`,
}),
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const stars = canvas.getAllByRole("radio");

await step("Tab focuses the stars in order", async () => {
await userEvent.tab();
expect(stars[0]).toHaveFocus();
await userEvent.tab();
await userEvent.tab();
expect(stars[2]).toHaveFocus();
});

await step("Enter commits the focused star as the rating", async () => {
await userEvent.keyboard("{Enter}");
expect(stars[2]).toHaveAttribute("aria-checked", "true");
expect(stars[0]).toHaveAttribute("aria-checked", "false");
});
},
};

// Difficulty preset — same primitive driven by props. Exercises the
// new emptyIcon/fullIcon/activeColor pass-through used by the
// score-picker on GameDetails.
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/v2/lib/forms/RSelect/RSelect.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,10 @@ export const Validation: Story = {
// ── Clearable ──────────────────────────────────────────────────────

export const Clearable: Story = {
// a11y todo (#1848): clear <button> nested in the activator <button>
// (nested-interactive). Fix = lift clear/chip-close out as siblings; a
// visual-layout refactor deferred pending in-browser verification.
parameters: { a11y: { test: "todo" } },
render: () => ({
components: { RSelect },
setup: () => ({ value: ref("psx"), items: PLATFORMS }),
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/v2/lib/forms/RSlider/RSlider.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import RSlider from "./RSlider.vue";
const meta: Meta<typeof RSlider> = {
title: "Forms/RSlider",
component: RSlider,
// RSlider has no visible label, so every instance needs `ariaLabel`.
args: { ariaLabel: "Value" },
argTypes: {
min: { control: "number" },
max: { control: "number" },
Expand Down Expand Up @@ -112,11 +114,11 @@ export const ColorLadder: Story = {
}),
template: `
<div style="display:flex;flex-direction:column;gap:18px;padding:24px;min-width:380px">
<RSlider v-model="primary" color="primary" value-position="right" value-suffix="%" />
<RSlider v-model="success" color="success" value-position="right" value-suffix="%" />
<RSlider v-model="warning" color="warning" value-position="right" value-suffix="%" />
<RSlider v-model="danger" color="danger" value-position="right" value-suffix="%" />
<RSlider v-model="info" color="info" value-position="right" value-suffix="%" />
<RSlider v-model="primary" color="primary" value-position="right" value-suffix="%" aria-label="Primary" />
<RSlider v-model="success" color="success" value-position="right" value-suffix="%" aria-label="Success" />
<RSlider v-model="warning" color="warning" value-position="right" value-suffix="%" aria-label="Warning" />
<RSlider v-model="danger" color="danger" value-position="right" value-suffix="%" aria-label="Danger" />
<RSlider v-model="info" color="info" value-position="right" value-suffix="%" aria-label="Info" />
</div>
`,
}),
Expand Down Expand Up @@ -148,7 +150,7 @@ export const CommitOnEnd: Story = {
},
template: `
<div style="padding:24px;min-width:380px;display:flex;flex-direction:column;gap:10px">
<RSlider v-model="preview" value-position="thumb" value-suffix="%" @end="onEnd" />
<RSlider v-model="preview" value-position="thumb" value-suffix="%" aria-label="Volume" @end="onEnd" />
<div style="font:12px sans-serif;color:var(--r-color-fg-muted)">
previewing: {{ preview }} · last committed: {{ committed }}
</div>
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/v2/lib/forms/RSwitch/RSwitch.stories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { expect, userEvent, within } from "storybook/test";
import { ref } from "vue";
import RSwitch from "./RSwitch.vue";

Expand Down Expand Up @@ -59,3 +60,29 @@ export const Small: Story = {
export const Disabled: Story = {
args: { label: "Disabled", disabled: true },
};

// Keyboard: Tab focuses the switch, Space/Enter toggle it.
export const KeyboardToggle: Story = {
name: "Keyboard toggle (play)",
args: { label: "Notifications" },
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const sw = canvas.getByRole("switch");

await step("Tab moves focus onto the switch", async () => {
await userEvent.tab();
expect(sw).toHaveFocus();
expect(sw).toHaveAttribute("aria-checked", "false");
});

await step("Space toggles it on", async () => {
await userEvent.keyboard(" ");
expect(sw).toHaveAttribute("aria-checked", "true");
});

await step("Enter toggles it back off", async () => {
await userEvent.keyboard("{Enter}");
expect(sw).toHaveAttribute("aria-checked", "false");
});
},
};
5 changes: 5 additions & 0 deletions frontend/src/v2/lib/forms/RTextField/RTextField.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export const WithSubtitle: Story = {

export const WithHint: Story = {
args: {
label: "Email address",
placeholder: "your@email.com",
hint: "We'll only contact you about account events.",
prependInnerIcon: "mdi-email",
Expand All @@ -172,6 +173,7 @@ export const WithHint: Story = {

export const WithError: Story = {
args: {
label: "Email address",
placeholder: "your@email.com",
error: true,
errorMessages: "Must be a valid email address",
Expand Down Expand Up @@ -301,17 +303,20 @@ export const LoginForm: Story = {
<div style="width:320px;display:flex;flex-direction:column;gap:16px;padding:24px;background:var(--r-color-bg-elevated);border:1px solid var(--r-color-border);border-radius:12px">
<RTextField
v-model="user"
label="Username"
variant="underlined"
placeholder="username"
prepend-inner-icon="mdi-account"
/>
<RTextField
v-model="pass"
label="Password"
variant="underlined"
:type="show ? 'text' : 'password'"
placeholder="password"
prepend-inner-icon="mdi-lock"
:append-inner-icon="show ? 'mdi-eye-off' : 'mdi-eye'"
:append-inner-tooltip="show ? 'Hide password' : 'Show password'"
@click:append-inner="show = !show"
/>
<RBtn block color="primary">Sign in</RBtn>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/v2/lib/forms/RTextField/RTextField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ interface Props {
/** Tooltip text shown on hover/focus over the append-inner
* adornment. The canonical use is the password-reveal eye icon. */
appendInnerTooltip?: string;
/** Accessible name for the interactive prepend-inner adornment button.
* Falls back to `prependInnerTooltip`. */
prependInnerLabel?: string;
/** Accessible name for the interactive append-inner adornment button.
* Falls back to `appendInnerTooltip`. */
appendInnerLabel?: string;
autocomplete?: string;
name?: string;
rules?: Rule[];
Expand Down Expand Up @@ -111,6 +117,8 @@ const props = withDefaults(defineProps<Props>(), {
appendInnerIcon: undefined,
prependInnerTooltip: undefined,
appendInnerTooltip: undefined,
prependInnerLabel: undefined,
appendInnerLabel: undefined,
autocomplete: undefined,
name: undefined,
rules: () => [],
Expand Down Expand Up @@ -357,6 +365,14 @@ function hasListener(name: string): boolean {
const prependInteractive = computed(() => hasListener("click:prepend-inner"));
const appendInteractive = computed(() => hasListener("click:append-inner"));

// Interactive adornments are real buttons; name them (label, else tooltip).
const prependAdornmentLabel = computed(
() => props.prependInnerLabel ?? props.prependInnerTooltip,
);
const appendAdornmentLabel = computed(
() => props.appendInnerLabel ?? props.appendInnerTooltip,
);

function onPrependInnerClick(evt: MouseEvent) {
// Always emit — emit() is a no-op when nothing's subscribed. The
// listener-detection above only governs the visual treatment
Expand Down Expand Up @@ -424,6 +440,7 @@ function onAppendInnerClick(evt: MouseEvent) {
:class="{ 'r-text-field__adornment--interactive': prependInteractive }"
:type="prependInteractive ? 'button' : undefined"
:tabindex="prependInteractive ? 0 : undefined"
:aria-label="prependInteractive ? prependAdornmentLabel : undefined"
@mousedown.prevent
@click="onPrependInnerClick"
>
Expand Down Expand Up @@ -505,6 +522,11 @@ function onAppendInnerClick(evt: MouseEvent) {
:tabindex="
appendInteractive && !showLoading && !showClear ? 0 : undefined
"
:aria-label="
appendInteractive && !showLoading && !showClear
? appendAdornmentLabel
: undefined
"
@mousedown.prevent
@click="onAppendInnerClick"
>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/v2/lib/menus/RMenuItem/RMenuItem.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ export const WithNavigation: Story = {
render: () => ({
components: { RMenuItem },
setup: () => ({ panelStyle: PANEL_STYLE }),
// Link items get role="menuitem", which needs a role="menu" parent.
template: `
<div style="padding:40px">
<div :style="panelStyle">
<div :style="panelStyle" role="menu" aria-label="Navigation">
<RMenuItem to="/platforms" icon="mdi-gamepad-variant-outline" label="Platforms" />
<RMenuItem href="https://romm.app" icon="mdi-open-in-new" label="External link" />
</div>
Expand All @@ -111,7 +112,7 @@ export const NewTabGesture: Story = {
setup: () => ({ args, panelStyle: PANEL_STYLE }),
template: `
<div style="padding:40px">
<div :style="panelStyle">
<div :style="panelStyle" role="menu" aria-label="Actions">
<RMenuItem v-bind="args" @click="args.onClick" />
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/v2/lib/overlays/RCarousel/RCarousel.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const Inline: Story = {
<img :src="item" :alt="'Slide ' + (index + 1)" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;" />
</template>
<template #thumbnail="{ item }">
<img :src="item" />
<img :src="item" alt="" />
</template>
</RCarousel>
</div>
Expand Down Expand Up @@ -124,7 +124,7 @@ export const InlineLight: Story = {
<img :src="item" :alt="'Slide ' + (index + 1)" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px;" />
</template>
<template #thumbnail="{ item }">
<img :src="item" />
<img :src="item" alt="" />
</template>
</RCarousel>
</div>
Expand Down
Loading
Loading