From c72ccdb049d296751427ea399f91010113f86924 Mon Sep 17 00:00:00 2001 From: sarayourfriend Date: Tue, 24 Sep 2024 12:24:37 -0600 Subject: [PATCH 1/2] Ariakit vue exploration with port of Heading --- frontend/package.json | 19 +- frontend/src/components/VFourOhFour.vue | 11 +- .../ariakit/heading/AHeading.composable.ts | 40 ++++ .../components/ariakit/heading/AHeading.vue | 24 +++ .../heading/AHeadingLevel.composable.ts | 15 ++ .../ariakit/heading/AHeadingLevel.vue | 19 ++ .../ariakit/heading/HeadingContext.ts | 17 ++ .../ariakit/heading/meta/AHeading.stories.ts | 31 +++ frontend/src/composables/ariakit.ts | 188 ++++++++++++++++++ frontend/src/types/ariakit.ts | 10 + pnpm-lock.yaml | 8 + 11 files changed, 369 insertions(+), 13 deletions(-) create mode 100644 frontend/src/components/ariakit/heading/AHeading.composable.ts create mode 100644 frontend/src/components/ariakit/heading/AHeading.vue create mode 100644 frontend/src/components/ariakit/heading/AHeadingLevel.composable.ts create mode 100644 frontend/src/components/ariakit/heading/AHeadingLevel.vue create mode 100644 frontend/src/components/ariakit/heading/HeadingContext.ts create mode 100644 frontend/src/components/ariakit/heading/meta/AHeading.stories.ts create mode 100644 frontend/src/composables/ariakit.ts create mode 100644 frontend/src/types/ariakit.ts diff --git a/frontend/package.json b/frontend/package.json index 84ac65c1e33..d8703624eda 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,6 +60,7 @@ "doc:media-props": "node ./scripts/document-media.js" }, "dependencies": { + "@ariakit/core": "^0.4.10", "@floating-ui/dom": "^1.6.8", "@nuxtjs/plausible": "^1.0.2", "@nuxtjs/robots": "^4.0.2", @@ -91,6 +92,13 @@ "@nuxtjs/storybook": "npm:@nuxtjs/storybook@nightly", "@playwright/test": "1.46.1", "@storybook-vue/nuxt": "npm:@storybook-vue/nuxt@nightly", + "@storybook/addon-essentials": "8.2.9", + "@storybook/addon-interactions": "8.2.9", + "@storybook/addon-links": "8.2.9", + "@storybook/blocks": "8.2.9", + "@storybook/builder-vite": "8.2.9", + "@storybook/test": "8.2.9", + "@storybook/vue3": "8.2.9", "@testing-library/user-event": "^14.5.2", "@testing-library/vue": "^8.1.0", "@vitest/coverage-v8": "^2.0.5", @@ -105,21 +113,14 @@ "npm-run-all2": "^6.2.2", "nuxt": "3.13.1", "rimraf": "^6.0.1", + "storybook": "8.2.9", "talkback": "^4.2.0", "typescript": "5.5.4", "vitest": "^2.0.5", "vitest-dom": "^0.1.1", "vue": "3.5.0", "vue-router": "^4.4.0", - "vue-tsc": "2.1.4", - "storybook": "8.2.9", - "@storybook/vue3": "8.2.9", - "@storybook/addon-links": "8.2.9", - "@storybook/builder-vite": "8.2.9", - "@storybook/addon-essentials": "8.2.9", - "@storybook/addon-interactions": "8.2.9", - "@storybook/test": "8.2.9", - "@storybook/blocks": "8.2.9" + "vue-tsc": "2.1.4" }, "browserslist": [ "> 1%", diff --git a/frontend/src/components/VFourOhFour.vue b/frontend/src/components/VFourOhFour.vue index 638248dfe5d..cd33f5c8a25 100644 --- a/frontend/src/components/VFourOhFour.vue +++ b/frontend/src/components/VFourOhFour.vue @@ -7,6 +7,8 @@ import { useAnalytics } from "~/composables/use-analytics" import { ALL_MEDIA } from "~/constants/media" import { skipToContentTargetId } from "~/constants/window" +import AHeadingLevel from "~/components/ariakit/heading/AHeadingLevel.vue" +import AHeading from "~/components/ariakit/heading/AHeading.vue" import VLink from "~/components/VLink.vue" import VStandaloneSearchBar from "~/components/VHeader/VSearchBar/VStandaloneSearchBar.vue" import VSvg from "~/components/VSvg/VSvg.vue" @@ -47,14 +49,15 @@ useHead({ >
-
-

+ {{ $t("404.title") }} -

+

{{ error }}

@@ -68,7 +71,7 @@ useHead({

-
+
diff --git a/frontend/src/components/ariakit/heading/AHeading.composable.ts b/frontend/src/components/ariakit/heading/AHeading.composable.ts new file mode 100644 index 00000000000..841e1158b69 --- /dev/null +++ b/frontend/src/components/ariakit/heading/AHeading.composable.ts @@ -0,0 +1,40 @@ +import { computed, MaybeRef, onUnmounted } from "vue" + +import { useMemoize } from "@vueuse/core" + +import { useTagName } from "~/composables/ariakit" + +import type { Renderable } from "~/types/ariakit" + +import { HeadingContext } from "./HeadingContext" + +export interface UseHeadingOptions { + templateRef: MaybeRef + element?: Renderable +} + +export function useHeading({ templateRef, element }: UseHeadingOptions) { + const level = HeadingContext.inject() || 1 + const Element = element || (`h${level}` as const) + const tagName = useTagName( + templateRef, + typeof element === "string" ? element : `h${level}` + ) + const getIsNativeHeading = useMemoize((tagName: string) => + /^h\d$/.test(tagName) + ) + const isNativeHeading = computed( + () => !!tagName.value && getIsNativeHeading(tagName.value) + ) + onUnmounted(() => getIsNativeHeading.clear()) + + const attributes = computed(() => ({ + role: !isNativeHeading.value ? "heading" : undefined, + "aria-level": !isNativeHeading.value ? level : undefined, + })) + + return { + Element, + attributes, + } +} diff --git a/frontend/src/components/ariakit/heading/AHeading.vue b/frontend/src/components/ariakit/heading/AHeading.vue new file mode 100644 index 00000000000..50c9a24444d --- /dev/null +++ b/frontend/src/components/ariakit/heading/AHeading.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/components/ariakit/heading/AHeadingLevel.composable.ts b/frontend/src/components/ariakit/heading/AHeadingLevel.composable.ts new file mode 100644 index 00000000000..4ad9f99bb29 --- /dev/null +++ b/frontend/src/components/ariakit/heading/AHeadingLevel.composable.ts @@ -0,0 +1,15 @@ +import { HeadingContext, type HeadingLevels } from "./HeadingContext" + +export interface HeadingLevelOptions { + level?: HeadingLevels +} + +export function useHeadingLevel({ level }: HeadingLevelOptions) { + const contextLevel = HeadingContext.inject() || 0 + const nextLevel = Math.max( + Math.min(level || contextLevel + 1, 6), + 1 + ) as HeadingLevels + HeadingContext.provide(nextLevel) + return nextLevel +} diff --git a/frontend/src/components/ariakit/heading/AHeadingLevel.vue b/frontend/src/components/ariakit/heading/AHeadingLevel.vue new file mode 100644 index 00000000000..b3e17734eab --- /dev/null +++ b/frontend/src/components/ariakit/heading/AHeadingLevel.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/components/ariakit/heading/HeadingContext.ts b/frontend/src/components/ariakit/heading/HeadingContext.ts new file mode 100644 index 00000000000..56e40327938 --- /dev/null +++ b/frontend/src/components/ariakit/heading/HeadingContext.ts @@ -0,0 +1,17 @@ +import { provide, inject, type InjectionKey } from "vue" + +export type HeadingLevels = 1 | 2 | 3 | 4 | 5 | 6 + +export type HeadingContext = HeadingLevels +const HeadingContextKey: InjectionKey = + Symbol.for("ariakit-heading") + +export const HeadingContext = Object.freeze({ + key: HeadingContextKey, + provide(context: HeadingContext) { + provide(HeadingContextKey, context) + }, + inject() { + return inject(HeadingContextKey, 0) + }, +}) diff --git a/frontend/src/components/ariakit/heading/meta/AHeading.stories.ts b/frontend/src/components/ariakit/heading/meta/AHeading.stories.ts new file mode 100644 index 00000000000..332f0434559 --- /dev/null +++ b/frontend/src/components/ariakit/heading/meta/AHeading.stories.ts @@ -0,0 +1,31 @@ +import { h } from "vue" + +import AHeading from "~/components/ariakit/heading/AHeading.vue" + +import AHeadingLevel from "~/components/ariakit/heading/AHeadingLevel.vue" + +import type { Meta, StoryObj } from "@storybook/vue3" + +const meta = { + title: "Ariakit/Heading", +} satisfies Meta + +export default meta +type Story = StoryObj + +export const AHeadingStory: Story = { + render: () => ({ + components: { AHeading, AHeadingLevel }, + setup() { + return () => + h("div", { class: "wrapper" }, [ + h(AHeadingLevel, null, [ + h(AHeading, { class: "heading" }, ["First heading"]), + h(AHeadingLevel, null, [ + h(AHeading, { as: { element: "div" } }, ["Second heading"]), + ]), + ]), + ]) + }, + }), +} diff --git a/frontend/src/composables/ariakit.ts b/frontend/src/composables/ariakit.ts new file mode 100644 index 00000000000..c6cd8d33de6 --- /dev/null +++ b/frontend/src/composables/ariakit.ts @@ -0,0 +1,188 @@ +import { computed, onMounted, ref, unref, type MaybeRef } from "vue" +import { useMutationObserver } from "@vueuse/core" + +import { addGlobalEventListener } from "@ariakit/core/utils/events" + +/** + * `useSafeLayoutEffect` + * + * Strictly speaking, not need for an equivalent. + * + * See Vue documentation about watch timing: https://vuejs.org/guide/essentials/watchers.html#callback-flush-timing + * + * See this page for a comparison of React's effect lifecycle + * with Vue's watch: https://gist.github.com/AlexVipond/b3e6c39ab4e1ec7395114210ddcaff92 + * + * When `canUseDOM` is true, we'll pass `flush: "post"` to the watch options. + * TODO: How does Vue SSR interact with this? + */ + +/** + * `useInitialValue` + * + * Not strictly necessary. Vue `setup` only runs once, so to reference the initial value, + * either don't use a ref to being with, or use `unref` to remove reactivity. + * + * https://vuejs.org/guide/components/props.html#reactive-props-destructure + */ + +/** + * `useLazyValue` + * + * ??? + */ + +/** + * `useLiveRef` + * + * Not necessary. Just use a regular Vue `ref` + */ + +/** + * `usePreviousValue` + * + * Potentially unnecessary as `watch` already passes the previous value to the callback + * + * `@vueuse/core` `usePrevious` can be used if needed + */ + +/** + * `useEvent` + * + * ??? + */ + +/** + * `useMergeRefs` + * + * Probably not necessary. Instead, components should use `defineExpose` + * and expose the relevant inner ref. + */ + +/** + * `useId` + * + * https://blog.vuejs.org/posts/vue-3-5#useid + */ + +/** + * `useDeferredValue` + * + * `@vueuse/core`'s `refDeferred`? + */ + +/** + * `useTagName` + * + * Use a computed + */ +export function useTagName( + element?: MaybeRef, + type?: MaybeRef +) { + const tagName = computed(() => { + return unref(element)?.tagName.toLowerCase() || unref(type) + }) + + return tagName +} + +export function useAttribute( + element: MaybeRef, + attributeName: string, + defaultValue?: string +) { + const attribute = ref(defaultValue) + + useMutationObserver( + element, + (mutations) => { + const value = + (mutations[0]?.target as T | undefined)?.getAttribute(attributeName) ?? + null + if (value !== null) { + attribute.value = value + } + }, + { attributeFilter: [attributeName] } + ) + + return attribute +} + +/** + * `useUpdateEffect` + * + * Use `onMounted` + */ + +/** + * `useUpdateLayoutEffect` + * + * Use `onMounted` + */ + +/** + * `useForceUpdate` + * + * Use `getCurrentInstance().forceUpdate()`? + * + * Otherwise use `:key` with a changeable value... + */ + +/** + * `useBooleanEvent` + * + * Depends on usage. + */ + +/** + * `useWrapElement` + * + * Depends on usage. + */ + +/** + * `useMetadataProps` + * + * Unnecessary in Vue. All props must be declared in Vue + * and are always separate from attributes passed through + * to elements. There is never a risk of props being unintentionally + * leaked into DOM attributes. + */ + +export function useIsMouseMoving() { + onMounted(() => { + addGlobalEventListener("mousemove", setMouseMoving, true) + // See https://github.com/ariakit/ariakit/issues/1137 + addGlobalEventListener("mousedown", resetMouseMoving, true) + addGlobalEventListener("mouseup", resetMouseMoving, true) + addGlobalEventListener("keydown", resetMouseMoving, true) + addGlobalEventListener("scroll", resetMouseMoving, true) + }) + + return isMouseMoving +} + +/** This ref is used only in `onMounted` calls and so is SSR safe at the top level */ +const isMouseMoving = ref(false) +let previousScreenX = 0 +let previousScreenY = 0 + +function hasMouseMovement(event: MouseEvent) { + const movementX = event.movementX || event.screenX - previousScreenX + const movementY = event.movementY || event.screenY - previousScreenY + previousScreenX = event.screenX + previousScreenY = event.screenY + return movementX || movementY || process.env.NODE_ENV === "test" +} + +function setMouseMoving(event: MouseEvent) { + if (hasMouseMovement(event)) { + isMouseMoving.value = true + } +} + +function resetMouseMoving() { + isMouseMoving.value = false +} diff --git a/frontend/src/types/ariakit.ts b/frontend/src/types/ariakit.ts new file mode 100644 index 00000000000..302f4abc564 --- /dev/null +++ b/frontend/src/types/ariakit.ts @@ -0,0 +1,10 @@ +import { Component, ComponentInstance, h } from "vue" + +type ComponentProps = ComponentInstance["$props"] + +export type Renderable = Parameters[0] + +export interface AsProps { + as?: E + asProps?: E extends Component ? ComponentProps : never +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 78580be09df..53a7bacae81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: frontend: dependencies: + '@ariakit/core': + specifier: ^0.4.10 + version: 0.4.10 '@floating-ui/dom': specifier: ^1.6.8 version: 1.6.8 @@ -398,6 +401,9 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + '@ariakit/core@0.4.10': + resolution: {integrity: sha512-mX3EabQbfVh5uTjsTJ3+gjj7KGdTNhIN0qZHJd5Z2iPUnKl9NBy23Lgu6PEskpVsKAZ3proirjguD7U9fKMs/A==} + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -8257,6 +8263,8 @@ snapshots: '@antfu/utils@0.7.10': {} + '@ariakit/core@0.4.10': {} + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 From d80a10fcf3d13e753996eb7f60dd6b0a7d2eff88 Mon Sep 17 00:00:00 2001 From: sarayourfriend Date: Tue, 24 Sep 2024 19:09:57 -0600 Subject: [PATCH 2/2] Add ASeparator --- .../separator/ASeparator.composable.ts | 14 +++++++ .../ariakit/separator/ASeparator.vue | 20 ++++++++++ .../separator/meta/ASeparator.stories.ts | 40 +++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 frontend/src/components/ariakit/separator/ASeparator.composable.ts create mode 100644 frontend/src/components/ariakit/separator/ASeparator.vue create mode 100644 frontend/src/components/ariakit/separator/meta/ASeparator.stories.ts diff --git a/frontend/src/components/ariakit/separator/ASeparator.composable.ts b/frontend/src/components/ariakit/separator/ASeparator.composable.ts new file mode 100644 index 00000000000..91a29399c03 --- /dev/null +++ b/frontend/src/components/ariakit/separator/ASeparator.composable.ts @@ -0,0 +1,14 @@ +import { computed, MaybeRef, unref } from "vue" + +export interface SeparatorOptions { + orientation?: MaybeRef<"horizontal" | "vertical"> +} + +export function useSeparator({ orientation = "horizontal" }: SeparatorOptions) { + const attributes = computed(() => ({ + role: "separator", + "aria-orientation": unref(orientation), + })) + + return { attributes } +} diff --git a/frontend/src/components/ariakit/separator/ASeparator.vue b/frontend/src/components/ariakit/separator/ASeparator.vue new file mode 100644 index 00000000000..0dd92e8405c --- /dev/null +++ b/frontend/src/components/ariakit/separator/ASeparator.vue @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/components/ariakit/separator/meta/ASeparator.stories.ts b/frontend/src/components/ariakit/separator/meta/ASeparator.stories.ts new file mode 100644 index 00000000000..724a181b351 --- /dev/null +++ b/frontend/src/components/ariakit/separator/meta/ASeparator.stories.ts @@ -0,0 +1,40 @@ +import { h } from "vue" + +import ASeparator from "~/components/ariakit/separator/ASeparator.vue" + +import type { Meta, StoryObj } from "@storybook/vue3" + +const meta = { + title: "Ariakit/Separator", +} satisfies Meta + +export default meta +type Story = StoryObj + +export const AHeadingStory: Story = { + render: () => ({ + components: { ASeparator }, + setup() { + return () => + h("p", { class: "wrapper" }, [ + "Content before the separator", + h(ASeparator, null, []), + "Content after the separator", + ]) + }, + }), +} + +export const AHeadingAsStory: Story = { + render: () => ({ + components: { ASeparator }, + setup() { + return () => + h("p", { class: "wrapper" }, [ + "The separator below is rendered as a styled div", + h(ASeparator, { as: "div", class: "border border-dashed" }, []), + "If you inspect it, you'll see that AHeading set the correct role and aria attributes to make the div a separator", + ]) + }, + }), +}