-
Notifications
You must be signed in to change notification settings - Fork 31
Create CLiveRegion component
#206
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <template> | ||
| <button @click="region.speak('Filtering categories was successful')"> | ||
| Click Me | ||
| </button> | ||
| </template> | ||
| <script setup lang="ts"> | ||
| import { useLiveRegion } from "../src/use-live-region" | ||
|
|
||
| const region = useLiveRegion() | ||
| </script> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./src" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <codebender828@gmail.com>", | ||
| "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" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from "./live-region" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<LiveRegionOptions> | ||
| 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<LiveRegionOptions>) { | ||
| 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<LiveRegionOptions>) { | ||
| 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", | ||
| }) | ||
| } | ||
|
Comment on lines
+121
to
+139
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems better to me to pass a Also, this setup function also looks like it can also just be a component |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| } | ||
|
Comment on lines
+8
to
+14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After thinking about it, seem better to have a All the options passed can basically be props and the rest of the code would be much simpler and more aligned with the Vue philosophy. What is your opinion about this @codebender828 , @TylerAPfledderer ?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Shyrro @codebender828 for context and my understanding of The developer can certainly access the other data to make their own reason, but it did not seem to be intended by default for someone to have to make an element just for a screen reader to readout a simple message on change from some event. I would not mind converting to a component, with composables to house the props that can be exported separately, but then the question would be how this new component is to be implemented in the environment. I'm primarily thinking about how the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tbh if you want to access methods of the components or other data you can expose them through the And for the fact the the component is outside the screen it doesn't really matter if you wrap it in a It just feels the "Vue way" so to say. Also, when manipulating the DOM directly through I might be wrong about this since this is a special hook, but it just feels more natural in Vue to have in this case a logical component instead of a hook.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Understood! I'll take a look at I'll play around with it, because you could probably argue the same case against direct DOM manipulation in React here with the reactivity, so maybe I can ask one of the React folks on the reason for this approach. 🤔 Better for me to know at least so I can get a better idea on differences! 😄
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let me know if u need any more clarification on what i'm saying or any more explanation on this approach. We can even discuss it in a call, might be easier |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { render } from "@chakra-ui/vue-test-utils" | ||
| import { LiveRegion, LiveRegionOptions } from "../index" | ||
|
|
||
| const renderDiv = () => | ||
| render({ | ||
| template: `<div/>`, | ||
| }) | ||
|
|
||
| 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") | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "extends": "../../tsconfig.json", | ||
| "include": ["src", "./index.tsx", "./index.ts"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @TylerAPfledderer
Can't this class be a component instead? It seem that it only creates/appends attributes to certain elements, which seems better as a component to me.
Also, using methods such as
removeChildshould only be done when there is no other solution, otherwise it's a bit of an anti-pattern in Vue. So i'd say that if we have the choice let's create a component.