improve a11y attributes

This commit is contained in:
swithek 2026-09-07 23:12:30 +02:00
parent 1dc4a85366
commit 85301f3cf4
48 changed files with 316 additions and 164 deletions

View File

@ -83,8 +83,9 @@ test.describe("search", () => {
await searchFor(dialog, "marmalade", result)
// the text exists on the draft alone, so that is the branch the
// hit names and the one the link opens
await expect(result.getByTestId("search-result-branch")).toHaveText("draft")
// hit names and the one the link opens. The branch name is the last
// span of the result's meta line
await expect(result.locator("span:last-child")).toHaveText("draft")
await result.click()
await expect(page).toHaveURL(/New-Page-[a-z0-9]{20}\?branch=[a-z0-9]{20}/)

View File

@ -117,13 +117,13 @@ Form validation (vee-validate) uses its own internal messages — see [README.md
- **shadcn-vue** components live in [app/components/shadcn/ui/](app/components/shadcn/ui/) with prefix `ShadcnUi`. Configured in [components.json](components.json).
- **Tailwind v4** via `@tailwindcss/vite`, theme in [app/assets/css/main.css](app/assets/css/main.css).
- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. The picker itself does not go through `<Icon>`: [modules/icon-picker-css.ts](modules/icon-picker-css.ts) generates one stylesheet with a rule per selectable icon, served as `virtual:icon-picker.css` and imported by the picker provider once the page's load event has fired. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix.
- **Open a11y bug: dialogs have no `DialogDescription`.** Every modal renders its description as a plain `<p>`, so the `aria-describedby` reka-ui puts on `DialogContent` points at nothing — screen readers get no description, and reka-ui warns once per mount. **This is scheduled to be fixed, not accepted.** The warning is suppressed in [vitest.config.ts](vitest.config.ts)'s `onConsoleLog` purely to keep test output readable in the meantime; that branch comes out with the fix, and the warning is still visible in `nuxt dev`. Note that routing the text through `ShadcnUiDialogDescription` is *not* a drop-in fix: the wrapper runs its class through `cn()`, and default tailwind-merge reads the custom `text-2sm` as a colour utility and drops it in favour of the wrapper's `text-sm`. Closing this needs either reka-ui's `DialogDescription` imported directly (no `cn()`) or `extendTailwindMerge` teaching `cn()` the custom text scale.
- **Every dialog describes itself.** reka-ui points `DialogContent`'s `aria-describedby` at a `DialogDescription`, so each dialog and sheet renders one — `ShadcnUiDialogDescription` (or the sheet variant) with `sr-only` when nothing visible plays that role, `as-child` around an `<i18n-t tag="p">` when the visible description is interpolated. The settings action components render theirs inside the `ActionModal` around them, which is why their tests mount through `mountUnderDialogRoot`. A dialog missing one makes reka-ui warn once per mount in `nuxt dev` and in test output.
## Formatting & TS
Prettier uses **tabs**, no semicolons, trailing commas — see [prettier.config.js](prettier.config.js).
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**: `eslint.config.typescript.tsconfigPath` in `nuxt.config.ts` switches the generated Nuxt preset to the `*-type-checked` rule sets, on top of which the config adds `eslint:recommended` (the Nuxt preset ships no base JS rules), typescript-eslint **strict + stylistic**, and `@intlify/eslint-plugin-vue-i18n`. Consequences worth knowing:
ESLint ([eslint.config.mjs](eslint.config.mjs)) runs **type-aware**: `eslint.config.typescript.tsconfigPath` in `nuxt.config.ts` switches the generated Nuxt preset to the `*-type-checked` rule sets, on top of which the config adds `eslint:recommended` (the Nuxt preset ships no base JS rules), typescript-eslint **strict + stylistic**, `@intlify/eslint-plugin-vue-i18n`, and `eslint-plugin-vuejs-accessibility` (its recommended set; the shadcn wrappers are exempt from the label-association and static-element rules, since the association is made where they are used). Consequences worth knowing:
- Linting builds a full TS program, so a cold `check-eslint` takes about a
minute. Runs are cached (`node_modules/.cache/eslint`) — unchanged files

View File

@ -57,18 +57,19 @@ async function deleteDocument() {
</ShadcnUiButton>
</ShadcnUiDialogHeader>
<div class="flex flex-col gap-2 self-stretch">
<i18n-t
scope="global"
keypath="editor.document-deletion-modal.description"
tag="p"
class="text-2sm text-muted-foreground"
>
<template #name>
<span class="font-medium break-all">
{{ lastValidTarget?.name }}
</span>
</template>
</i18n-t>
<ShadcnUiDialogDescription as-child class="text-2sm">
<i18n-t
scope="global"
keypath="editor.document-deletion-modal.description"
tag="p"
>
<template #name>
<span class="font-medium break-all">
{{ lastValidTarget?.name }}
</span>
</template>
</i18n-t>
</ShadcnUiDialogDescription>
</div>
<div class="flex gap-2 self-stretch">
<ShadcnUiButton

View File

@ -305,9 +305,12 @@ async function handleMarkAllRead() {
<div
v-for="notification in notifications"
:key="notification.id"
role="link"
tabindex="0"
class="flex min-w-0 cursor-pointer items-center gap-2 rounded-md p-2 hover:bg-accent/40 [&:active:not(:has(button:active))]:bg-accent/70"
:class="notification.read && 'opacity-60'"
@click="handleNotificationClick(notification)"
@keydown.enter.self="handleNotificationClick(notification)"
>
<ShadcnUiAvatar class="mt-0.25 size-8.25 border">
<ShadcnUiAvatarFallback>

View File

@ -224,8 +224,11 @@ describe("<SearchModal>", { concurrent: false }, () => {
await search("r")
// a result's spans are its icon, its type and its branch name, in that
// order, and highlights are <mark>s — so the branch is the only span
// that closes its parent
const branches = [
...document.body.querySelectorAll("[data-testid='search-result-branch']"),
...document.body.querySelectorAll("a span:last-child"),
].map((el) => el.textContent.trim())
expect(branches).toEqual(["main", "draft"])

View File

@ -209,10 +209,7 @@ function resultTypeText(type: string) {
<span class="capitalize">
{{ resultTypeText(result.type) }}
</span>
<span
data-testid="search-result-branch"
class="before:px-1 before:content-['·']"
>
<span class="before:px-1 before:content-['·']">
{{ result.branchName }}
</span>
</div>

View File

@ -57,18 +57,19 @@ async function deleteTag() {
</ShadcnUiButton>
</ShadcnUiDialogHeader>
<div class="flex flex-col gap-2 self-stretch">
<i18n-t
scope="global"
keypath="sidebar.tag-deletion-modal.description"
tag="p"
class="text-2sm text-muted-foreground"
>
<template #name>
<span class="font-medium break-all">
{{ lastValidTarget?.name }}
</span>
</template>
</i18n-t>
<ShadcnUiDialogDescription as-child class="text-2sm">
<i18n-t
scope="global"
keypath="sidebar.tag-deletion-modal.description"
tag="p"
>
<template #name>
<span class="font-medium break-all">
{{ lastValidTarget?.name }}
</span>
</template>
</i18n-t>
</ShadcnUiDialogDescription>
</div>
<div class="flex gap-2 self-stretch">
<ShadcnUiButton

View File

@ -124,7 +124,7 @@ function hideTooltip() {
}
// one delegated listener for the whole grid, so a cell is a plain button
function handleGridHover(e: MouseEvent) {
function handleGridHover(e: Event) {
const cell = (e.target as HTMLElement).closest<HTMLElement>("[data-name]")
if (!cell) {
return
@ -230,6 +230,8 @@ function handleEscape(e: KeyboardEvent) {
@scroll="handleScroll"
@mouseover="handleGridHover"
@mouseleave="hideTooltip"
@focusin="handleGridHover"
@focusout="hideTooltip"
>
<!--the scroller patches each recycled cell on every scroll, so
a cell has to be cheap to patch: plain elements, no components-->

View File

@ -125,10 +125,10 @@ async function openActionMenu(wrapper: VueWrapper) {
await nextTick()
}
// the icon picker comes first, then the review action and the menu that
// switches between the review actions
// the hook handle and the icon picker come first, then the review action
// and the menu that switches between the review actions
function actionMenuTrigger(wrapper: VueWrapper) {
return wrapper.findAll("button")[2]
return wrapper.findAll("button")[3]
}
function hookHandle(wrapper: VueWrapper) {

View File

@ -393,20 +393,24 @@ async function executeReviewableAction() {
@update:open="(v: boolean) => (hookMenuOpen = v)"
>
<ShadcnUiDropdownMenuTrigger as-child>
<div
<button
type="button"
:data-menu-open="hookMenuOpen ? '' : undefined"
:class="
cn(
'group/hook-handle absolute top-0.5 right-full flex h-7 items-center pr-1.5',
'pointer-events-none opacity-0 transition-opacity duration-100',
'group-hover/name-row:pointer-events-auto group-hover/name-row:opacity-100',
'focus-visible:pointer-events-auto focus-visible:opacity-100',
'data-menu-open:pointer-events-auto data-menu-open:opacity-100',
)
"
@mouseenter="hoveringHookHandle = true"
@mouseleave="hoveringHookHandle = false"
@focus="hoveringHookHandle = true"
@blur="hoveringHookHandle = false"
>
<div
<span
:data-hook-status="hookStatus"
:class="
cn(
@ -422,21 +426,21 @@ async function executeReviewableAction() {
title takes the same handle at the same width there and
keeps the hook button where the block ones do
-->
<div class="relative h-5 w-3 overflow-hidden lg:hidden">
<span class="relative h-5 w-3 overflow-hidden lg:hidden">
<Icon
name="mingcute:dots-line"
class="absolute top-1/2 left-1/2 size-5 -translate-x-1/2 -translate-y-1/2"
/>
</div>
</span>
<Icon
name="mingcute:leaf-line"
class="mt-0.25 hidden size-4.5 lg:block"
/>
</div>
</span>
<span class="sr-only">
{{ $t("editor.hook-handle.screen-reader-hint") }}
</span>
</div>
</button>
</ShadcnUiDropdownMenuTrigger>
<ShadcnUiDropdownMenuContent side="right" align="start" loop>
<HooksMenuContent

View File

@ -283,6 +283,7 @@ onUnmounted(() => {
<div class="flex flex-col gap-2">
<textarea
v-model="inputText"
:aria-label="t('editor.ai-chat.input-label')"
class="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
:placeholder="t('editor.ai-chat.input-placeholder')"
rows="2"

View File

@ -53,7 +53,7 @@ function dragBy(dx: number, dy: number) {
async function startDrag(wrapper: VueWrapper) {
await wrapper
.get("[aria-label='Resize']")
.get(".cursor-nwse-resize")
.trigger("mousedown", { clientX: 100, clientY: 200 })
}
@ -202,7 +202,7 @@ describe("<FigmaBlock>", { concurrent: false }, () => {
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(wrapper.find("[aria-label='Edit URL']").exists()).toBe(true)
expect(wrapper.find("[aria-label='Resize']").exists()).toBe(true)
expect(wrapper.find(".cursor-nwse-resize").exists()).toBe(true)
})
it("hides the edit and resize controls in read mode", async ({ expect }) => {
@ -211,13 +211,13 @@ describe("<FigmaBlock>", { concurrent: false }, () => {
const wrapper = await mountFigma({ src: FIGMA_URL })
expect(wrapper.find("[aria-label='Edit URL']").exists()).toBe(false)
expect(wrapper.find("[aria-label='Resize']").exists()).toBe(false)
expect(wrapper.find(".cursor-nwse-resize").exists()).toBe(false)
})
it("opens the url popover from the empty state", async ({ expect }) => {
const wrapper = await mountFigma()
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(popoverInput()).not.toBeNull()
})
@ -234,7 +234,7 @@ describe("<FigmaBlock>", { concurrent: false }, () => {
useEditorMeta().setEditable(false)
const wrapper = await mountFigma()
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(popoverInput()).toBeNull()
})

View File

@ -258,13 +258,14 @@ onBeforeUnmount(() => {
</div>
<iframe
:src="embedUrl"
:title="t('editor.figma.iframe-title')"
class="block w-full border-0"
:style="{ height: `${displayHeight}px` }"
allowfullscreen
/>
<div
v-if="!isEditingDisabled"
aria-label="Resize"
aria-hidden="true"
class="absolute right-0.25 bottom-0.25 size-4 cursor-nwse-resize text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
contenteditable="false"
@mousedown="startResize"
@ -274,13 +275,15 @@ onBeforeUnmount(() => {
</div>
<!-- Empty state -->
<div
<button
v-else
ref="figma-anchor"
type="button"
contenteditable="false"
:aria-disabled="isEditingDisabled"
:class="
cn(
'relative flex w-full items-center gap-2 rounded-md bg-muted p-2 transition-colors duration-150 select-none',
'relative flex w-full items-center gap-2 rounded-md bg-muted p-2 text-left transition-colors duration-150 select-none',
!isEditingDisabled &&
'cursor-pointer hover:bg-muted/70 active:bg-muted/90',
diffClass,
@ -296,7 +299,7 @@ onBeforeUnmount(() => {
: t("editor.figma.description")
}}
</span>
</div>
</button>
</NodeViewWrapper>
<!-- URL popover -->
@ -317,6 +320,7 @@ onBeforeUnmount(() => {
contenteditable="false"
>
<div class="flex items-center gap-0.75">
<!-- eslint-disable vuejs-accessibility/no-autofocus -- the popover opens on the person's own request to type a url, so focus belongs in its only field -->
<ShadcnUiInput
v-model="urlInput"
:placeholder="t('editor.figma.placeholder')"
@ -325,6 +329,7 @@ onBeforeUnmount(() => {
disable-focus-effect
@keydown="handleKeydown"
/>
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
<div class="w-[0.0625rem] shrink-0 self-stretch bg-border" />
<ShadcnUiButton size="icon-sm" variant="ghost" @click="closePopover">
<Icon name="lucide:x" />

View File

@ -327,7 +327,7 @@ describe("<FileBlock>", { concurrent: false }, () => {
.spyOn(fileInput(wrapper), "click")
.mockImplementation(() => undefined)
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(1)
expect(fileInput(wrapper).getAttribute("accept")).toBeNull()
@ -338,7 +338,7 @@ describe("<FileBlock>", { concurrent: false }, () => {
const wrapper = await mountFile()
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})
@ -350,7 +350,7 @@ describe("<FileBlock>", { concurrent: false }, () => {
const wrapper = await mountFile()
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})
@ -360,7 +360,7 @@ describe("<FileBlock>", { concurrent: false }, () => {
const wrapper = await mountFile()
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})
@ -371,7 +371,7 @@ describe("<FileBlock>", { concurrent: false }, () => {
const wrapper = await mountFile({ uploading: true })
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})

View File

@ -211,11 +211,13 @@ async function downloadOnDesktop() {
</span>
</span>
</component>
<div
<button
v-else
type="button"
:aria-disabled="!canUpload"
:class="
cn(
'relative flex w-full items-center gap-2 rounded-md bg-muted p-2 transition-colors duration-150 select-none',
'relative flex w-full items-center gap-2 rounded-md bg-muted p-2 text-left transition-colors duration-150 select-none',
canUpload && 'active:bg-muted-90 cursor-pointer hover:bg-muted/70',
diffClass,
)
@ -237,9 +239,10 @@ async function downloadOnDesktop() {
<input
ref="fileInputRef"
type="file"
aria-hidden="true"
class="hidden"
@change="handleFileChange"
/>
</div>
</button>
</NodeViewWrapper>
</template>

View File

@ -75,7 +75,7 @@ function sizeImage(wrapper: VueWrapper, width: number, height: number) {
async function startDrag(wrapper: VueWrapper) {
await wrapper
.get("[aria-label='Resize image']")
.get(".cursor-nwse-resize")
.trigger("mousedown", { clientX: 100, clientY: 200 })
}
@ -257,7 +257,7 @@ describe("<ImageBlock>", { concurrent: false }, () => {
}) => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
expect(wrapper.find("[aria-label='Resize image']").exists()).toBe(true)
expect(wrapper.find(".cursor-nwse-resize").exists()).toBe(true)
})
it("hides the resize handle in read mode", async ({ expect }) => {
@ -265,7 +265,7 @@ describe("<ImageBlock>", { concurrent: false }, () => {
const wrapper = await mountImage({ src: "https://cdn.test/a.png" })
expect(wrapper.find("[aria-label='Resize image']").exists()).toBe(false)
expect(wrapper.find(".cursor-nwse-resize").exists()).toBe(false)
})
it("scales the image while the handle is dragged", async ({ expect }) => {
@ -379,7 +379,7 @@ describe("<ImageBlock>", { concurrent: false }, () => {
.spyOn(fileInput(wrapper), "click")
.mockImplementation(() => undefined)
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(1)
})
@ -389,7 +389,7 @@ describe("<ImageBlock>", { concurrent: false }, () => {
const wrapper = await mountImage()
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})
@ -400,7 +400,7 @@ describe("<ImageBlock>", { concurrent: false }, () => {
const wrapper = await mountImage({ uploading: true })
const click = vi.spyOn(fileInput(wrapper), "click")
await wrapper.get("[data-node-view-wrapper] > div").trigger("click")
await wrapper.get("[data-node-view-wrapper] > button").trigger("click")
expect(click).toHaveBeenCalledTimes(0)
})

View File

@ -251,18 +251,20 @@ onBeforeUnmount(() => {
/>
<div
v-if="showResizeHandle"
aria-label="Resize image"
aria-hidden="true"
class="absolute right-0.25 bottom-0.25 size-4 cursor-nwse-resize text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
@mousedown="startResize"
>
<Icon name="lucide:move-diagonal-2" />
</div>
</div>
<div
<button
v-else
type="button"
:aria-disabled="!canUpload"
:class="
cn(
'relative flex w-full items-center gap-2 rounded-md bg-muted p-2 transition-colors duration-150 select-none',
'relative flex w-full items-center gap-2 rounded-md bg-muted p-2 text-left transition-colors duration-150 select-none',
canUpload && 'active:bg-muted-90 cursor-pointer hover:bg-muted/70',
diffClass,
)
@ -284,10 +286,11 @@ onBeforeUnmount(() => {
<input
ref="fileInputRef"
type="file"
aria-hidden="true"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="handleFileChange"
/>
</div>
</button>
</NodeViewWrapper>
</template>

View File

@ -21,7 +21,7 @@ function mountTypeSelect(props: Record<string, unknown> = {}) {
}
function tiles(wrapper: VueWrapper) {
return wrapper.findAll("div.flex-1")
return wrapper.findAll("button")
}
// the editable flag is a shared cookie state and the editor store is

View File

@ -24,7 +24,10 @@ function handleTypeSelection(type: GenericQueryChartType) {
</script>
<template>
<div class="flex gap-1 px-1.75">
<div
<button
type="button"
:aria-pressed="config?.visualizationType === GenericQueryChartType.Line"
:aria-disabled="isEditingDisabled"
:class="
cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md border py-1 opacity-100 transition-all duration-150 select-none',
@ -45,11 +48,14 @@ function handleTypeSelection(type: GenericQueryChartType) {
@click="handleTypeSelection(GenericQueryChartType.Line)"
>
<Icon name="lucide:chart-line" class="size-5 shrink-0" />
<div class="text-2sm text-foreground">
<span class="text-2sm text-foreground">
{{ $t("editor.metrics.config.type-options.line-chart.title") }}
</div>
</div>
<div
</span>
</button>
<button
type="button"
:aria-pressed="config?.visualizationType === GenericQueryChartType.Bar"
:aria-disabled="isEditingDisabled"
:class="
cn(
'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md border py-1 opacity-100 transition-all duration-150 select-none',
@ -70,11 +76,14 @@ function handleTypeSelection(type: GenericQueryChartType) {
@click="handleTypeSelection(GenericQueryChartType.Bar)"
>
<Icon name="lucide:bar-chart-3" class="size-5 shrink-0" />
<div class="text-2sm text-foreground">
<span class="text-2sm text-foreground">
{{ $t("editor.metrics.config.type-options.bar-chart.title") }}
</div>
</div>
<div
</span>
</button>
<button
type="button"
:aria-pressed="config?.visualizationType === GenericQueryChartType.Gauge"
:aria-disabled="isEditingDisabled"
:class="
cn(
'flex flex-1 flex-col items-center justify-center rounded-md border py-1 opacity-100 transition-all duration-150 select-none',
@ -95,9 +104,9 @@ function handleTypeSelection(type: GenericQueryChartType) {
@click="handleTypeSelection(GenericQueryChartType.Gauge)"
>
<Icon name="lucide:gauge" class="size-5.5 shrink-0" />
<div class="text-2sm text-foreground">
<span class="text-2sm text-foreground">
{{ $t("editor.metrics.config.type-options.gauge-chart.title") }}
</div>
</div>
</span>
</button>
</div>
</template>

View File

@ -30,6 +30,7 @@ function handleClick(btn: "first" | "second") {
<template>
<div
role="presentation"
class="group/right-side-extra-action absolute -bottom-7 left-0 z-10 flex h-7 w-full cursor-default items-center justify-center gap-3"
@mousedown="
(e) => {

View File

@ -18,7 +18,7 @@ function mountIndicator(props: Record<string, unknown> = {}) {
}
function position(wrapper: VueWrapper): string {
return wrapper.get("div").attributes("style") ?? ""
return wrapper.get("button").attributes("style") ?? ""
}
describe("<CommentIndicator>", () => {
@ -66,25 +66,25 @@ describe("<CommentIndicator>", () => {
it("stays faded while nothing points at it", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(wrapper.get("div").attributes("data-hovered")).toBeUndefined()
expect(wrapper.get("button").attributes("data-hovered")).toBeUndefined()
})
it("becomes solid once the pointer is on it", async ({ expect }) => {
const wrapper = await mountIndicator({ hovered: true })
expect(wrapper.get("div").attributes("data-hovered")).toBe("")
expect(wrapper.get("button").attributes("data-hovered")).toBe("")
})
it("becomes solid while it is forcibly highlighted", async ({ expect }) => {
const wrapper = await mountIndicator({ forcedHighlight: true })
expect(wrapper.get("div").attributes("data-hovered")).toBe("")
expect(wrapper.get("button").attributes("data-hovered")).toBe("")
})
it("animates its moves while the page is still", async ({ expect }) => {
const wrapper = await mountIndicator()
expect(wrapper.get("div").classes()).toContain(
expect(wrapper.get("button").classes()).toContain(
"transition-[top,left,opacity]",
)
})

View File

@ -28,7 +28,9 @@ const offsets = computed(() => {
})
</script>
<template>
<div
<button
type="button"
:aria-label="$t('editor.comment-indicator.screen-reader-hint')"
:data-hovered="props.hovered || props.forcedHighlight ? '' : undefined"
:class="
cn(
@ -41,7 +43,7 @@ const offsets = computed(() => {
left: `${props.left + offsets.left}px`,
}"
>
<div
<span
:class="
cn(
'flex items-center justify-center rounded-full border',
@ -53,6 +55,6 @@ const offsets = computed(() => {
name="mingcute:message-4-fill"
class="relative size-3 text-foreground"
/>
</div>
</div>
</span>
</button>
</template>

View File

@ -39,6 +39,8 @@ const emit = defineEmits<{
@mouseleave="
emit('comment-hover-change', 'node', overlay.nodeCommentId, false)
"
@focus="emit('comment-hover-change', 'node', overlay.nodeCommentId, true)"
@blur="emit('comment-hover-change', 'node', overlay.nodeCommentId, false)"
/>
</Teleport>
<Teleport
@ -60,6 +62,8 @@ const emit = defineEmits<{
@mouseleave="
emit('comment-hover-change', 'text', indicator.commentId, false)
"
@focus="emit('comment-hover-change', 'text', indicator.commentId, true)"
@blur="emit('comment-hover-change', 'text', indicator.commentId, false)"
/>
</Teleport>
</template>

View File

@ -138,6 +138,19 @@ function handleDragHoverLeave() {
removeDragOverlay()
}
// focus moving between the handle's own buttons is not a leave
function handleDragFocusOut(e: FocusEvent) {
const handle = e.currentTarget as HTMLElement
if (
e.relatedTarget instanceof globalThis.Node &&
handle.contains(e.relatedTarget)
) {
return
}
handleDragHoverLeave()
}
function createDragOverlay(nodeEl: HTMLElement) {
showHighlight(
nodeEl.getBoundingClientRect(),
@ -179,6 +192,8 @@ function dragEnd() {
@node-change="handleNodeHover"
@mouseenter="handleDragHoverEnter"
@mouseleave="handleDragHoverLeave"
@focusin="handleDragHoverEnter"
@focusout="handleDragFocusOut"
@dragstart="dragStart"
@dragend="dragEnd"
>

View File

@ -37,11 +37,12 @@ const open = ref(false)
<template>
<div class="flex w-full flex-col">
<ShadcnUiCollapsible :open="open">
<div
<button
type="button"
:data-selected="itemSelected ? '' : undefined"
:class="
cn(
'flex cursor-pointer items-center justify-between gap-1 rounded py-0.75 pr-2 pl-1 text-2sm font-medium select-none hover:bg-accent/50 hover:text-accent-foreground active:bg-accent active:text-accent-foreground',
'flex w-full cursor-pointer items-center justify-between gap-1 rounded py-0.75 pr-2 pl-1 text-left text-2sm font-medium select-none hover:bg-accent/50 hover:text-accent-foreground active:bg-accent active:text-accent-foreground',
'hover:[&>*>*>.hide-on-parent-hover]:pointer-events-none hover:[&>*>*>.hide-on-parent-hover]:opacity-0',
'hover:[&>*>*>.show-on-parent-hover]:pointer-events-auto hover:[&>*>*>.show-on-parent-hover]:opacity-100',
'data-[selected]:bg-accent/50 data-[selected]:text-accent-foreground data-[selected]:active:bg-accent data-[selected]:active:text-accent-foreground',
@ -96,7 +97,7 @@ const open = ref(false)
name="mingcute:check-fill"
class="size-3.5 shrink-0"
/>
</div>
</button>
<ShadcnUiCollapsibleContent v-if="item.type === 'folder'" as-child>
<div :class="['ml-3.25 py-1 pl-2', 'border-l border-border']">
<div v-if="subItems.length" class="flex flex-col gap-1">

View File

@ -245,6 +245,16 @@ function handlePopoverMouseLeave() {
}
}
// focus moving between the popover's own controls is not a leave
function handlePopoverFocusOut(e: FocusEvent) {
const popover = e.currentTarget as HTMLElement
if (e.relatedTarget instanceof Node && popover.contains(e.relatedTarget)) {
return
}
handlePopoverMouseLeave()
}
function handleClickOutside(event?: MouseEvent) {
if (!isEditing.value || !popoverElem.value) {
return
@ -512,6 +522,7 @@ defineExpose({
leave-active-class="animate-out"
leave-to-class="fade-out-0"
>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- the handlers only track whether the pointer or focus is inside the popover so it stays open; the popover is a container, not a control -->
<div
v-if="renderPopover && currentLink"
ref="link-bubble-popover"
@ -522,6 +533,8 @@ defineExpose({
class="absolute z-popover max-w-[20rem] min-w-40 gap-2 rounded-lg border border-border bg-background py-1 shadow-md"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
@focusin="handlePopoverMouseEnter"
@focusout="handlePopoverFocusOut"
>
<div v-if="!isEditing" class="flex h-full min-w-0 flex-col gap-1.5">
<div class="flex h-full min-w-0 items-center gap-1 pr-1 pl-1.75">

View File

@ -44,6 +44,7 @@ function handleKeydown(event: KeyboardEvent) {
</script>
<template>
<div class="flex items-center gap-0.75">
<!-- eslint-disable vuejs-accessibility/no-autofocus -- the menu opens on the person's own request to type a url, so focus belongs in its only field -->
<ShadcnUiInput
v-model="linkUrl"
:placeholder="$t('editor.link.page-or-url-placeholder')"
@ -52,6 +53,7 @@ function handleKeydown(event: KeyboardEvent) {
disable-focus-effect
@keydown="handleKeydown"
/>
<!-- eslint-enable vuejs-accessibility/no-autofocus -->
<div class="h-full w-[0.0625rem] shrink-0 bg-border" />
<ShadcnUiButton size="icon-sm" variant="ghost" @click="cancel">
<Icon name="lucide:x" />

View File

@ -71,11 +71,21 @@ describe("<CommandButton>", () => {
expect(wrapper.emitted("click")).toHaveLength(1)
})
it("reports the pointer moving over it", async ({ expect }) => {
it("asks to be highlighted when the pointer moves over it", async ({
expect,
}) => {
const wrapper = await mountButton()
await wrapper.get("button").trigger("mouseover")
expect(wrapper.emitted("hover")).toHaveLength(1)
expect(wrapper.emitted("highlight")).toHaveLength(1)
})
it("asks to be highlighted when it receives focus", async ({ expect }) => {
const wrapper = await mountButton()
await wrapper.get("button").trigger("focus")
expect(wrapper.emitted("highlight")).toHaveLength(1)
})
})

View File

@ -7,7 +7,7 @@ const props = defineProps<{
selectedIndex: number | null
}>()
const emit = defineEmits<{
(e: "click" | "hover"): void
(e: "click" | "highlight"): void
}>()
</script>
@ -22,7 +22,8 @@ const emit = defineEmits<{
)
"
@click="emit('click')"
@mouseover="emit('hover')"
@mouseover="emit('highlight')"
@focus="emit('highlight')"
>
<div class="flex items-center gap-2">
<Icon

View File

@ -150,7 +150,7 @@ function selectItem(index: number) {
:item-index="index"
:selected-index="selectedIndex"
@click="selectItem(index)"
@hover="selectedIndex = index"
@highlight="selectedIndex = index"
/>
<div
v-if="

View File

@ -1,4 +1,3 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import {
@ -9,6 +8,7 @@ import AccountDeletionAction from "./AccountDeletionAction.vue"
import {
findButtonByText,
mockAuthEndpoint,
mountUnderDialogRoot,
seedAuthOrganization,
settleMutations,
t,
@ -19,7 +19,7 @@ vi.mock("vue-sonner", () => ({
}))
function mountAction() {
return mountSuspended(AccountDeletionAction)
return mountUnderDialogRoot(AccountDeletionAction)
}
async function confirmDeletion(

View File

@ -56,9 +56,9 @@ async function deleteAccount() {
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2 self-stretch">
<p class="text-2sm text-muted-foreground">
<ShadcnUiDialogDescription class="text-2sm">
{{ $t("settings.action-modals.account-deletion.description") }}
</p>
</ShadcnUiDialogDescription>
<div v-if="lastOrgMemeber" class="text-2sm font-medium text-foreground">
{{
$t(

View File

@ -1,4 +1,3 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { afterEach, beforeEach, describe, it, vi } from "vitest"
import { toast } from "vue-sonner"
import {
@ -7,7 +6,12 @@ import {
mockEndpoint,
} from "~/composables/api/test-helpers"
import DataSourceRemovalAction from "./DataSourceRemovalAction.vue"
import { findButtonByText, settleMutations, t } from "../test-helpers"
import {
findButtonByText,
mountUnderDialogRoot,
settleMutations,
t,
} from "../test-helpers"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
@ -24,7 +28,7 @@ const DATA_SOURCE: DataSource = {
}
function mountAction() {
return mountSuspended(DataSourceRemovalAction, {
return mountUnderDialogRoot(DataSourceRemovalAction, {
props: { data: DATA_SOURCE },
})
}

View File

@ -47,18 +47,19 @@ async function removeDataSource() {
</script>
<template>
<div class="flex flex-col gap-5">
<i18n-t
scope="global"
keypath="settings.action-modals.data-source-removal.description"
class="text-2sm text-muted-foreground"
tag="p"
>
<template #name>
<span class="font-medium text-foreground">
{{ props.data.name }}
</span>
</template>
</i18n-t>
<ShadcnUiDialogDescription as-child class="text-2sm">
<i18n-t
scope="global"
keypath="settings.action-modals.data-source-removal.description"
tag="p"
>
<template #name>
<span class="font-medium text-foreground">
{{ props.data.name }}
</span>
</template>
</i18n-t>
</ShadcnUiDialogDescription>
<div class="flex w-full gap-2">
<ShadcnUiButton
type="submit"

View File

@ -253,7 +253,7 @@ const onSubmit = form.handleSubmit(async (values) => {
</script>
<template>
<div class="flex flex-col">
<p class="text-2sm text-muted-foreground">
<ShadcnUiDialogDescription class="text-2sm">
{{
isCreation
? $t(
@ -263,7 +263,7 @@ const onSubmit = form.handleSubmit(async (values) => {
`settings.action-modals.data-source-upsert.description.update.${dataSourceType}`,
)
}}
</p>
</ShadcnUiDialogDescription>
<form
class="mt-5 flex w-full flex-col items-center gap-5"
autocomplete="off"

View File

@ -61,9 +61,9 @@ const onSubmit = form.handleSubmit(async (values) => {
</script>
<template>
<div class="flex flex-col">
<p class="text-2sm text-muted-foreground">
<ShadcnUiDialogDescription class="text-2sm">
{{ $t("settings.action-modals.email-change.description") }}
</p>
</ShadcnUiDialogDescription>
<form
class="mt-5 flex w-full flex-col items-center gap-5"
autocomplete="off"

View File

@ -139,9 +139,9 @@ async function onSetSubmit() {
<template>
<div class="flex flex-col">
<template v-if="hasPassword">
<p class="text-2sm text-muted-foreground">
<ShadcnUiDialogDescription class="text-2sm">
{{ $t("settings.action-modals.password-change.description") }}
</p>
</ShadcnUiDialogDescription>
<form
class="mt-5 flex w-full flex-col gap-5"
autocomplete="off"
@ -284,18 +284,19 @@ async function onSetSubmit() {
</form>
</template>
<template v-else>
<i18n-t
scope="global"
keypath="settings.action-modals.password-change.description-set"
tag="p"
class="text-2sm text-muted-foreground"
>
<template #email>
<span class="font-semibold">
{{ fetchAuthSession.state.value.data?.data?.user.email }}
</span>
</template>
</i18n-t>
<ShadcnUiDialogDescription as-child class="text-2sm">
<i18n-t
scope="global"
keypath="settings.action-modals.password-change.description-set"
tag="p"
>
<template #email>
<span class="font-semibold">
{{ fetchAuthSession.state.value.data?.data?.user.email }}
</span>
</template>
</i18n-t>
</ShadcnUiDialogDescription>
<div class="mt-5 flex w-full gap-2">
<ShadcnUiButton
type="button"

View File

@ -243,6 +243,7 @@ function handleAvatarClick() {
<input
ref="avatarInput"
type="file"
aria-hidden="true"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="handleAvatarChange"

View File

@ -78,7 +78,7 @@ const onSubmit = form.handleSubmit(async (values) => {
</script>
<template>
<div class="flex flex-col">
<p class="text-2sm text-muted-foreground">
<ShadcnUiDialogDescription class="text-2sm">
{{
isMaxMembersReached
? $t(
@ -86,7 +86,7 @@ const onSubmit = form.handleSubmit(async (values) => {
)
: $t("settings.action-modals.workspace-invitation.description")
}}
</p>
</ShadcnUiDialogDescription>
<form
class="mt-5 flex w-full flex-col items-center gap-5"
autocomplete="off"

View File

@ -86,18 +86,19 @@ async function removeMember() {
</script>
<template>
<div class="flex flex-col gap-5">
<i18n-t
scope="global"
keypath="settings.action-modals.workspace-member-removal.description"
class="text-2sm text-muted-foreground"
tag="p"
>
<template #member>
<span class="font-medium text-foreground">
{{ props.member.user.name }}
</span>
</template>
</i18n-t>
<ShadcnUiDialogDescription as-child class="text-2sm">
<i18n-t
scope="global"
keypath="settings.action-modals.workspace-member-removal.description"
tag="p"
>
<template #member>
<span class="font-medium text-foreground">
{{ props.member.user.name }}
</span>
</template>
</i18n-t>
</ShadcnUiDialogDescription>
<div class="flex w-full gap-2">
<ShadcnUiButton
type="submit"

View File

@ -212,6 +212,7 @@ function handleMemberDelete(member: OrganizationMember) {
<input
ref="logoInput"
type="file"
aria-hidden="true"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="handleLogoChange"

View File

@ -94,9 +94,9 @@ const onSubmit = form.handleSubmit(async (values) => {
</script>
<template>
<div class="flex flex-col">
<p class="text-2sm text-muted-foreground">
<ShadcnUiDialogDescription class="text-2sm">
{{ $t("settings.action-modals.workspace-url-change.description") }}
</p>
</ShadcnUiDialogDescription>
<form
class="mt-5 flex w-full flex-col items-center gap-5"
autocomplete="off"

View File

@ -11,6 +11,7 @@ import {
seedQueryData,
type RecordedCall,
} from "~/composables/api/test-helpers"
import { Dialog } from "./shadcn/ui/dialog"
import { SidebarProvider } from "./shadcn/ui/sidebar"
import { TooltipProvider } from "./shadcn/ui/tooltip"
@ -207,6 +208,25 @@ export function openTooltipText(wrapper: VueWrapper): string {
return document.getElementById(id)?.textContent ?? ""
}
// a DialogDescription reads the id it answers from the dialog root's
// context, so a component that renders one — the settings actions, whose
// dialog is the ActionModal around them — needs a root to mount at all.
// The root renders inline, and the wrapper handed back is the component's
// own, so its emitted events stay visible to the test.
export async function mountUnderDialogRoot(
component: TestComponent,
options: { props?: Record<string, unknown> } = {},
) {
const root = await mountSuspended(Dialog, {
props: { open: true },
slots: {
default: () => h(component, options.props),
},
})
return root.findComponent(component)
}
// the settings action modals hold their submit for delay(300) so the
// spinner is visible; driving that needs fake timers, but installing them
// before the mount leaves the nuxt app's own async setup frozen — so they
@ -215,7 +235,7 @@ export async function mountWithFrozenClock(
component: TestComponent,
options: { props?: Record<string, unknown> } = {},
) {
const wrapper = await mountSuspended(component, { props: options.props })
const wrapper = await mountUnderDialogRoot(component, options)
vi.useFakeTimers()
return wrapper

View File

@ -3,6 +3,7 @@ import js from "@eslint/js"
import vueI18n from "@intlify/eslint-plugin-vue-i18n"
import pluginTs from "@typescript-eslint/eslint-plugin"
import vitest from "@vitest/eslint-plugin"
import vueA11y from "eslint-plugin-vuejs-accessibility"
import eslintConfigPrettier from "eslint-config-prettier/flat"
import withNuxt from "./.nuxt/eslint.config.mjs"
@ -115,6 +116,8 @@ export default withNuxt([
},
},
...vueA11y.configs["flat/recommended"],
...vueI18n.configs["flat/recommended"],
{
name: "oxynote/vue-i18n",
@ -182,6 +185,22 @@ export default withNuxt([
},
},
// the shadcn wrappers render a bare input, textarea or label whose
// association is made where they are used, so the rules pairing a
// control with its label cannot see it. InputGroupAddon's click only
// moves focus into the input it decorates — a pointer convenience with
// nothing for a keyboard to reach.
{
name: "oxynote/vue-a11y/shadcn",
files: ["app/components/shadcn/ui/**"],
rules: {
"vuejs-accessibility/click-events-have-key-events": "off",
"vuejs-accessibility/form-control-has-label": "off",
"vuejs-accessibility/label-has-for": "off",
"vuejs-accessibility/no-static-element-interactions": "off",
},
},
{
rules: {
"no-tabs": "off",

View File

@ -133,7 +133,8 @@
"description": "Embed Figma",
"placeholder": "https://www.figma.com/... + Enter",
"edit": "Edit URL",
"empty": "No Figma embed"
"empty": "No Figma embed",
"iframe-title": "Figma embed"
},
"image": {
"description": "Upload an image",
@ -238,6 +239,9 @@
"bubble-menu": {
"comment-label": "Comment"
},
"comment-indicator": {
"screen-reader-hint": "Open comment"
},
"comment-thread": {
"unknown-author": "Unknown",
"comment-header": "{author} {time}",
@ -808,6 +812,7 @@
"sheet-description": "Rubber Duck chat panel.",
"empty-state-description": "Quack! Tell me what's on your mind and I'll help you think it through.",
"input-placeholder": "Quack quack...",
"input-label": "Message",
"send": "Send",
"new-chat": "New Chat",
"tool-status": {

View File

@ -147,6 +147,7 @@
"electron": "44.0.0",
"eslint": "^10.9.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vuejs-accessibility": "^2.6.0",
"happy-dom": "^20.11.13",
"knip": "^6.33.0",
"playwright": "^1.62.1",

View File

@ -324,6 +324,9 @@ importers:
eslint-config-prettier:
specifier: ^10.1.8
version: 10.1.8(eslint@10.9.1(jiti@2.7.0))
eslint-plugin-vuejs-accessibility:
specifier: ^2.6.0
version: 2.6.0(eslint@10.9.1(jiti@2.7.0))(globals@17.11.0)
happy-dom:
specifier: ^20.11.13
version: 20.11.13
@ -4338,6 +4341,10 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -5538,6 +5545,13 @@ packages:
'@typescript-eslint/parser':
optional: true
eslint-plugin-vuejs-accessibility@2.6.0:
resolution: {integrity: sha512-QtJO3UdLH+aydue/im7aBsYKO99ouJ6hm4wEHL1Xm/BwDfYTAmXEPhTImNwlOHp2bbid7rNY7IrHz+IZzGApYA==}
engines: {node: '>=16.0.0'}
peerDependencies:
eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
globals: '>= 13.12.1'
eslint-processor-vue-blocks@2.0.0:
resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==}
peerDependencies:
@ -13751,6 +13765,8 @@ snapshots:
dependencies:
tslib: 2.8.1
aria-query@5.3.2: {}
assertion-error@2.0.1: {}
ast-kit@2.2.0:
@ -15054,6 +15070,15 @@ snapshots:
'@stylistic/eslint-plugin': 5.10.0(eslint@10.9.1(jiti@2.7.0))
'@typescript-eslint/parser': 8.68.0(eslint@10.9.1(jiti@2.7.0))(typescript@6.0.3)
eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.9.1(jiti@2.7.0))(globals@17.11.0):
dependencies:
aria-query: 5.3.2
eslint: 10.9.1(jiti@2.7.0)
globals: 17.11.0
vue-eslint-parser: 10.4.1(eslint@10.9.1(jiti@2.7.0))
transitivePeerDependencies:
- supports-color
eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.42)(eslint@10.9.1(jiti@2.7.0)):
dependencies:
'@vue/compiler-sfc': 3.5.42

View File

@ -36,15 +36,6 @@ const sharedTestOptions = {
if (log.startsWith("[Icon] failed to load icon")) {
return false
}
// temporary suppression. reka-ui warns once per dialog mount that
// no DialogDescription answers the aria-describedby it points at,
// and every modal in the app has that gap, so no individual suite
// can act on it. Delete this branch once the dialogs describe
// themselves
if (log.startsWith("Warning: Missing `Description`")) {
return false
}
},
} satisfies TestUserConfig