mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
improve a11y attributes
This commit is contained in:
parent
1dc4a85366
commit
85301f3cf4
|
|
@ -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}/)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-->
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user