diff --git a/packages/c-live-region/README.md b/packages/c-live-region/README.md new file mode 100644 index 00000000..63355f8a --- /dev/null +++ b/packages/c-live-region/README.md @@ -0,0 +1,11 @@ +# @chakra-ui/c-live-region + +Live region + +## Installation + +```sh +yarn add @chakra-ui/c-live-region +# or +npm i @chakra-ui/c-live-region +``` \ No newline at end of file diff --git a/packages/c-live-region/examples/base-live-region.vue b/packages/c-live-region/examples/base-live-region.vue new file mode 100644 index 00000000..1f1493c3 --- /dev/null +++ b/packages/c-live-region/examples/base-live-region.vue @@ -0,0 +1,10 @@ + + diff --git a/packages/c-live-region/index.tsx b/packages/c-live-region/index.tsx new file mode 100644 index 00000000..46e72b16 --- /dev/null +++ b/packages/c-live-region/index.tsx @@ -0,0 +1 @@ +export * from "./src" diff --git a/packages/c-live-region/package.json b/packages/c-live-region/package.json new file mode 100644 index 00000000..d5d8cb66 --- /dev/null +++ b/packages/c-live-region/package.json @@ -0,0 +1,39 @@ +{ + "name": "@chakra-ui/c-live-region", + "description": "Chakra UI Vue | Live region component", + "version": "0.0.0-alpha.0", + "main": "dist/chakra-ui-c-live-region.cjs.js", + "module": "dist/chakra-ui-c-live-region.esm.js", + "author": "Jonathan Bakebwa ", + "homepage": "https://github.com/chakra-ui/chakra-ui-vue-next#readme", + "license": "MIT", + "files": [ + "dist" + ], + "exports": { + ".": { + "require": "./dist/chakra-ui-c-live-region.cjs.js", + "default": "./dist/chakra-ui-c-live-region.esm.js" + } + }, + "repository": "https://github.com/chakra-ui/chakra-ui-vue-next/tree/master/packages/c-live-region", + "bugs": { + "url": "https://github.com/chakra-ui/chakra-ui-vue-next/issues" + }, + "sideEffects": false, + "scripts": { + "clean": "rimraf dist" + }, + "dependencies": { + "@chakra-ui/vue-system": "0.1.0-alpha.10" + }, + "devDependencies": { + "vue": "^3.2.37" + }, + "peerDependencies": { + "vue": "^3.1.4" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/c-live-region/src/index.tsx b/packages/c-live-region/src/index.tsx new file mode 100644 index 00000000..2f32d288 --- /dev/null +++ b/packages/c-live-region/src/index.tsx @@ -0,0 +1 @@ +export * from "./live-region" diff --git a/packages/c-live-region/src/live-region.ts b/packages/c-live-region/src/live-region.ts new file mode 100644 index 00000000..5dc330ff --- /dev/null +++ b/packages/c-live-region/src/live-region.ts @@ -0,0 +1,139 @@ +import { HTMLAttributes } from "vue" + +function isDom() { + return Boolean(globalThis?.document) +} +const isBrowser = isDom() +export interface LiveRegionOptions { + /** + * A unique id for the created live region element + */ + id?: string + /** + * Used to mark a part of the page as "live" so that updates will + * be communicated to users by screen readers. + * + * - If set to `polite`: tells assistive technology to alert the user + * to this change when it has finished whatever it is currently doing + * + * - If set to `assertive`: tells assistive technology to interrupt whatever + * it is doing and alert the user to this change immediately + * + * @default "polite". + */ + "aria-live"?: "polite" | "assertive" + /** + * The desired value of the role attribute + * @default "status" + */ + role?: "status" | "alert" | "log" + /** + * Indicates what types of changes should be presented to the user. + * @default "all" + */ + "aria-relevant"?: HTMLAttributes["aria-relevant"] + /** + * Indicates whether the entire region should be + * considered as a whole when communicating updates + * + * @default true + */ + "aria-atomic"?: HTMLAttributes["aria-atomic"] + /** + * The node to append the live region node to + */ + parentNode?: HTMLElement +} + +export class LiveRegion { + region: HTMLElement | null + options: Required + parentNode: HTMLElement + + constructor(options?: LiveRegionOptions) { + this.options = getOptions(options) as any + this.region = getRegion(this.options) + this.parentNode = this.options.parentNode + if (this.region) { + this.parentNode.appendChild(this.region) + } + } + + /** + * Message provided to the region to be read out by the Screen Reader. + * + * Message can be supplied on trigger of some event (i.e. button click) + */ + public speak(message: string) { + this.clear() + if (this.region) { + this.region.innerText = message + } + } + + /** + * Removes the region. + */ + public destroy() { + if (this.region) { + this.region.parentNode?.removeChild(this.region) + } + } + + /** + * Clears the inner text of the region + */ + public clear() { + if (this.region) { + this.region.innerText = "" + } + } +} + +function getOptions(options?: LiveRegionOptions) { + const defaultOptions: LiveRegionOptions = { + "aria-live": "polite", + "aria-atomic": "true", + "aria-relevant": "all", + role: "status", + id: "chakra-a11y-live-region", + parentNode: isBrowser ? document.body : undefined, + } + if (options) { + return Object.assign(defaultOptions, options) + } + return defaultOptions +} + +function getRegion(options: Required) { + let region = isBrowser ? document.getElementById(options.id) : null + + if (region) return region + + if (isBrowser) { + region = document.createElement("div") + setup(region, options) + } + + return region +} + +function setup(region: HTMLElement, options: Required) { + region.id = options.id || "chakra-live-region" + region.className = "__chakra-live-region" + region.setAttribute("aria-live", options["aria-live"]) + region.setAttribute("role", options.role) + region.setAttribute("aria-relevant", options["aria-relevant"]) + region.setAttribute("aria-atomic", String(options["aria-atomic"])) + Object.assign(region.style, { + border: "0px", + clip: "rect(0px, 0px, 0px, 0px)", + height: "1px", + width: "1px", + margin: "-1px", + padding: "0px", + overflow: "hidden", + whiteSpace: "nowrap", + position: "absolute", + }) +} diff --git a/packages/c-live-region/src/use-live-region.ts b/packages/c-live-region/src/use-live-region.ts new file mode 100644 index 00000000..c6c46763 --- /dev/null +++ b/packages/c-live-region/src/use-live-region.ts @@ -0,0 +1,14 @@ +import { reactive, watchEffect } from "vue" +import { LiveRegion, LiveRegionOptions } from "./live-region" + +/** + * Creates a hidden live region with dynamic content based on triggered events + * to be read out by the screen reader on change of the content. + */ +export function useLiveRegion(options?: LiveRegionOptions) { + const liveRegion = reactive(() => new LiveRegion(options)) + + watchEffect((cleanup) => cleanup(() => liveRegion().destroy())) + + return liveRegion() +} diff --git a/packages/c-live-region/tests/c-live-region.test.ts b/packages/c-live-region/tests/c-live-region.test.ts new file mode 100644 index 00000000..d9d924a9 --- /dev/null +++ b/packages/c-live-region/tests/c-live-region.test.ts @@ -0,0 +1,54 @@ +import { render } from "@chakra-ui/vue-test-utils" +import { LiveRegion, LiveRegionOptions } from "../index" + +const renderDiv = () => + render({ + template: `
`, + }) + +describe("LiveRegion", () => { + it("creates a container and has the proper aria and role attributes", () => { + renderDiv() + + new LiveRegion() + const region = document.getElementById("chakra-a11y-live-region") + + expect(region).toHaveAttribute("aria-atomic", "true") + expect(region).toHaveAttribute("aria-live", "polite") + expect(region).toHaveAttribute("aria-relevant", "all") + expect(region).toHaveAttribute("role", "status") + }) + + it("creates a container using the provided options", () => { + renderDiv() + + const options: LiveRegionOptions = { + id: "some-id", + role: "alert", + "aria-live": "assertive", + "aria-relevant": "removals", + "aria-atomic": "false", + } + + // eslint-disable-next-line no-new + new LiveRegion(options) + const region = document.getElementById("some-id") + + expect(region).toHaveAttribute("aria-atomic", "false") + expect(region).toHaveAttribute("aria-live", "assertive") + expect(region).toHaveAttribute("aria-relevant", "removals") + expect(region).toHaveAttribute("role", "alert") + }) + + it("can 'speak' by setting its text content", () => { + renderDiv() + + const liveRegion = new LiveRegion() + const region = document.getElementById( + "chakra-a11y-live-region" + ) as HTMLElement + + liveRegion.speak("Hello World") + expect(region.innerText).toEqual("Hello World") + }) +}) diff --git a/packages/c-live-region/tsconfig.json b/packages/c-live-region/tsconfig.json new file mode 100644 index 00000000..fc56401d --- /dev/null +++ b/packages/c-live-region/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "./index.tsx", "./index.ts"] +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 81fc8caf..6d92b556 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "@chakra-ui/c-form-control": "0.0.0-alpha.5", "@chakra-ui/c-icon": "1.0.0-alpha.10", "@chakra-ui/c-input": "0.0.0-alpha.5", + "@chakra-ui/c-live-region": "0.0.0-alpha.0", "@chakra-ui/c-modal": "1.1.0-alpha.10", "@chakra-ui/c-motion": "0.1.0-alpha.9", "@chakra-ui/c-pin-input": "0.0.0-alpha.0", @@ -47,7 +48,7 @@ "@chakra-ui/c-theme-provider": "1.0.0-alpha.10", "@chakra-ui/c-visually-hidden": "1.0.0-alpha.10", "@chakra-ui/styled-system": "^2.2.2", - "@chakra-ui/utils": "^2.0.3", + "@chakra-ui/utils": "2.0.11", "@chakra-ui/vue-a11y": "0.1.0-alpha.9", "@chakra-ui/vue-composables": "0.1.0-alpha.9", "@chakra-ui/vue-layout": "0.1.0-alpha.11", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6a1b8041..42295d98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -147,6 +147,7 @@ export * from "@chakra-ui/c-input" // L export * from "@chakra-ui/vue-layout" +export * from "@chakra-ui/c-live-region" // M export * from "@chakra-ui/c-modal"