├── .eslintignore ├── .github └── CONTRIBUTING.md ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── config └── typescript │ └── tsconfig.build.json ├── lerna.json ├── package.json ├── packages ├── aria-composables │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── LICENSE │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── composables │ │ │ ├── __tests__ │ │ │ │ ├── click-outside.spec.ts │ │ │ │ ├── events.spec.ts │ │ │ │ ├── focus-observer.spec.ts │ │ │ │ ├── id.spec.ts │ │ │ │ ├── keys.spec.ts │ │ │ │ ├── reactive-defaults.spec.ts │ │ │ │ └── template-refs.spec.ts │ │ │ ├── arrow-navigation.ts │ │ │ ├── click-outside.ts │ │ │ ├── events.ts │ │ │ ├── focus-observer.ts │ │ │ ├── index.ts │ │ │ ├── keys.ts │ │ │ ├── reactive-defaults.ts │ │ │ ├── state.ts │ │ │ └── template-refs.ts │ │ ├── helpers │ │ │ ├── __tests__ │ │ │ │ ├── focus-tracker.spec.ts │ │ │ │ └── tab-direction.spec.ts │ │ │ ├── focus-tracker.ts │ │ │ ├── index.ts │ │ │ └── tab-direction.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ ├── apply-focus.ts │ │ │ ├── focusable-elements.ts │ │ │ ├── id.ts │ │ │ ├── index.ts │ │ │ └── inject.ts │ ├── test │ │ └── helpers.ts │ └── tsconfig.build.json ├── aria-widgets │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierignore │ ├── .prettierrc │ ├── LICENSE │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ │ ├── Accordion │ │ │ ├── AccordionContent.ts │ │ │ ├── AccordionHeader.ts │ │ │ ├── index.ts │ │ │ └── use-accordion.ts │ │ ├── Button │ │ │ ├── Button.ts │ │ │ └── index.ts │ │ ├── Clickable │ │ │ ├── index.ts │ │ │ └── use-clickable.ts │ │ ├── Dialog │ │ │ ├── DialogContent.ts │ │ │ ├── DialogTrigger.ts │ │ │ ├── index.ts │ │ │ └── useDialog.ts │ │ ├── Disclosure │ │ │ ├── DisclosureContent.ts │ │ │ ├── DisclosureTrigger.ts │ │ │ ├── index.ts │ │ │ └── use-disclosure.ts │ │ ├── FocusTrap │ │ │ ├── FocusTrap.ts │ │ │ ├── index.css │ │ │ ├── index.ts │ │ │ └── inert.ts │ │ ├── ListBox │ │ │ ├── ListBoxItem.ts │ │ │ ├── index.css │ │ │ ├── index.ts │ │ │ └── useListBox.ts │ │ ├── Popover │ │ │ ├── PopoverContent.ts │ │ │ ├── PopoverTrigger.ts │ │ │ ├── index.ts │ │ │ └── usePopover.ts │ │ ├── SkipToContent │ │ │ └── index.ts │ │ ├── Tabbable │ │ │ ├── index.ts │ │ │ └── use-tabbable.ts │ │ ├── Tabs │ │ │ ├── Tab.ts │ │ │ ├── TabList.ts │ │ │ ├── TabPanel.ts │ │ │ ├── index.css │ │ │ ├── index.ts │ │ │ └── use-tabs.ts │ │ ├── Teleport │ │ │ ├── Teleport.ts │ │ │ └── index.ts │ │ ├── ToggleButton │ │ │ └── index.ts │ │ ├── VisuallyHidden │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ ├── index.js │ │ │ └── pick.ts │ ├── templates │ │ ├── README.md │ │ ├── childComponent.ts │ │ └── wrapperComponent.ts │ ├── tsconfig.build.json │ ├── tsdx.config.js │ └── yarn.lock ├── dev-server │ ├── .eslintrc.js │ ├── App.vue │ ├── LICENSE │ ├── assets │ │ └── tailwind.css │ ├── components │ │ └── SignupForm.vue │ ├── index.html │ ├── main.ts │ ├── package.json │ ├── postcss.config.js │ ├── router │ │ ├── index.ts │ │ └── routes.ts │ ├── tailwind.config.js │ ├── views │ │ ├── Accordions.vue │ │ ├── Buttons.vue │ │ ├── Dialogs.vue │ │ ├── Disclosures.vue │ │ ├── FocusTraps.vue │ │ ├── Home.vue │ │ ├── ListBox.vue │ │ ├── Popovers.vue │ │ └── Tabs.vue │ └── vite.config.js ├── docs │ ├── .vuepress │ │ └── config │ │ │ └── config.js │ ├── README.md │ ├── content │ │ └── README.md │ └── package.json └── examples │ ├── .browserslistrc │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── cypress.json │ ├── package.json │ ├── postcss.config.js │ ├── public │ ├── favicon.ico │ └── index.html │ ├── src │ ├── App.vue │ ├── assets │ │ ├── logo.png │ │ └── tailwind.css │ ├── components │ │ └── SignupForm.vue │ ├── main.ts │ ├── router │ │ └── index.ts │ ├── shims-vue.d.ts │ └── views │ │ ├── Accordions.vue │ │ ├── Buttons.vue │ │ ├── Dialogs.vue │ │ ├── Disclosures.vue │ │ ├── FocusTraps.vue │ │ ├── Home.vue │ │ ├── ListBox.vue │ │ ├── Popovers.vue │ │ └── Tabs.vue │ ├── tailwind.config.js │ ├── tests │ └── e2e │ │ ├── .eslintrc.js │ │ ├── plugins │ │ └── index.js │ │ ├── specs │ │ └── test.js │ │ └── support │ │ ├── commands.js │ │ └── index.js │ ├── tsconfig.json │ ├── vue.config.js │ └── yarn.lock ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | /packages/*/dist -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | _TBD_ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | # custom 24 | /TODO.md 25 | /NOTES.md -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12 2 | v11 3 | v10 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "semi": false 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Thorsten Lünborg 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-aria 2 | 3 | > WAI-ARIA compliant components that come without any styles, plus lower-level utility composables. 4 | > Meant as a base for building your own component collection while ensuring solid A11y from the get-go. 5 | 6 | ## WARNING: EXPERIMENTAL 7 | 8 | This project is currently at an exploratory/experimental stage in its development. 9 | We are still in the process of figuring out the patterns and designed APIs suited for those patterns. 10 | 11 | - APIs will change frequently and without notice. 12 | - Many things don't work, and if they do, might break easily. 13 | - Even more things are still completely missing. 14 | 15 | So: 16 | 17 | - If you want to contribute to the project, please see [the "For Developers" Notes](#for-developers). 18 | - If you want to use this already: don't. 19 | 20 | ## Documentation 21 | 22 | - Source: see `./packages/docs` 23 | - Live: _TBD_ 24 | 25 | ## Installation 26 | 27 | ```javascript 28 | import { createApp } from 'vue' 29 | // import the plugin 30 | import { install as plugin } from '@varia/widgets' 31 | 32 | import App from './App.vue' 33 | 34 | createApp(App) 35 | .use(plugin) // add the plugin to your app 36 | .mount('#app') 37 | ``` 38 | 39 | ## State of Development 40 | 41 | 42 |
43 | Click here to the the state of development 44 | 45 | ### Basics 46 | 47 | #### `` 48 | 49 | - [x] Implementation 50 | - ~~Examples~~ 51 | - [ ] Documentation 52 | - [ ] Unit Tests 53 | - [ ] E2e Tests 54 | 55 | #### `` 56 | 57 | - [x] Implementation 58 | - ~~Examples~~ 59 | - [ ] Documentation 60 | - [ ] Unit Tests 61 | - [ ] E2e Tests 62 | 63 | #### `
190 | 191 | ## Usage 192 | 193 | ### Tabs Component 194 | 195 | ```html 196 | 209 | 210 | 227 | ``` 228 | 229 | This gives you: 230 | 231 | - a fully functional Tabs UI. 232 | - which is completely unstyled. 233 | - yet fully accessible (WAI-ARIA 1.2 spec-compliant) 234 | - commuication between `useTabs()`, `` and `` abstracted away through `provide/inject` 235 | 236 | ### Customizing through composition 237 | 238 | - _TBD_ 239 | 240 | ### Using a lower-level composable 241 | 242 | - _TBD_ 243 | 244 | ## Aknowledgements 245 | 246 | I've studied other projects that share the same or or a similar close in my endavour, some more closely than others. If I took anything specific from them you'll find mentions in the source. 247 | 248 | Projects: 249 | 250 | - Vuetensils 251 | - Reach-UI 252 | - Reakit 253 | 254 | ## For Developers 255 | 256 | This project is set up as a monorepo using lerna and yarn workspaces. For this reason, yarn is required to contribute to this project, all found in the `./packages` directory. 257 | 258 | - `@varia/composables`: a suppor package providing lower-level composables, upon which the components in `aria-widgets` are built. Can be used standalone as well. 259 | - `@varia/widgets`: the main package, exporting all of the components. 260 | - `docs`: the project's documentation, built with [Vuepress](vuepress.vuejs.org) 261 | - `examples`: a Vue CLI app containing examples for all of the components from `aria-widgets`. used for e2e tests 262 | 263 | For more information on how to contribute, please see [the contribution guide](./github/CONTRIBUTING.md) 264 | -------------------------------------------------------------------------------- /config/typescript/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules", "dist"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | // "noUnusedLocals": true, 18 | // "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "node", 22 | "esModuleInterop": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmClient": "yarn", 3 | "packages": ["packages/*"], 4 | "version": "0.0.0", 5 | "useWorkspaces": true 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "varia", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "build": "lerna run build --scope @varia/*", 10 | "serve": "lerna run serve --parallel --scope @varia/*", 11 | "vite": "yarn workspace dev-server run dev", 12 | "examples": "yarn workspace examples run serve", 13 | "docs:dev": "yarn workspace docs run dev", 14 | "docs:build": "yarn workspace docs run build" 15 | }, 16 | "devDependencies": { 17 | "lerna": "^3.20.2" 18 | }, 19 | "dependencies": { 20 | "patch-package": "^6.2.2", 21 | "postinstall-postinstall": "^2.1.0", 22 | "tsdx": "^0.13.1", 23 | "typescript": "^3.8.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/aria-composables/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'plugin:vue/essential', 5 | '@vue/typescript', 6 | 'prettier/@typescript-eslint', 7 | 'plugin:prettier/recommended', 8 | ], 9 | rules: { 10 | eqeqeq: 'off', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /packages/aria-composables/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /packages/aria-composables/.prettierignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /types -------------------------------------------------------------------------------- /packages/aria-composables/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "semi": false 6 | } -------------------------------------------------------------------------------- /packages/aria-composables/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Thorsten Lünborg 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /packages/aria-composables/README.md: -------------------------------------------------------------------------------- 1 | # `@varia/composables` 2 | 3 | > This package is part of the varia project, a collection of Vue 3 components that provide unstyled, but fully accessible building blocks for popular UI concepts. 4 | 5 | ## What This Is 6 | 7 | The `composables` package is kind of the core package of `@varia`. It provides a set of lower-level composables and utility functions that the actual component packages can build upon to implement their logic. 8 | 9 | As such, it's listed as a `dependency` for pretty much every component package in this monorepo. 10 | 11 | ## Provided Functionality 12 | 13 | Composables: 14 | 15 | - track focus of a single element or a group of elements 16 | - track tab direction 17 | - track keybord events and call functions when specific conditions are met 18 | - arrow navigation primitives (both for "roving tabindex" and `aria-activedescendant` patterns) 19 | - track clicks outside of an element 20 | 21 | Utilities: 22 | 23 | - id helpers: generate random ids and cache them for re-use 24 | - query helpers to find focusable elements in various ways and situations (i.e. "get the first focusable Element that is a descendant of `provided element`") 25 | - a small set of reactivity helpers to improve writing of composition functions. 26 | -------------------------------------------------------------------------------- /packages/aria-composables/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | } 4 | -------------------------------------------------------------------------------- /packages/aria-composables/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@varia/composables", 3 | "version": "0.0.0", 4 | "description": "Core composables for @varia/widgets", 5 | "author": "Thorsten ", 6 | "homepage": "https://github.com/LinusBorg/varia/packages/aria-composables", 7 | "license": "Apache-2.0", 8 | "main": "dist/index.js", 9 | "module": "dist/composables.esm.js", 10 | "typings": "dist/index.d.ts", 11 | "files": [ 12 | "dist", 13 | "src" 14 | ], 15 | "scripts": { 16 | "start": "tsdx watch --tsconfig tsconfig.build.json", 17 | "serve": "tsdx watch --tsconfig tsconfig.build.json --noClean", 18 | "build": "tsdx build --tsconfig tsconfig.build.json", 19 | "test": "tsdx test", 20 | "test:watch": "tsdx test --watch --runInBand", 21 | "lint": "eslint src/**/*.ts test/**/*.ts --fix" 22 | }, 23 | "dependencies": { 24 | "cuid": "^2.1.8" 25 | }, 26 | "peerDependencies": { 27 | "vue": "^3.0.0-beta.15" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/dom": "^7.2.1", 31 | "@types/jest": "^25.2.1", 32 | "@vue/eslint-config-typescript": "^5.0.1", 33 | "@vue/test-utils": "^2.0.0-alpha.7", 34 | "eslint-plugin-vue": "^7.0.0-alpha.7", 35 | "husky": "^4.2.5", 36 | "tslib": "^1.11.1", 37 | "typescript": "^3.8.3", 38 | "vue": "^3.0.0-beta.15" 39 | }, 40 | "husky": { 41 | "hooks": { 42 | "pre-commit": "yarn lint" 43 | } 44 | }, 45 | "publishConfig": { 46 | "access": "public", 47 | "registry": "https://registry.yarnpkg.com" 48 | }, 49 | "engines": { 50 | "node": ">=10" 51 | }, 52 | "repository": { 53 | "type": "git", 54 | "url": "git+https://github.com/LinusBorg/varia.git" 55 | }, 56 | "bugs": { 57 | "url": "https://github.com/LinusBorg/varia/issues" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/click-outside.spec.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, ref, nextTick } from 'vue' 2 | import { TemplRef } from '../../types' 3 | import { useClickOutside } from '../click-outside' 4 | import { mount } from '@vue/test-utils' 5 | 6 | describe('Click Outside', () => { 7 | const template = ` 8 |
9 | 10 |
11 | 12 |
13 |
14 | ` 15 | 16 | const createComponent = (spy: (...args: any[]) => any) => 17 | defineComponent({ 18 | template, 19 | setup() { 20 | const insideBtn: TemplRef = ref() 21 | const outsideBtn: TemplRef = ref() 22 | const wrapper: TemplRef = ref() 23 | 24 | useClickOutside([wrapper], spy) 25 | return { 26 | insideBtn, 27 | outsideBtn, 28 | wrapper, 29 | } 30 | }, 31 | }) 32 | 33 | it('works', async () => { 34 | const spy = jest.fn() 35 | const spy2 = jest.fn() 36 | const component = createComponent(spy) 37 | const wrapper = mount(component, { 38 | attachTo: document.body, 39 | }) 40 | document.addEventListener('click', spy2) 41 | await nextTick() 42 | await nextTick() 43 | 44 | const elInner = document.getElementById('inside_button') 45 | expect(elInner).toBeDefined() 46 | elInner?.click() 47 | await nextTick() 48 | expect(spy).toHaveBeenCalledTimes(0) 49 | 50 | const elOuter = document.getElementById('outside_button') 51 | expect(elOuter).toBeDefined() 52 | elOuter?.click() 53 | await nextTick() 54 | 55 | expect(spy2).toHaveBeenCalledTimes(2) 56 | expect(spy).toHaveBeenCalledTimes(1) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/events.spec.ts: -------------------------------------------------------------------------------- 1 | import { useEvent, useEventIf } from '../events' 2 | 3 | import { ref, nextTick } from 'vue' 4 | import { fireEvent } from '@testing-library/dom' 5 | 6 | describe('useEvent', () => { 7 | it('adds Listener', async () => { 8 | const el = document.createElement('button') 9 | const spy = jest.fn() 10 | const unwatch = useEvent(el, 'click', spy) 11 | el.click() 12 | 13 | await nextTick() 14 | 15 | expect(spy).toHaveBeenCalled() 16 | unwatch() 17 | }) 18 | 19 | it('unwatch removes listeners', async () => { 20 | const el = document.createElement('button') 21 | const spy = jest.fn() 22 | const unwatch = useEvent(el, 'click', spy) 23 | el.click() 24 | unwatch() 25 | el.click() 26 | await nextTick() 27 | 28 | expect(spy).toHaveBeenCalledTimes(1) 29 | }) 30 | 31 | it('swaps Listeners', async () => { 32 | const el1 = document.createElement('button') 33 | const el2 = document.createElement('button') 34 | const spy = jest.fn() 35 | const elRef = ref(el1) 36 | const unwatch = useEvent(elRef, 'click', spy) 37 | elRef.value.click() 38 | elRef.value = el2 39 | 40 | await nextTick() 41 | elRef.value.click() 42 | 43 | await nextTick() 44 | expect(spy).toHaveBeenCalledTimes(2) 45 | expect(spy.mock.calls[0][0].target).toBe(el1) 46 | expect(spy.mock.calls[1][0].target).toBe(el2) 47 | 48 | unwatch() 49 | }) 50 | 51 | it('removes listeners when elRef is empty', async () => { 52 | const el1 = document.createElement('button') 53 | const el2 = document.createElement('button') 54 | const spy = jest.fn() 55 | const elRef = ref(el1) 56 | const unwatch = useEvent(elRef, 'click', spy) 57 | elRef.value!.click() 58 | 59 | elRef.value = undefined 60 | await nextTick() 61 | el1.click() 62 | await nextTick() 63 | expect(spy).toHaveBeenCalledTimes(1) 64 | 65 | unwatch() 66 | }) 67 | }) 68 | 69 | describe('useEventIf', () => { 70 | it('triggers only when conditionRef is true', async () => { 71 | const el = document.createElement('button') 72 | const spy = jest.fn() 73 | const conditionRef = ref(false) 74 | const unwatch = useEventIf(conditionRef, el, 'click', spy) 75 | 76 | el.click() 77 | await nextTick() 78 | expect(spy).not.toHaveBeenCalled() 79 | 80 | conditionRef.value = true 81 | await nextTick() 82 | el.click() 83 | expect(spy).toHaveBeenCalled() 84 | unwatch() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/focus-observer.spec.ts: -------------------------------------------------------------------------------- 1 | import { useElementFocusObserver } from '../focus-observer' 2 | import { TemplRef } from '../../types' 3 | import { ref, nextTick } from 'vue' 4 | import { fireEvent } from '@testing-library/dom' 5 | 6 | describe('Focus Observers', () => { 7 | const addEl = (name = 'BUTTON', id = 'id') => { 8 | const el = document.createElement(name) 9 | el.setAttribute('id', id) 10 | document.body.append(el) 11 | } 12 | beforeEach(() => { 13 | addEl() 14 | }) 15 | afterEach(() => { 16 | document.body.innerHTML = '' 17 | }) 18 | it('useElementFocusObserver', async () => { 19 | const el = document.getElementById('id') 20 | if (!el) throw new Error('element not found') 21 | const elRef: TemplRef = ref() 22 | const { hasFocus } = useElementFocusObserver(elRef) 23 | 24 | expect(hasFocus.value).toBe(false) 25 | fireEvent.focus(el) 26 | fireEvent.focusIn(el) 27 | elRef.value = el 28 | await nextTick() 29 | expect(hasFocus.value).toBe(true) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/id.spec.ts: -------------------------------------------------------------------------------- 1 | import { createCachedIdFn } from '../../utils/id' 2 | 3 | describe('createCachedIdFn', () => { 4 | it('returns a function', () => { 5 | const idGen = createCachedIdFn() 6 | expect(typeof idGen).toBe('function') 7 | }) 8 | 9 | it('generates a stable id-string for a given name', () => { 10 | const idGen = createCachedIdFn() 11 | const id = idGen('test') 12 | expect(typeof id).toBe('string') 13 | expect(id).toBe(idGen('test')) 14 | }) 15 | 16 | it('generates different ids for different names', () => { 17 | const idGen = createCachedIdFn() 18 | const id1 = idGen('1') 19 | const id2 = idGen('2') 20 | expect(id1 !== id2).toBe(true) 21 | }) 22 | 23 | it('add prefix when given as argument to factory', () => { 24 | const idGen = createCachedIdFn('prefix') 25 | const id = idGen('test') 26 | expect(/^prefix_.+/.test(id)).toBe(true) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/keys.spec.ts: -------------------------------------------------------------------------------- 1 | import { useArrowKeys, useKeyIf } from '../keys' 2 | import { ref } from 'vue' 3 | import { fireEvent } from '@testing-library/dom' 4 | 5 | const pressUp = () => 6 | fireEvent.keyUp(document, { 7 | key: 'Up', 8 | code: 38, 9 | }) 10 | const pressDown = () => 11 | fireEvent.keyUp(document, { 12 | key: 'Down', 13 | code: 40, 14 | }) 15 | const pressLeft = () => 16 | fireEvent.keyUp(document, { 17 | key: 'Left', 18 | code: 37, 19 | }) 20 | const pressRight = () => 21 | fireEvent.keyUp(document, { 22 | key: 'Right', 23 | code: 39, 24 | }) 25 | 26 | describe('useKeyIf', () => { 27 | it('triggers on keyboard keyup events if conditionRef = true', async () => { 28 | const conditionRef = ref(true) 29 | const spy = jest.fn() 30 | const unwatch = useKeyIf(conditionRef, ['Up'], spy) 31 | 32 | fireEvent.keyUp(document, { 33 | key: 'Up', 34 | code: 38, 35 | }) 36 | fireEvent.keyUp(document, { 37 | key: 'Down', 38 | code: 40, 39 | }) 40 | expect(spy).toHaveBeenCalledTimes(1) 41 | conditionRef.value = false 42 | fireEvent.keyUp(document, { 43 | key: 'Up', 44 | code: 38, 45 | }) 46 | expect(spy).toHaveBeenCalledTimes(1) 47 | unwatch() 48 | }) 49 | }) 50 | 51 | describe('useArrowKeys', () => { 52 | it('triggers handlers for correct Keys if conditionRef = true', () => { 53 | const conditionRef = ref(true) 54 | const spies = { 55 | up: jest.fn().mockName('up'), 56 | down: jest.fn().mockName('down'), 57 | left: jest.fn().mockName('left'), 58 | right: jest.fn().mockName('right'), 59 | } 60 | const unwatch = useArrowKeys(conditionRef, { 61 | up: spies.up, 62 | down: spies.down, 63 | left: spies.left, 64 | right: spies.right, 65 | }) 66 | 67 | pressUp() 68 | pressDown() 69 | pressLeft() 70 | pressRight() 71 | // fire unrelate key 72 | fireEvent.keyUp(document, { 73 | key: 'Enter', 74 | code: 13, 75 | }) 76 | expect(spies.up).toHaveBeenCalledTimes(1) 77 | expect(spies.down).toHaveBeenCalledTimes(1) 78 | expect(spies.left).toHaveBeenCalledTimes(1) 79 | expect(spies.right).toHaveBeenCalledTimes(1) 80 | conditionRef.value = false 81 | pressUp() 82 | expect(spies.up).toHaveBeenCalledTimes(1) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/reactive-defaults.spec.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, reactive, nextTick, ref } from 'vue' 2 | 3 | import { useReactiveDefaults } from '../reactive-defaults' 4 | 5 | const defaults = { 6 | a: 'A', 7 | b: 'B', 8 | c: 'C', 9 | } 10 | 11 | describe('useReactiveDefaults', () => { 12 | it('works with reactive', async () => { 13 | const original = reactive({ 14 | a: 'AA', 15 | b: 'B', 16 | }) 17 | 18 | const obj = useReactiveDefaults(original, defaults) 19 | 20 | expect(obj.a.value).toBe('AA') 21 | expect(obj.b.value).toBe('B') 22 | expect(obj.c.value).toBe('C') 23 | original.a = 'AAA' 24 | await nextTick() 25 | expect(obj.a.value).toBe('AAA') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/__tests__/template-refs.spec.ts: -------------------------------------------------------------------------------- 1 | import { createTemplateRefList } from '../template-refs' 2 | import { mount } from '@vue/test-utils' 3 | import { onMounted, Ref, ref, h, defineComponent } from 'vue' 4 | import { wait } from '../../../test/helpers' 5 | 6 | describe('TemplateRefList', () => { 7 | it('works with refFn', async () => { 8 | let _elements!: Ref 9 | const wrapper = mount( 10 | defineComponent({ 11 | setup() { 12 | const show = ref(false) as Ref 13 | const { elements, refFn } = createTemplateRefList() 14 | _elements = elements 15 | onMounted(() => { 16 | show.value = true 17 | }) 18 | return { 19 | refFn, 20 | show, 21 | } 22 | }, 23 | render() { 24 | return [ 25 | h('DIV', { key: 'A', ref: this.refFn }), 26 | this.show && h('SPAN', { key: 'B', ref: this.refFn }), 27 | h('DIV', { key: 'C', ref: this.refFn }), 28 | ].filter(Boolean) 29 | }, 30 | }) 31 | ) 32 | 33 | // await wait() 34 | expect(_elements.value.length).toBe(2) 35 | await wait() 36 | expect(_elements.value.length).toBe(3) 37 | expect(_elements.value[2].tagName).toBe('SPAN') 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/arrow-navigation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | reactive, 3 | ref, 4 | computed, 5 | watch, 6 | Ref, 7 | onMounted, 8 | onUnmounted, 9 | nextTick, 10 | readonly, 11 | } from 'vue' 12 | import { TemplRef, MaybeRef, ArrowNavigation } from '../types' 13 | import { 14 | useElementFocusObserver, 15 | useSelectorFocusObserver, 16 | } from './focus-observer' 17 | import { useArrowKeys, useKeyIf } from './keys' 18 | import { sortByDocPosition } from '../utils/focusable-elements' 19 | 20 | import { ArrowNavigationOptions } from '../types' 21 | import { useReactiveDefaults } from './reactive-defaults' 22 | import { useEventIf } from './events' 23 | 24 | /** 25 | * Utility to determine the first HTMLElement in an array 26 | * which as 'aria-selected' set to true 27 | * 28 | * @param {Set} _elementIds 29 | * @returns {HTMLElement | undefined} 30 | */ 31 | function getFirstSelectedElement(_elementIds: Array) { 32 | const elementIds = _elementIds.slice().sort(sortByDocPosition) 33 | return elementIds.find(el => el.getAttribute('aria-selected') === 'true') 34 | } 35 | 36 | /** 37 | * Gets elements for the elementIds we have 38 | * @param selector {string} CSS selector of all the ids of elements in the nav. 39 | */ 40 | function elementsFromIds(selector: string) { 41 | const els = 42 | selector.length > 0 43 | ? (Array.from(document.querySelectorAll(selector)) as HTMLElement[]) 44 | : [] 45 | return els 46 | } 47 | 48 | /** 49 | * This function creates an aPI for us to track which element ids are 50 | * currentply part of the navigation, and add new ids to this group. 51 | * 52 | * @returns {object} elementIdsAPI 53 | * @property {Set} elementIdsAPI.elementIds Set of all ids 54 | * @property {Ref} elementIdsAPI.elementIdSelector CSS selector built from ids 55 | * @property {function} elementIdsAPI.addIdToNavigation add id string to the Set 56 | */ 57 | function createElementIdState() { 58 | const elementIds = reactive(new Set()) 59 | const elementIdSelector = computed(() => { 60 | return Array.from(elementIds) 61 | .map(id => '#' + id) 62 | .join(',') 63 | }) 64 | const addIdToNavigation = ( 65 | id: string, 66 | _disabled: MaybeRef = false 67 | ) => { 68 | const disabled = ref(_disabled) 69 | watch( 70 | disabled, 71 | nextDisabled => { 72 | !nextDisabled ? elementIds.add(id) : elementIds.delete(id) 73 | }, 74 | { immediate: true } 75 | ) 76 | onUnmounted(() => elementIds.delete(id)) 77 | } 78 | return { 79 | elementIds: readonly(elementIds), 80 | elementIdSelector: readonly(elementIdSelector), 81 | addIdToNavigation, 82 | } 83 | } 84 | 85 | const defaultOptions: ArrowNavigationOptions = { 86 | autoSelect: false, 87 | loop: false, 88 | orientation: undefined, 89 | startOnFirstSelected: false, 90 | virtual: false, 91 | } 92 | 93 | export function useArrowNavigation( 94 | options: Partial, 95 | _wrapperElRef?: TemplRef 96 | ): ArrowNavigation { 97 | const { 98 | autoSelect, 99 | loop, 100 | orientation, 101 | startOnFirstSelected, 102 | virtual, 103 | } = useReactiveDefaults(options, defaultOptions) 104 | 105 | // state and methods to work with element id selectors of children 106 | const { 107 | elementIds, 108 | elementIdSelector, 109 | addIdToNavigation, 110 | } = createElementIdState() 111 | 112 | // The id of the child element that is considered active and will receive focus 113 | // if the user navigates to this navigation's group of elements 114 | const currentActiveId = ref('') 115 | const currentActiveElement = computed(() => { 116 | return currentActiveId.value.length > 0 117 | ? (document.querySelector('#' + currentActiveId.value) as HTMLElement) 118 | : undefined 119 | }) 120 | 121 | // Determine wether or not our group has Focus 122 | // A. if virtual: true, we only need to watch the wrapper Element 123 | // because we will be using active - descendant 124 | // B. if virtual: false, we need to watch the individual elementIds 125 | // because we will be using the roving tabindex pattern 126 | const wrapperElRef = _wrapperElRef || ref() 127 | const virtualObserver = useElementFocusObserver(wrapperElRef) 128 | const selectorObserer = useSelectorFocusObserver(elementIdSelector) 129 | const hasFocus = computed(() => { 130 | return virtual.value 131 | ? virtualObserver.hasFocus.value 132 | : selectorObserer.hasFocus.value 133 | }) 134 | 135 | /** 136 | * - Determines the next element to focus within the group of `elementIds` 137 | * @param {string} to 'next' | 'prev' | 'start' | 'end' 138 | */ 139 | const moveto = (to: 'next' | 'prev' | 'start' | 'end') => { 140 | let nextIdx: number 141 | const children = elementsFromIds(elementIdSelector.value) 142 | const max = children.length - 1 143 | 144 | const idx = currentActiveId.value 145 | ? children.findIndex(el => el.id === currentActiveId.value) 146 | : 0 147 | 148 | switch (to) { 149 | case 'next': 150 | nextIdx = idx >= max ? (loop.value ? 0 : idx) : idx + 1 151 | break 152 | case 'prev': 153 | nextIdx = idx <= 0 ? (loop.value ? max : idx) : idx - 1 154 | break 155 | case 'start': 156 | nextIdx = 0 157 | break 158 | case 'end': 159 | nextIdx = max 160 | break 161 | default: 162 | throw new Error() 163 | } 164 | 165 | const nextEl = children[nextIdx] 166 | if (nextEl === currentActiveElement.value) return 167 | if (nextEl) { 168 | currentActiveId.value = nextEl.id 169 | } 170 | !virtual.value && nextEl && hasFocus.value && nextEl.focus() 171 | } 172 | 173 | /** 174 | * Listeners for doing the actual arrow navigation 175 | */ 176 | const backward = (event: KeyboardEvent) => { 177 | if (event.shiftKey || event.ctrlKey) return 178 | moveto('prev') 179 | autoSelect.value && click() 180 | } 181 | const forward = (event: KeyboardEvent) => { 182 | if (event.shiftKey || event.ctrlKey) return 183 | moveto('next') 184 | autoSelect.value && click() 185 | } 186 | const click = () => currentActiveElement.value?.click() 187 | useArrowKeys( 188 | hasFocus, 189 | { 190 | up: backward, 191 | down: forward, 192 | right: forward, 193 | left: backward, 194 | }, 195 | reactive({ orientation }) 196 | ) 197 | 198 | useKeyIf(hasFocus, ['Home', 'End', 'Enter', ' '], ((event: KeyboardEvent) => { 199 | switch (event.key) { 200 | case 'Home': 201 | moveto('start') 202 | autoSelect.value && click() 203 | break 204 | case 'End': 205 | moveto('end') 206 | autoSelect.value && click() 207 | break 208 | case 'Enter': 209 | case ' ': 210 | // when using virtual mode, we need to do a click on the "focused" argument 211 | virtual.value && click() 212 | break 213 | default: 214 | return 215 | } 216 | }) as EventListener) 217 | 218 | const virtualAndHasFocus = computed(() => virtual.value && hasFocus.value) 219 | useEventIf(virtualAndHasFocus, document, 'keydown', (( 220 | event: KeyboardEvent 221 | ) => { 222 | if (event.key === ' ') { 223 | console.log('prevented space scroll!') 224 | event.preventDefault() 225 | } 226 | }) as EventListener) 227 | /** 228 | * Determines which of the `elementIds` should be the first one to receive focus 229 | * when the tab sequence reaches out composite widge 230 | */ 231 | const determineFirstFocus = () => { 232 | if (startOnFirstSelected.value) { 233 | const selectedEl = getFirstSelectedElement( 234 | elementsFromIds(elementIdSelector.value) 235 | ) 236 | selectedEl 237 | ? (currentActiveId.value = selectedEl.id || '') 238 | : moveto('start') 239 | } else { 240 | moveto('start') 241 | } 242 | } 243 | 244 | // whenever we lose focus, we should determine 245 | // on which element focus should start again 246 | // when the user returns 247 | watch(hasFocus, active => { 248 | if (active) return 249 | determineFirstFocus() 250 | }) 251 | 252 | // ... and we should do the same check on mounted 253 | onMounted(() => { 254 | nextTick(() => { 255 | determineFirstFocus() 256 | }) 257 | }) 258 | 259 | return { 260 | hasFocus, 261 | elementIds: elementIds as Set, 262 | currentActiveElement, 263 | currentActiveId, 264 | virtual, 265 | // Methods 266 | addIdToNavigation, 267 | select: (id: string) => void (currentActiveId.value = id), 268 | wrapperElRef, 269 | } 270 | } 271 | export function useArrowNavWrapper(api: ArrowNavigation) { 272 | // wrapperAttributes need to be applied to the wrapper Element 273 | // but only when using "virtual" mode 274 | // TODO: pull this out into its own use* composable 275 | return computed(() => { 276 | return api.virtual.value 277 | ? { 278 | ref: api.wrapperElRef, 279 | tabindex: 0, 280 | 'aria-owns': Array.from(api.elementIds as Set).join(','), 281 | 'aria-activedescendant': api.currentActiveId.value, 282 | } 283 | : {} 284 | }) 285 | } 286 | 287 | export function useArrowNavigationItem( 288 | { 289 | id, 290 | isDisabled, 291 | }: { 292 | id: string 293 | isDisabled: Ref 294 | }, 295 | api: ArrowNavigation 296 | ): Ref<{ 297 | tabindex: string | undefined 298 | 'data-varia-focus'?: boolean 299 | onClick: () => void 300 | }> { 301 | api.addIdToNavigation(id, isDisabled) 302 | 303 | const hasFocus = computed(() => id === api.currentActiveId.value) 304 | 305 | const onClick = () => { 306 | !isDisabled.value && api.select(id) 307 | } 308 | return computed(() => { 309 | return api.virtual.value 310 | ? { 311 | tabindex: undefined, 312 | 'data-varia-focus': hasFocus.value, 313 | onClick, 314 | } 315 | : { 316 | tabindex: hasFocus.value ? '0' : '-1', 317 | onClick, 318 | } 319 | }) 320 | } 321 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/click-outside.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onUnmounted, unref, nextTick } from 'vue' 2 | import { useEvent } from './events' 3 | import { MaybeTemplRef } from '../types' 4 | 5 | // TODO: needs to take teleported elements into account. 6 | export function useClickOutside( 7 | _els: MaybeTemplRef[], 8 | cb: (...args: any[]) => any 9 | ) { 10 | let unwatch: ReturnType 11 | onMounted(() => { 12 | nextTick(() => { 13 | unwatch = useEvent(document, 'click', ({ target }) => { 14 | const els = _els.map(unref).filter(Boolean) as HTMLElement[] 15 | const isOutside = els.every(el => { 16 | return el !== target && !el?.contains(target as Node) 17 | }) 18 | isOutside && cb() 19 | }) 20 | }) 21 | }) 22 | onUnmounted(() => unwatch()) 23 | } 24 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/events.ts: -------------------------------------------------------------------------------- 1 | import { ref, Ref, watch } from 'vue' 2 | import { MaybeRef, TemplRefType } from '../types' 3 | 4 | export type ElementRefs = 5 | | MaybeRef 6 | | MaybeRef 7 | | MaybeRef 8 | 9 | export function useEvent( 10 | _el: ElementRefs, 11 | name: string, 12 | handler: (event: Evt) => void, 13 | opts?: boolean | AddEventListenerOptions 14 | ) { 15 | const el = ref((_el as unknown) as Element) 16 | const unwatch = watch( 17 | el, 18 | (el, _, onCleanup) => { 19 | el && el.addEventListener(name, handler as EventListener, opts) 20 | onCleanup(() => { 21 | el && el.removeEventListener(name, handler as EventListener, opts) 22 | }) 23 | }, 24 | { immediate: true } 25 | ) 26 | 27 | return unwatch 28 | } 29 | 30 | export function useEventIf( 31 | condRef: Ref, 32 | elRef: ElementRefs, 33 | name: string, 34 | handler: EventListener, 35 | opts?: boolean | AddEventListenerOptions 36 | ) { 37 | return useEvent( 38 | elRef, 39 | name, 40 | (event: T) => condRef.value && handler(event), 41 | opts 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/focus-observer.ts: -------------------------------------------------------------------------------- 1 | import { computed, Ref } from 'vue' 2 | import { focusTracker } from '../helpers' 3 | import { TemplRef } from '../types' 4 | 5 | export function useElementFocusObserver(el: TemplRef) { 6 | const hasFocus = computed( 7 | () => !!el.value && focusTracker.currentEl.value === el.value 8 | ) 9 | return { 10 | hasFocus, 11 | } 12 | } 13 | 14 | export function useSelectorFocusObserver(selector: Ref) { 15 | const hasFocus = computed(() => { 16 | if (selector.value.length === 0) return false 17 | return !!focusTracker.currentEl.value?.matches(selector.value) 18 | }) 19 | return { 20 | hasFocus, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/index.ts: -------------------------------------------------------------------------------- 1 | // Focus Tracking 2 | export * from './focus-observer' 3 | 4 | // Click and Keyboard Events 5 | export * from './arrow-navigation' 6 | export * from './click-outside' 7 | export * from './keys' 8 | export * from './events' 9 | 10 | // State Helpers 11 | export * from './state' 12 | export * from './reactive-defaults' 13 | 14 | // Other 15 | export * from './template-refs' 16 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/keys.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue' 2 | import { useEventIf } from './events' 3 | 4 | import { ArrowKeyHandlers } from '../types' 5 | 6 | export function useKeyIf( 7 | condRef: Ref, 8 | keys: string[], 9 | handler: (event: KeyboardEvent) => void, 10 | opts?: boolean | AddEventListenerOptions 11 | ) { 12 | return useEventIf( 13 | condRef, 14 | document, 15 | 'keyup', 16 | ((e: KeyboardEvent) => { 17 | if (keys.indexOf((e as any).key) === -1) return 18 | handler(e) 19 | }) as EventListener, 20 | opts 21 | ) 22 | } 23 | 24 | export function useArrowKeys( 25 | condRef: Ref, 26 | { up, down, left, right }: ArrowKeyHandlers = {}, 27 | { 28 | orientation, 29 | }: { 30 | orientation?: 'horizontal' | 'vertical' 31 | } = {} 32 | ) { 33 | const o = orientation 34 | const handleKeyup = (e: KeyboardEvent) => { 35 | const key = e.key 36 | switch (key) { 37 | case 'Left': 38 | case 'ArrowLeft': 39 | left && (!o || o === 'horizontal') && left(e) 40 | break 41 | case 'Up': 42 | case 'ArrowUp': 43 | up && (!o || o === 'vertical') && up(e) 44 | break 45 | case 'Right': 46 | case 'ArrowRight': 47 | right && (!o || o === 'horizontal') && right(e) 48 | break 49 | case 'Down': 50 | case 'ArrowDown': 51 | down && (!o || o === 'vertical') && down(e) 52 | break 53 | default: 54 | break 55 | } 56 | } 57 | return useEventIf(condRef, document, 'keyup', handleKeyup as EventListener) 58 | } 59 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/reactive-defaults.ts: -------------------------------------------------------------------------------- 1 | import { watch, reactive, toRefs, ToRefs, readonly } from 'vue' 2 | 3 | export function useReactiveDefaults( 4 | obj: Partial, 5 | defaults: T 6 | ) { 7 | const o = reactive({}) as T 8 | watch(obj, () => Object.assign(o, defaults, obj), { immediate: true }) 9 | return toRefs(readonly(o)) 10 | } 11 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/state.ts: -------------------------------------------------------------------------------- 1 | import { computed, getCurrentInstance } from 'vue' 2 | 3 | export function wrapProp( 4 | props: T, 5 | name: K 6 | ) { 7 | const vm = getCurrentInstance() 8 | if (!vm) { 9 | throw new Error('wrapVModel has to be called in setup') 10 | } 11 | const state = computed({ 12 | get: () => props[name], 13 | set: (v: T[K]) => vm.emit('update:' + name, v), 14 | }) 15 | return state 16 | } 17 | -------------------------------------------------------------------------------- /packages/aria-composables/src/composables/template-refs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ref, 3 | Ref, 4 | onBeforeUpdate, 5 | onMounted, 6 | onUpdated, 7 | watch, 8 | readonly, 9 | } from 'vue' 10 | 11 | export function createTemplateRefList() { 12 | const elements = ref([]) 13 | 14 | onBeforeUpdate(() => (elements.value = [])) 15 | return { 16 | elements: readonly(elements), 17 | refFn: (el: HTMLElement) => void elements.value.push(el), 18 | } 19 | } 20 | 21 | const QUERY_FOCUSABLE_ELEMENTS = 22 | 'button, [href], input, textarea, [tabindex], audio, video' 23 | 24 | export function createTemplateRefQuery( 25 | elRef: Ref, 26 | query: string = QUERY_FOCUSABLE_ELEMENTS 27 | ) { 28 | const elements = ref([]) 29 | const handler = () => { 30 | const el = elRef.value 31 | if (el) { 32 | elements.value = [ 33 | ...((el.querySelectorAll(query) as unknown) as HTMLElement[]), 34 | ] 35 | } else { 36 | elements.value = [] 37 | } 38 | } 39 | watch(elRef, handler) 40 | onMounted(handler) 41 | onUpdated(handler) 42 | 43 | return elements 44 | } 45 | -------------------------------------------------------------------------------- /packages/aria-composables/src/helpers/__tests__/focus-tracker.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusTracker } from '../focus-tracker' 2 | import { fireEvent } from '@testing-library/dom' 3 | import { nextTick } from 'vue' 4 | describe('Focus Tracker', () => { 5 | const addEl = (id = 'id', name = 'BUTTON') => { 6 | const el = document.createElement(name) 7 | el.setAttribute('id', id) 8 | document.body.append(el) 9 | } 10 | beforeEach(() => { 11 | addEl() 12 | addEl('id2') 13 | }) 14 | afterEach(() => { 15 | document.body.innerHTML = '' 16 | }) 17 | it('works', async () => { 18 | const el = document.getElementById('id') 19 | if (!el) throw new Error('element not found') 20 | const el2 = document.getElementById('id') 21 | if (!el2) throw new Error('element not found') 22 | 23 | expect(focusTracker.currentEl.value).toBe(undefined) 24 | expect(focusTracker.activeEl.value).toBe(document.body) 25 | expect(focusTracker.prevEl.value).toBe(undefined) 26 | 27 | fireEvent.focus(el) 28 | fireEvent.focusIn(el) 29 | await nextTick() 30 | expect(focusTracker.currentEl.value).toBe(el) 31 | expect(focusTracker.activeEl.value).toBe(el) 32 | expect(focusTracker.prevEl.value).toBe(document.body) 33 | 34 | fireEvent.focus(el2) 35 | fireEvent.focusIn(el2) 36 | await nextTick() 37 | expect(focusTracker.currentEl.value).toBe(el2) 38 | expect(focusTracker.activeEl.value).toBe(el2) 39 | expect(focusTracker.prevEl.value).toBe(el) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /packages/aria-composables/src/helpers/__tests__/tab-direction.spec.ts: -------------------------------------------------------------------------------- 1 | import { wait } from '../../../test/helpers' 2 | import { fireEvent } from '@testing-library/dom' 3 | import { tabDirection } from '../tab-direction' 4 | 5 | describe('Tab Direction', () => { 6 | it('tracks tabDirection', async () => { 7 | expect(tabDirection.value).toBe(undefined) 8 | fireEvent.keyDown(document, { 9 | key: 'Tab', 10 | code: 9, 11 | }) 12 | await wait() 13 | expect(tabDirection.value).toBe('forward') 14 | 15 | fireEvent.keyDown(document, { 16 | key: 'Tab', 17 | code: 9, 18 | shiftKey: true, 19 | }) 20 | await wait() 21 | expect(tabDirection.value).toBe('backward') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /packages/aria-composables/src/helpers/focus-tracker.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed, readonly } from 'vue' 2 | import { useEvent } from '../composables/events' 3 | 4 | // all elements that are not claimed by an explicit FocusGroup 5 | // are part of the global FocusGroup 6 | 7 | const prevEl = ref() 8 | const activeEl = ref(document.activeElement as HTMLElement) 9 | const docHasFocus = ref(document.hasFocus()) 10 | // when a FocusGroup takes over focus management, 11 | // it notifies the tracker by calling this function 12 | 13 | useEvent(document, 'focusin', e => { 14 | docHasFocus.value = true 15 | prevEl.value = activeEl.value 16 | activeEl.value = e.target as HTMLElement 17 | }) 18 | 19 | useEvent(document, 'focusout', () => { 20 | setTimeout(() => { 21 | docHasFocus.value = document.hasFocus() 22 | }, 0) 23 | }) 24 | 25 | export const focusTracker = { 26 | // State 27 | prevEl: readonly(prevEl), 28 | activeEl: readonly(activeEl), 29 | currentEl: computed(() => (docHasFocus.value ? activeEl.value : undefined)), 30 | } 31 | -------------------------------------------------------------------------------- /packages/aria-composables/src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { tabDirection } from './tab-direction' 2 | export { focusTracker } from './focus-tracker' 3 | -------------------------------------------------------------------------------- /packages/aria-composables/src/helpers/tab-direction.ts: -------------------------------------------------------------------------------- 1 | import { ref, provide, computed, inject, InjectionKey, readonly } from 'vue' 2 | import { useEvent } from '../composables/events' 3 | import { TabDirection } from '../types' 4 | import { TABBABLE_ELS } from '../utils/focusable-elements' 5 | 6 | export const tabDirectionKey: InjectionKey = Symbol( 7 | 'TabDirectionKey' 8 | ) 9 | 10 | export const tabDirection = ref<'backward' | 'forward' | undefined>() 11 | 12 | useEvent(document, 'keydown', ((event: KeyboardEvent) => { 13 | if (event.key === 'Tab') { 14 | tabDirection.value = event.shiftKey ? 'backward' : 'forward' 15 | } 16 | }) as EventListener) 17 | 18 | useEvent(document, 'click', ((event: MouseEvent) => { 19 | if ((event.target as Element).matches(TABBABLE_ELS)) { 20 | setTimeout(() => (tabDirection.value = undefined), 0) 21 | } 22 | }) as EventListener) 23 | -------------------------------------------------------------------------------- /packages/aria-composables/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | 3 | export * from './composables' 4 | 5 | export * from './helpers' 6 | export { 7 | createId, 8 | createCachedIdFn, 9 | applyFocus, 10 | getFocusableElements, 11 | getFirstFocusableChild, 12 | getLastFocusableChild, 13 | TABBABLE_ELS, 14 | isNativeTabbable, 15 | createInjector, 16 | } from './utils' 17 | -------------------------------------------------------------------------------- /packages/aria-composables/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue' 2 | export type MaybeRef = T | Ref 3 | 4 | /** 5 | * Types for Template Refs 6 | */ 7 | export type TemplRefType = El | undefined 8 | export type TemplRef = Ref> 9 | export type MaybeTemplRef = MaybeRef> 10 | 11 | // Options Interfaces 12 | 13 | // ArrowNavigation 14 | export interface ArrowNavigationOptions { 15 | orientation: 'horizontal' | 'vertical' | undefined 16 | loop: boolean 17 | startOnFirstSelected: boolean 18 | autoSelect: boolean 19 | virtual: boolean 20 | } 21 | export interface ArrowNavigation { 22 | hasFocus: Ref 23 | elementIds: Set 24 | currentActiveId: Ref 25 | currentActiveElement: TemplRef 26 | virtual: Ref 27 | select: (id: string) => void 28 | addIdToNavigation: (id: string, disabled: Ref) => void 29 | wrapperElRef: TemplRef 30 | } 31 | 32 | export type ArrowKeyHandlers = { 33 | up?: (event: KeyboardEvent) => void 34 | down?: (event: KeyboardEvent) => void 35 | left?: (event: KeyboardEvent) => void 36 | right?: (event: KeyboardEvent) => void 37 | } 38 | 39 | // API Interfaces (For apis exposed via porvide/inject, usually) 40 | export interface FocusTrackerAPI { 41 | prevEl: Readonly> 42 | activeEl: Readonly> 43 | currentEl: Readonly> 44 | } 45 | 46 | export type TabDirection = Ref<'forward' | 'backward' | undefined> 47 | 48 | /** 49 | * Base Interfaces for the composite widgets APIs 50 | * Those are usually provided by a wrapper component to children 51 | */ 52 | type OptionsBase = Record 53 | 54 | export interface BaseAPI> { 55 | generateId?: (name: string) => string 56 | state?: StateAPIBase 57 | arrowNav?: ArrowNavigation 58 | elements?: ElementsAPI 59 | options?: Options 60 | // [key: string]: any 61 | } 62 | 63 | export type StateAPIBase = BooleanStateAPI | SingleStateAPI | SetStateAPI 64 | 65 | export interface BooleanStateAPI { 66 | selected: Ref 67 | select?: () => void 68 | unselect?: () => void 69 | toggle?: () => void 70 | } 71 | export interface SingleStateAPI { 72 | selected: Ref 73 | select: (item: Item) => void 74 | unselect?: () => void 75 | } 76 | 77 | export interface SetStateAPI { 78 | selected: Set 79 | select: (item: Item) => void 80 | unselect: (item: Item) => void 81 | toggle: (item: Item) => void 82 | } 83 | 84 | export interface ElementsAPI { 85 | triggerEl: Ref 86 | contentEl: Ref 87 | } 88 | -------------------------------------------------------------------------------- /packages/aria-composables/src/utils/apply-focus.ts: -------------------------------------------------------------------------------- 1 | export function applyFocus(_el: HTMLElement) { 2 | // else, it's an Element 3 | const el = _el 4 | el.focus() 5 | } 6 | -------------------------------------------------------------------------------- /packages/aria-composables/src/utils/focusable-elements.ts: -------------------------------------------------------------------------------- 1 | import { applyFocus } from './apply-focus' 2 | 3 | const focusableElList = [ 4 | 'a[href]', 5 | 'area[href]', 6 | 'button:not([disabled])', 7 | 'embed', 8 | 'iframe', 9 | 'input:not([disabled])', 10 | 'object', 11 | 'select:not([disabled])', 12 | 'textarea:not([disabled])', 13 | '*[tabindex]', 14 | '*[contenteditable]', 15 | ] 16 | 17 | export const TABBABLE_ELS = focusableElList.join(',') 18 | 19 | export function getFocusableElements( 20 | el: HTMLElement, 21 | query: string = TABBABLE_ELS 22 | ) { 23 | return Array.from(el.querySelectorAll(query)) as HTMLElement[] 24 | } 25 | 26 | export function getFirstFocusableChild(wrapperEl: HTMLElement) { 27 | const els = getFocusableElements(wrapperEl) 28 | return els.length ? els[0] : undefined 29 | } 30 | export function getLastFocusableChild(wrapperEl: HTMLElement) { 31 | const els = getFocusableElements(wrapperEl) 32 | return els.length ? els[els.length - 1] : undefined 33 | } 34 | 35 | export function sortByDocPosition(a: HTMLElement, b: HTMLElement) { 36 | return a.compareDocumentPosition(b) & 2 ? 1 : -1 37 | } 38 | 39 | export function isNativeTabbable( 40 | element: Element 41 | ): element is HTMLElement & { disabled: boolean } { 42 | return ( 43 | element.tagName === 'BUTTON' || 44 | element.tagName === 'INPUT' || 45 | element.tagName === 'SELECT' || 46 | element.tagName === 'TEXTAREA' || 47 | element.tagName === 'A' || 48 | element.tagName === 'AUDIO' || 49 | element.tagName === 'VIDEO' 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /packages/aria-composables/src/utils/id.ts: -------------------------------------------------------------------------------- 1 | import cuid from 'cuid' 2 | 3 | export const createId = cuid 4 | 5 | export function createCachedIdFn(seed?: string) { 6 | const idMap: { [key: string]: string } = {} 7 | 8 | return (name: string) => { 9 | if (name && idMap[name]) return idMap[name] 10 | 11 | const _id = cuid() 12 | const id = seed ? `${seed}_${_id}` : _id 13 | if (name) { 14 | idMap[name] = id 15 | } 16 | return id 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/aria-composables/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './id' 2 | export * from './apply-focus' 3 | export * from './focusable-elements' 4 | export * from './inject' 5 | -------------------------------------------------------------------------------- /packages/aria-composables/src/utils/inject.ts: -------------------------------------------------------------------------------- 1 | import { InjectionKey, inject } from 'vue' 2 | import { BaseAPI } from '../types' 3 | export function createInjector( 4 | defaultKey: InjectionKey, 5 | nameOfInjector: string 6 | ) { 7 | return function(key: InjectionKey = defaultKey) { 8 | const api = inject(key) 9 | if (!api) { 10 | throw new Error(`injection now found when calling ${nameOfInjector}. 11 | Make sure to have called the matching use*() function before, or in a parent component`) 12 | } 13 | return api 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/aria-composables/test/helpers.ts: -------------------------------------------------------------------------------- 1 | import { nextTick } from 'vue' 2 | import { mount as _mount } from '@vue/test-utils' 3 | import { fireEvent } from '@testing-library/dom' 4 | 5 | type SetupFn = (...args: any[]) => Record 6 | 7 | export const focus = (el: any) => { 8 | ;(el as HTMLElement).focus() 9 | // workaround for jsdom: focus() does not trigger focusin event 10 | fireEvent.focusIn(el as Element) 11 | } 12 | 13 | export async function wait(n?: number) { 14 | if (!n) return nextTick() 15 | 16 | return new Promise(res => setTimeout(res, n)) 17 | } 18 | export function mount(setup: SetupFn, options: any = {}) { 19 | const component = { 20 | setup, 21 | render: options.template 22 | ? undefined 23 | : function() { 24 | return null 25 | }, 26 | } 27 | return _mount(component, options) 28 | } 29 | 30 | const Parent = { 31 | setup() { 32 | const provideFocusTracker = require('../src/composables/use-global-focustracker') 33 | .provideFocusTracker 34 | 35 | provideFocusTracker() 36 | }, 37 | render() { 38 | return null 39 | }, 40 | } 41 | 42 | export function mountWithTrackerParent(setup: SetupFn, options: any) { 43 | return mount(setup, { 44 | ...options, 45 | parentComponent: Parent, 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /packages/aria-composables/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../config/typescript/tsconfig.build.json", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "baseUrl": "./src", 6 | "rootDir": "./src", 7 | "outDir": "dist", 8 | "declarationDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/aria-widgets/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'plugin:vue/essential', 5 | '@vue/typescript', 6 | 'prettier/@typescript-eslint', 7 | 'plugin:prettier/recommended', 8 | ], 9 | rules: { 10 | eqeqeq: 'off', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /packages/aria-widgets/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | -------------------------------------------------------------------------------- /packages/aria-widgets/.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | /types -------------------------------------------------------------------------------- /packages/aria-widgets/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "semi": false 6 | } -------------------------------------------------------------------------------- /packages/aria-widgets/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Thorsten Lünborg 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /packages/aria-widgets/README.md: -------------------------------------------------------------------------------- 1 | # TSDX Bootstrap 2 | 3 | This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx). 4 | 5 | ## Local Development 6 | 7 | Below is a list of commands you will probably find useful. 8 | 9 | ### `npm start` or `yarn start` 10 | 11 | Runs the project in development/watch mode. Your project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab. 12 | 13 | 14 | 15 | Your library will be rebuilt if you make edits. 16 | 17 | ### `npm run build` or `yarn build` 18 | 19 | Bundles the package to the `dist` folder. 20 | The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module). 21 | 22 | 23 | 24 | ### `npm test` or `yarn test` 25 | 26 | Runs the test watcher (Jest) in an interactive mode. 27 | By default, runs tests related to files changed since the last commit. 28 | -------------------------------------------------------------------------------- /packages/aria-widgets/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | } 4 | -------------------------------------------------------------------------------- /packages/aria-widgets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "name": "@varia/widgets", 4 | "author": "Thorsten", 5 | "homepage": "https://github.com/LinusBorg/varia/packages/aria-widgets", 6 | "license": "Apache-2.0", 7 | "main": "dist/index.js", 8 | "module": "dist/widgets.esm.js", 9 | "files": [ 10 | "dist", 11 | "src" 12 | ], 13 | "typings": "dist/index.d.ts", 14 | "engines": { 15 | "node": ">=10" 16 | }, 17 | "scripts": { 18 | "start": "tsdx watch --tsconfig tsconfig.build.json", 19 | "serve": "tsdx watch --tsconfig tsconfig.build.json --noClean", 20 | "build": "tsdx build --tsconfig tsconfig.build.json", 21 | "test": "tsdx test", 22 | "lint": "eslint src/**/*.ts test/**/*.ts --fix" 23 | }, 24 | "dependencies": { 25 | "@popperjs/core": "^2.4.0", 26 | "@varia/composables": "0.0.0" 27 | }, 28 | "peerDependencies": { 29 | "vue": "^3.0.0-beta.15" 30 | }, 31 | "devDependencies": { 32 | "@types/jest": "^25.2.1", 33 | "@vue/eslint-config-typescript": "^5.0.1", 34 | "@vue/test-utils": "^2.0.0-alpha.7", 35 | "autoprefixer": "^9.7.6", 36 | "eslint-plugin-vue": "^7.0.0-alpha.7", 37 | "husky": "^4.2.5", 38 | "rollup-plugin-postcss": "^3.1.1", 39 | "tsdx": "^0.13.1", 40 | "tslib": "^1.11.1", 41 | "vue": "^3.0.0-beta.15", 42 | "typescript": "^3.8.3" 43 | }, 44 | "husky": { 45 | "hooks": { 46 | "pre-commit": "yarn lint" 47 | } 48 | }, 49 | "publishConfig": { 50 | "access": "public", 51 | "registry": "https://registry.yarnpkg.com" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "git+https://github.com/LinusBorg/varia.git" 56 | }, 57 | "bugs": { 58 | "url": "https://github.com/LinusBorg/varia/issues" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/aria-widgets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Accordion/AccordionContent.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, PropType, computed } from 'vue' 2 | import { AccordionAPI, AccordionAPIKey } from '../types' 3 | import { injectAccordionAPI } from './use-accordion' 4 | 5 | export interface AccordionContentProps { 6 | tag?: string 7 | tabsKey?: AccordionAPIKey 8 | name: string 9 | } 10 | 11 | export function useAccordionContent( 12 | props: AccordionContentProps, 13 | api: AccordionAPI 14 | ) { 15 | const id = api.generateId(props.name) 16 | const isExpanded = computed(() => api.state.selected.has(props.name)) 17 | return { 18 | isExpanded, 19 | attributes: computed(() => ({ 20 | id, 21 | 'aria-labelledby': id, 22 | })), 23 | } 24 | } 25 | 26 | export const AccordionContent = defineComponent({ 27 | name: 'AccordionHeader', 28 | props: { 29 | tag: String, 30 | tabsKey: Symbol as PropType, 31 | name: { 32 | type: String, 33 | required: true, 34 | }, 35 | }, 36 | setup(props, { slots }) { 37 | const api = injectAccordionAPI(props.tabsKey) 38 | const { isExpanded, attributes } = useAccordionContent(props, api) 39 | 40 | return () => 41 | isExpanded.value && 42 | h( 43 | props.tag || 'DIV', 44 | attributes.value, 45 | slots.default?.({ 46 | isExpanded: isExpanded.value, 47 | attributes: attributes.value, 48 | }) 49 | ) 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Accordion/AccordionHeader.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, PropType, computed, mergeProps } from 'vue' 2 | import { AccordionAPI, AccordionAPIKey, ButtonOptions } from '../types' 3 | import { injectAccordionAPI } from './use-accordion' 4 | import { ButtonProps, useButton } from '../Button' 5 | import { useArrowNavigationItem, createId } from '@varia/composables' 6 | 7 | export interface AccordionHeaderProps extends ButtonOptions { 8 | tag?: string 9 | headingLevel: number 10 | name: string 11 | tabsKey?: AccordionAPIKey 12 | } 13 | export function useAccordionHeader( 14 | props: AccordionHeaderProps, 15 | api: AccordionAPI 16 | ) { 17 | const id = createId() 18 | const contentId = api.generateId(props.name) 19 | 20 | // Derrived state 21 | const isExpanded = computed(() => api.state.selected.has(props.name)) 22 | const isDisabled = computed(() => !!props.disabled) 23 | 24 | // Derrived Element Attributes 25 | const btnAttrs = useButton(props) 26 | const arrowAttrs = useArrowNavigationItem( 27 | { 28 | id, 29 | isDisabled, 30 | }, 31 | api.arrowNav 32 | ) 33 | return computed(() => 34 | mergeProps(btnAttrs.value, arrowAttrs.value, { 35 | id, 36 | 'aria-expanded': isExpanded.value, 37 | 'aria-controls': contentId, 38 | onClick: () => 39 | isExpanded.value 40 | ? api.state.unselect(props.name) 41 | : api.state.select(props.name), 42 | }) 43 | ) 44 | } 45 | 46 | export const accordionHeaderProps = { 47 | tag: String, 48 | h: { 49 | type: [Number, String], 50 | required: true, 51 | }, 52 | name: { 53 | type: String, 54 | required: true, 55 | }, 56 | tabsKey: Symbol as PropType, 57 | ...ButtonProps, 58 | } 59 | 60 | export const AccordionHeader = defineComponent({ 61 | name: 'AccordionHeader', 62 | props: accordionHeaderProps, 63 | setup(props, { slots }) { 64 | const api = injectAccordionAPI(props.tabsKey) 65 | const attributes = useAccordionHeader(props as AccordionHeaderProps, api) 66 | return () => 67 | h('h' + props.h || '1', [ 68 | h( 69 | props.tag || 'button', 70 | attributes.value, 71 | slots.default?.(attributes.value) 72 | ), 73 | ]) 74 | }, 75 | }) 76 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Accordion/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-accordion' 2 | export * from './AccordionHeader' 3 | export * from './AccordionContent' 4 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Accordion/use-accordion.ts: -------------------------------------------------------------------------------- 1 | import { provide, reactive, defineComponent, PropType } from 'vue' 2 | import { 3 | createCachedIdFn, 4 | useArrowNavigation, 5 | createInjector, 6 | } from '@varia/composables' 7 | import { AccordionOptions, AccordionAPIKey } from '../types' 8 | import { ClickableProps } from '../Clickable' 9 | 10 | export type AccordionState = Set 11 | 12 | export const accordionKey = Symbol('disclosure') as AccordionAPIKey 13 | 14 | function _useAccordion(options: AccordionOptions = {}, _state: AccordionState) { 15 | const { 16 | orientation = 'vertical', 17 | multiple = false, 18 | loop = false, 19 | customName, 20 | } = options 21 | 22 | //Accordion State 23 | // TODO: We should come up with a good interface to leave state updates to the parent. 24 | // this leaves consumers of this widget the freedom to implement `multiple`, or `always-one-open` etc on their own. 25 | const selected: AccordionState = _state ?? reactive(new Set()) 26 | const select = (item: string) => { 27 | if (!multiple) { 28 | selected.clear() 29 | } 30 | selected.add(item) 31 | } 32 | const unselect = (item: string) => { 33 | selected.delete(item) 34 | } 35 | 36 | const arrowNav = useArrowNavigation( 37 | reactive({ 38 | orientation, 39 | loop, 40 | }) 41 | ) 42 | 43 | const generateId = createCachedIdFn('accordion') 44 | const api = { 45 | generateId, 46 | state: { 47 | selected, 48 | select, 49 | unselect, 50 | }, 51 | arrowNav, 52 | options, 53 | } 54 | // provide(key, api) 55 | 56 | return api 57 | } 58 | 59 | export const useAccordion = Object.assign(_useAccordion, { 60 | withProvide(options: AccordionOptions = {}, _state: AccordionState) { 61 | const api = _useAccordion(options, _state) 62 | provide(accordionKey, api) 63 | return api 64 | }, 65 | }) 66 | 67 | export const injectAccordionAPI = createInjector( 68 | accordionKey, 69 | 'injectAccordionAPI()' 70 | ) 71 | 72 | export const AccordionProps = { 73 | multiple: { 74 | type: Boolean, 75 | required: true, 76 | }, 77 | modelValue: { 78 | type: Set as PropType, 79 | }, 80 | ...ClickableProps, 81 | } 82 | export const Accordion = defineComponent({ 83 | name: 'Accordion', 84 | props: AccordionProps, 85 | setup(props, { slots }) { 86 | useAccordion.withProvide(props as AccordionOptions, props.modelValue!) 87 | return () => slots.default?.() 88 | }, 89 | }) 90 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Button/Button.ts: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, h, Ref } from 'vue' 2 | import { useClickable, ClickableProps } from '../Clickable' 3 | import { ButtonOptions } from '../types' 4 | 5 | export const ButtonProps = { 6 | tag: { type: String, default: 'button' }, 7 | ...ClickableProps, 8 | } 9 | 10 | export function useButton( 11 | options: ButtonOptions, 12 | el?: Ref 13 | ) { 14 | return computed(() => ({ 15 | ...useClickable(options, el).value, 16 | role: 'button' as const, 17 | })) 18 | } 19 | 20 | export const Button = defineComponent({ 21 | name: 'Button', 22 | props: ButtonProps, 23 | setup(props, { slots }) { 24 | const attributes = useButton(props) 25 | return () => 26 | h( 27 | props.tag, 28 | { 29 | ...attributes.value, 30 | }, 31 | slots.default?.(attributes) 32 | ) 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button' 2 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Clickable/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-clickable' 2 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Clickable/use-clickable.ts: -------------------------------------------------------------------------------- 1 | import { Ref } from 'vue' 2 | import { ClickableOptions } from '../types' 3 | import { useTabbable } from '../Tabbable' 4 | import { useEvent, focusTracker } from '@varia/composables' 5 | 6 | export { TabbableProps as ClickableProps } from '../Tabbable' 7 | 8 | export function useClickable( 9 | options: ClickableOptions, 10 | el?: Ref 11 | ) { 12 | const tabbableAttrs = useTabbable(options, el) 13 | useEvent(document, 'keydown', ((e: KeyboardEvent) => { 14 | const el = tabbableAttrs.value.ref 15 | if (e.target !== el.value) return 16 | if (el?.value?.tagName === 'BUTTON') return 17 | if (e.key === 'Enter' || e.key === ' ') { 18 | el?.value?.click() 19 | } 20 | focusTracker.currentEl.value === tabbableAttrs.value.ref.value && 21 | e.key === ' ' && 22 | e.preventDefault() 23 | }) as EventListener) 24 | 25 | return tabbableAttrs 26 | } 27 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Dialog/DialogContent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | PropType, 4 | ref, 5 | getCurrentInstance, 6 | watch, 7 | nextTick, 8 | h, 9 | } from 'vue' 10 | import { 11 | TemplRef, 12 | useKeyIf, 13 | useClickOutside, 14 | getFirstFocusableChild, 15 | } from '@varia/composables' 16 | import { injectDialogAPI } from './useDialog' 17 | import { useDisclosureContent } from '../Disclosure' 18 | import { FocusTrap } from '../FocusTrap' 19 | import { DialogAPIKey, DialogAPI, DialogContentOptions } from '../types' 20 | import { Teleport } from '../Teleport' 21 | 22 | const defaults: DialogContentOptions = { 23 | closeOnEscape: true, 24 | closeOnClickOutside: true, 25 | focusOnOpen: false, 26 | returnFocusOnClose: true, 27 | } 28 | 29 | export function useDialogContent( 30 | _options: Partial = {}, 31 | api: DialogAPI 32 | ) { 33 | const options = Object.assign({}, defaults, _options) 34 | const vm = getCurrentInstance() 35 | const el: TemplRef = ref() 36 | const { attributes } = useDisclosureContent(api, el) 37 | 38 | const { 39 | state: { selected: isOpen }, 40 | elements: { triggerEl }, 41 | } = api 42 | // Closing Behaviours 43 | const close = () => { 44 | vm && vm.emit('closed') 45 | isOpen.value = false 46 | } 47 | 48 | // The following pieces of code are copied from the Popover 49 | // We should see if/how we can putr them in /composables 50 | options.closeOnEscape && useKeyIf(ref(true), ['Escape'], close) 51 | options.closeOnClickOutside && useClickOutside([el, triggerEl], close) 52 | 53 | // Focus Lifecycle 54 | options.focusOnOpen && 55 | watch(isOpen, isOpen => { 56 | isOpen && 57 | el.value && 58 | nextTick(() => { 59 | const nextEl = getFirstFocusableChild(el.value!) 60 | nextEl && nextEl.focus() 61 | }) 62 | !isOpen && options.returnFocusOnClose && triggerEl.value?.focus() 63 | }) 64 | 65 | return { 66 | isOpen, 67 | close, 68 | attributes, 69 | } 70 | } 71 | 72 | // A lot of props are copied from `Popover`- 73 | // no idea how to share when we move to individual packages 74 | export const DialogContentProps = { 75 | tag: { 76 | type: String, 77 | default: 'DIV', 78 | }, 79 | apiKey: { 80 | type: Symbol as PropType, 81 | }, 82 | closeOnEscape: { type: Boolean as PropType, default: true }, 83 | closeOnClickOutside: { 84 | type: Boolean as PropType, 85 | default: true, 86 | }, 87 | focusOnOpen: { 88 | type: Boolean as PropType, 89 | }, 90 | returnFocusOnClose: { 91 | type: Boolean as PropType, 92 | default: true, 93 | }, 94 | } 95 | 96 | export const DialogContent = defineComponent({ 97 | name: 'DialogContent', 98 | props: DialogContentProps, 99 | setup(props, { slots }) { 100 | const api = injectDialogAPI(props.apiKey) 101 | const state = useDialogContent(props, api) 102 | return () => 103 | state.isOpen.value && 104 | //h(Teleport, { to: '[data-varia-teleport-dialogs]' }, [ 105 | h(props.tag ?? 'DIV', state.attributes.value, [ 106 | h(FocusTrap, () => slots.default?.(state)), 107 | ]) 108 | //]) 109 | }, 110 | }) 111 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Dialog/DialogTrigger.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType, h } from 'vue' 2 | import { injectDialogAPI } from './useDialog' 3 | import { useDisclosureTrigger } from '../Disclosure' 4 | import { DialogAPIKey, DialogAPI } from '../types' 5 | 6 | interface DialogOptions {} 7 | 8 | export function useDialogTrigger(options: DialogOptions = {}, api: DialogAPI) { 9 | const el = api.elements.triggerEl 10 | const attributes = useDisclosureTrigger(options, api, el) 11 | 12 | return attributes 13 | } 14 | 15 | export const dialogTriggerProps = { 16 | tag: { 17 | type: String, 18 | }, 19 | apiKey: { 20 | type: Symbol as PropType, 21 | }, 22 | } 23 | 24 | export const DialogTrigger = defineComponent({ 25 | name: 'DialogTrigger', 26 | props: dialogTriggerProps, 27 | setup(props, { slots }) { 28 | const api = injectDialogAPI(props.apiKey) 29 | const attributes = useDialogTrigger(props, api) 30 | return () => h(props.tag ?? 'button', attributes.value, slots.default?.()) 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useDialog' 2 | export * from './DialogTrigger' 3 | export * from './DialogContent' 4 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Dialog/useDialog.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, provide, Ref, PropType } from 'vue' 2 | import { usePopover } from '../Popover' 3 | 4 | import { DialogOptions, DialogAPIKey } from '../types' 5 | import { wrapProp, createInjector } from '@varia/composables' 6 | 7 | export const dialogAPIKey = Symbol('dialogAPI') as DialogAPIKey 8 | export function useDialog( 9 | selected: Ref, 10 | options: DialogOptions 11 | ) { 12 | const popoverAPI = usePopover(selected) 13 | 14 | provide(dialogAPIKey, { 15 | ...popoverAPI, 16 | options: { 17 | ...popoverAPI.options, 18 | ...options, 19 | }, 20 | }) 21 | 22 | return popoverAPI 23 | } 24 | 25 | export const injectDialogAPI = createInjector(dialogAPIKey, 'injectDialogAPI') 26 | 27 | export const dialogProps = { 28 | modal: Boolean as PropType, 29 | modelValue: Boolean as PropType, 30 | } 31 | 32 | export const Dialog = defineComponent({ 33 | name: 'Dialog', 34 | props: dialogProps, 35 | emits: ['update:modelValue'], 36 | setup(props, { slots }) { 37 | const state = wrapProp(props, 'modelValue') 38 | const { 39 | state: { selected }, 40 | } = useDialog(state, props) 41 | return () => slots.default?.({ selected }) 42 | }, 43 | }) 44 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Disclosure/DisclosureContent.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, computed, Ref, PropType } from 'vue' 2 | import { injectDisclosureAPI } from './use-disclosure' 3 | 4 | import { DisclosureAPIKey, DisclosureAPI } from '../types' 5 | 6 | export function useDisclosureContent( 7 | api: DisclosureAPI, 8 | el?: Ref 9 | ) { 10 | const { 11 | state: { selected: isOpen }, 12 | options: { id }, 13 | } = api 14 | const attributes = computed(() => ({ 15 | ref: el, 16 | id, 17 | style: !isOpen.value ? 'display: none' : undefined, 18 | 'aria-hidden': !isOpen.value, 19 | })) 20 | 21 | return { isOpen, attributes } 22 | } 23 | 24 | export const DisclosureContent = defineComponent({ 25 | name: 'Disclosure', 26 | props: { 27 | tag: { 28 | type: String, 29 | default: 'DIV', 30 | }, 31 | apiKey: { 32 | type: Symbol as PropType, 33 | }, 34 | }, 35 | setup(props, { slots }) { 36 | const api = injectDisclosureAPI(props.apiKey) 37 | const { isOpen, attributes } = useDisclosureContent(api) 38 | return () => { 39 | return h( 40 | props.tag, 41 | attributes.value, 42 | slots.default?.({ attributes: attributes.value, isOpen: isOpen.value }) 43 | ) 44 | } 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Disclosure/DisclosureTrigger.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, computed, h, Ref, PropType } from 'vue' 2 | import { useButton, ButtonProps } from '../Button' 3 | import { injectDisclosureAPI } from './use-disclosure' 4 | 5 | import { ButtonOptions, DisclosureAPIKey, DisclosureAPI } from '../types' 6 | 7 | export function useDisclosureTrigger( 8 | props: ButtonOptions, 9 | api: DisclosureAPI, 10 | el?: Ref 11 | ) { 12 | const { 13 | state: { selected: isOpen }, 14 | options: { id }, 15 | } = api 16 | 17 | const onClick = () => { 18 | isOpen.value = !isOpen.value 19 | } 20 | const btnAttrs = useButton(props, el) 21 | const attributes = computed(() => ({ 22 | ...btnAttrs.value, 23 | 'aria-expanded': isOpen.value, 24 | 'aria-controls': id, 25 | onClick, 26 | })) 27 | 28 | return attributes 29 | } 30 | 31 | export const DisclosureTrigger = defineComponent({ 32 | name: 'DisclosureTrigger', 33 | props: { 34 | tag: { 35 | type: String, 36 | default: 'DIV', 37 | }, 38 | apiKey: { 39 | type: Symbol as PropType, 40 | }, 41 | ...ButtonProps, 42 | }, 43 | setup(props, { slots }) { 44 | const api = injectDisclosureAPI(props.apiKey) 45 | const attributes = useDisclosureTrigger(props, api) 46 | return () => { 47 | return slots.replace 48 | ? slots.replace(attributes.value) 49 | : h( 50 | props.tag, 51 | { 52 | ...attributes.value, 53 | }, 54 | slots.default?.(attributes.value) 55 | ) 56 | } 57 | }, 58 | }) 59 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Disclosure/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-disclosure' 2 | export * from './DisclosureTrigger' 3 | export * from './DisclosureContent' 4 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Disclosure/use-disclosure.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, provide, Ref, PropType } from 'vue' 2 | import { createId, wrapProp, createInjector } from '@varia/composables' 3 | 4 | import { DisclosureAPIKey, DisclosureOptions } from '../types' 5 | 6 | export const disclosureAPIKey = Symbol('disclosure') as DisclosureAPIKey 7 | 8 | function _useDisclosure( 9 | selected: Ref, 10 | { skipProvide, customKey }: DisclosureOptions = {} 11 | ) { 12 | const id = createId() 13 | const api = { 14 | state: { 15 | selected, 16 | toggle: () => (selected.value = !selected.value), 17 | }, 18 | options: { 19 | id, 20 | }, 21 | } 22 | // const key = customKey ?? disclosureAPIKey 23 | // !skipProvide && provide(key, api) 24 | 25 | return api 26 | } 27 | 28 | export const useDisclosure = Object.assign(_useDisclosure, { 29 | withProvide(selected: Ref) { 30 | const api = _useDisclosure(selected) 31 | provide(disclosureAPIKey, api) 32 | return api 33 | }, 34 | }) 35 | 36 | export const injectDisclosureAPI = createInjector( 37 | disclosureAPIKey, 38 | `injectDisclosureAPI()` 39 | ) 40 | 41 | export const disclosureProps = { 42 | modelValue: Boolean as PropType, 43 | } 44 | 45 | export const Disclosure = defineComponent({ 46 | name: 'Disclosure', 47 | props: disclosureProps, 48 | emits: ['update:modelValue'], 49 | setup(props, { slots }) { 50 | const state = wrapProp(props, 'modelValue') 51 | useDisclosure.withProvide(state) 52 | return () => slots.default?.() 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/FocusTrap/FocusTrap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | readonly, 4 | reactive, 5 | Ref, 6 | ref, 7 | watch, 8 | onMounted, 9 | onUnmounted, 10 | defineComponent, 11 | h, 12 | PropType, 13 | toRaw, 14 | } from 'vue' 15 | import { 16 | applyFocus, 17 | getFocusableElements, 18 | TABBABLE_ELS, 19 | focusTracker, 20 | tabDirection, 21 | TemplRef, 22 | TemplRefType, 23 | useEventIf, 24 | wrapProp, 25 | } from '@varia/composables' 26 | 27 | // import { useInert } from './inert' 28 | 29 | import { FocusTrapOptions } from '../types' 30 | 31 | // only one FocusTrap can be active at a time. 32 | // So we track a Queue of all active FocusTraps, 33 | const queue = reactive>(new Set()) 34 | const remove = (id: Symbol) => queue.delete(id) 35 | const add = (id: Symbol) => { 36 | remove(id) 37 | queue.add(id) 38 | } 39 | const active = computed(() => Array.from(queue).reverse()[0]) 40 | const focusTrapQueue = readonly({ 41 | active, 42 | add, 43 | remove, 44 | }) 45 | 46 | function getNextFocusElement( 47 | el1: HTMLElement, 48 | el2: HTMLElement, 49 | direction: 'forward' | 'backward' 50 | ) { 51 | const property = 52 | direction === 'backward' ? 'previousElementSibling' : 'nextElementSibling' 53 | const startEl = direction === 'backward' ? el2 : el1 54 | const endEl = direction === 'backward' ? el1 : el2 55 | 56 | let nextSibling: Element | null = startEl[property] 57 | 58 | while (nextSibling && nextSibling !== endEl) { 59 | // if sibling is focusable, return it 60 | if (nextSibling.matches(TABBABLE_ELS)) return nextSibling as HTMLElement 61 | // else, loof for focusable descendants 62 | const els = getFocusableElements(nextSibling as HTMLElement) 63 | const idx = direction === 'backward' ? els.length - 1 : 0 64 | // if a descendant is focusable, return it 65 | if (els[idx]) return els[idx] 66 | // else, go to next Sibling 67 | nextSibling = nextSibling[property] 68 | } 69 | return undefined 70 | } 71 | 72 | function isBetween(el: El, el1: El, el2: El) { 73 | return ( 74 | el.compareDocumentPosition(el2) & Node.DOCUMENT_POSITION_FOLLOWING && 75 | el.compareDocumentPosition(el1) & Node.DOCUMENT_POSITION_PRECEDING 76 | ) 77 | } 78 | 79 | const defaultOptions = { 80 | activateOnMount: true, 81 | } 82 | export function useFocusTrap( 83 | state: Ref, 84 | _options: FocusTrapOptions = defaultOptions 85 | ) { 86 | const options = Object.assign({}, defaultOptions, _options) 87 | 88 | const startEl: TemplRef = ref() 89 | const endEl: TemplRef = ref() 90 | 91 | const id = Symbol('focusGroupId') 92 | const activate = () => { 93 | focusTrapQueue.add(id) 94 | !state.value && (state.value = true) 95 | } 96 | const deactivate = () => { 97 | focusTrapQueue.remove(id) 98 | state.value && (state.value = false) 99 | } 100 | const isActiveTrap = computed(() => toRaw(focusTrapQueue.active) === id) 101 | watch(state, state => { 102 | state ? activate() : deactivate() 103 | }) 104 | 105 | // options.useInert && useInert(wrapperEl, isActiveTrap) 106 | 107 | // Mount/Unmount 108 | options.activateOnMount && onMounted(activate) 109 | onUnmounted(deactivate) 110 | 111 | const autoMovefocus = (defaultDirection: 'forward' | 'backward') => { 112 | console.log('autofucs()', tabDirection.value) 113 | let el: TemplRefType 114 | switch (tabDirection.value) { 115 | case 'forward': 116 | // if (skip === 'forward') return 117 | el = getNextFocusElement(startEl.value!, endEl.value!, 'forward') 118 | el && el.focus() 119 | break 120 | case 'backward': 121 | // if (skip === 'backward') return 122 | el = getNextFocusElement(startEl.value!, endEl.value!, 'backward') 123 | el && el.focus() 124 | break 125 | case undefined: 126 | el = getNextFocusElement(startEl.value!, endEl.value!, defaultDirection) 127 | el && el.focus() 128 | break 129 | } 130 | } 131 | // first element should never have focus 132 | useEventIf(isActiveTrap, startEl, 'focus', () => autoMovefocus('forward')) 133 | // last element should never have focus 134 | useEventIf(isActiveTrap, endEl, 'focus', () => autoMovefocus('backward')) 135 | 136 | // move focus if - for whatever reason, i.e. a mouse click, 137 | // any element not included of the trap's elements has received focus 138 | useEventIf(isActiveTrap, document, 'focusin', ({ target }) => { 139 | if (target === startEl.value || target === endEl.value) return 140 | if (isBetween(target as HTMLElement, startEl.value!, endEl.value!)) return 141 | const prevEl = focusTracker.prevEl.value 142 | if (prevEl) { 143 | if (isBetween(prevEl, startEl.value!, endEl.value!)) { 144 | // if focus was moved outside of the Trap, 145 | // bring it back to the last element in the Trap 146 | applyFocus(prevEl) 147 | } else { 148 | // but if the previously focussed Element wasn't inside the FocusTrap, 149 | // move focus to the first element in the Trap. 150 | startEl.value!.focus() 151 | } 152 | } 153 | }) 154 | 155 | return { 156 | startElAttrs: computed(() => ({ 157 | ref: startEl, 158 | 'data-varia-visually-hidden': true, 159 | 'data-varia-focustrap-start': true, 160 | tabindex: isActiveTrap.value ? 0 : undefined, 161 | })), 162 | endElAttrs: computed(() => ({ 163 | 'data-varia-visually-hidden': true, 164 | 'data-varia-focustrap-end': true, 165 | ref: endEl, 166 | tabindex: isActiveTrap.value ? 0 : undefined, 167 | })), 168 | isActive: isActiveTrap, 169 | activate, 170 | deactivate, 171 | } 172 | } 173 | 174 | const focusTrapProps = { 175 | tag: String, 176 | activateOnMount: { 177 | type: Boolean as PropType, 178 | default: true, 179 | }, 180 | modelValue: { 181 | type: Boolean as PropType, 182 | required: true, 183 | }, 184 | useInert: { 185 | type: Boolean as PropType, 186 | default: false, 187 | }, 188 | } 189 | 190 | export const FocusTrap = defineComponent({ 191 | name: 'FocusTrap', 192 | props: focusTrapProps, 193 | inheritAttrs: false, 194 | setup(props, { slots }) { 195 | const state = wrapProp(props, 'modelValue') 196 | const focusTrap = useFocusTrap(state, props) 197 | return () => { 198 | return [ 199 | h('SPAN', focusTrap.startElAttrs.value), 200 | ...(slots.default?.() || []), 201 | h('SPAN', focusTrap.endElAttrs.value), 202 | ] 203 | } 204 | }, 205 | }) 206 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/FocusTrap/index.css: -------------------------------------------------------------------------------- 1 | [data-varia-visually-hidden='true'] { 2 | position: absolute !important; 3 | height: 1px; 4 | width: 1px; 5 | overflow: hidden; 6 | clip: rect(1px 1px 1px 1px); /* IE6, IE7 */ 7 | clip: rect(1px, 1px, 1px, 1px); 8 | white-space: nowrap; /* added line */ 9 | } 10 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/FocusTrap/index.ts: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | export * from './FocusTrap' 3 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/FocusTrap/inert.ts: -------------------------------------------------------------------------------- 1 | import { watchEffect, Ref } from 'vue' 2 | import { TemplRef } from '@varia/composables' 3 | 4 | export function useInert(wrapperEl: TemplRef, isActive: Ref) { 5 | watchEffect(onCleanup => { 6 | const { value: el } = wrapperEl 7 | if (el && isActive.value) { 8 | inert(el) 9 | } 10 | onCleanup(() => el && uninert(el)) 11 | }) 12 | } 13 | 14 | function inert(el: HTMLElement, set: boolean = true) { 15 | let ancestors: Array = [el] 16 | let parent: HTMLElement | null = el.parentElement 17 | let sibling: Element | null 18 | while (parent) { 19 | ancestors.push(parent) 20 | parent = parent.parentElement 21 | } 22 | for (let el of ancestors) { 23 | sibling = el.nextElementSibling 24 | if (!sibling) continue 25 | while (sibling) { 26 | set 27 | ? sibling.setAttribute('inert', 'true') 28 | : sibling.removeAttribute('inert') 29 | sibling = sibling.nextElementSibling 30 | } 31 | sibling = el.previousElementSibling 32 | if (!sibling) continue 33 | while (sibling) { 34 | set 35 | ? sibling.setAttribute('inert', 'true') 36 | : sibling.removeAttribute('inert') 37 | sibling = sibling.previousElementSibling 38 | } 39 | } 40 | } 41 | function uninert(el: HTMLElement) { 42 | return inert(el, false) 43 | } 44 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/ListBox/ListBoxItem.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | ref, 4 | computed, 5 | toRefs, 6 | h, 7 | PropType, 8 | inject, 9 | mergeProps, 10 | } from 'vue' 11 | import { useArrowNavigationItem, TemplRef } from '@varia/composables' 12 | import { injectListBoxAPI, listBoxAPIKey } from './useListBox' 13 | import { useButton, ButtonProps } from '../Button' 14 | import { ButtonOptions, ListBoxAPI, ListBoxAPIKey } from '../types' 15 | 16 | export const listBoxItemProps = { 17 | ...ButtonProps, 18 | tag: { 19 | type: String, 20 | default: 'DIV', 21 | }, 22 | apiKey: { 23 | type: Symbol as PropType, 24 | }, 25 | label: { 26 | type: String, 27 | }, 28 | item: { 29 | required: true, 30 | }, 31 | } 32 | 33 | export function useListBoxItem(props: ButtonOptions, api: ListBoxAPI) { 34 | const el: TemplRef = ref() 35 | const onClick = () => { 36 | if (!props.disabled && props.item) { 37 | api.options.autoSelect 38 | ? api.state.select(props.item) 39 | : api.state.toggle(props.item) 40 | } 41 | } 42 | const isDisabled = computed(() => !!props.disabled) 43 | const isSelected = computed(() => api.state.selected.has(props.item)) 44 | 45 | // Arrow Navigation 46 | const id = api.generateId(props.item) 47 | // api.arrowNavAPI.addIdToNavigation(id, isDisabled) 48 | // const hasFocus = computed(() => api.arrowNavAPI.currentActiveId.value === id) 49 | 50 | // Attributes 51 | const buttonAttrs = useButton(props, el) 52 | const arrowAttrs = useArrowNavigationItem( 53 | { 54 | id, 55 | isDisabled, 56 | }, 57 | api.arrowNav 58 | ) 59 | return computed(() => 60 | mergeProps(buttonAttrs.value, arrowAttrs.value, { 61 | id, 62 | role: 'option' as const, 63 | 'aria-selected': isSelected.value, 64 | onClick, 65 | ref: el, 66 | }) 67 | ) 68 | } 69 | 70 | export const ListBoxItem = defineComponent({ 71 | name: 'ListboxItem', 72 | props: listBoxItemProps, 73 | setup(props, { slots }) { 74 | const { label } = toRefs(props) 75 | const api = inject(listBoxAPIKey) 76 | const attributes = useListBoxItem(props, api!) 77 | 78 | return () => { 79 | return h( 80 | props.tag, 81 | { 82 | ...attributes.value, 83 | }, 84 | label?.value || slots.default?.(attributes.value) 85 | ) 86 | } 87 | }, 88 | }) 89 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/ListBox/index.css: -------------------------------------------------------------------------------- 1 | [role='listbox'] [role='option']:not([aria-disabled='true']) { 2 | cursor: pointer; 3 | } 4 | [role='listbox'] { 5 | outline: none; 6 | } 7 | [role='listbox']:focus [role='option'][data-varia-focus='true'] { 8 | outline: 2px solid red; 9 | } 10 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/ListBox/index.ts: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | export * from './useListBox' 3 | export * from './ListBoxItem' 4 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/ListBox/useListBox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | reactive, 4 | Ref, 5 | provide, 6 | PropType, 7 | h, 8 | watch, 9 | } from 'vue' 10 | import { 11 | createCachedIdFn, 12 | wrapProp, 13 | useArrowNavigation, 14 | useArrowNavWrapper, 15 | useReactiveDefaults, 16 | createInjector, 17 | } from '@varia/composables' 18 | 19 | import { ListBoxOptions, ListBoxAPI, ListBoxAPIKey } from '../types' 20 | 21 | export const listBoxAPIKey = Symbol('listBoxAPI') as ListBoxAPIKey 22 | 23 | const defaultOptions: ListBoxOptions = { 24 | autoSelect: false, 25 | virtual: false, 26 | multiple: false, 27 | orientation: 'vertical', 28 | } 29 | 30 | export function useListBox( 31 | state: Ref, 32 | options: Partial = {} 33 | ) { 34 | const { multiple, orientation, virtual, autoSelect } = useReactiveDefaults( 35 | options, 36 | defaultOptions 37 | ) 38 | 39 | // State 40 | // TODO: this should be cleaner and possibly abstracted away 41 | const selected = reactive(new Set(state.value)) 42 | watch(state, s => { 43 | selected.clear() 44 | s.forEach(item => selected.add(item)) 45 | }) 46 | const select = (item: any) => { 47 | !multiple.value && selected.clear() 48 | selected.add(item) 49 | state.value = Array.from(selected) 50 | } 51 | const unselect = (item: any) => { 52 | selected.delete(item) 53 | state.value = Array.from(selected) 54 | } 55 | const toggle = (item: any) => { 56 | selected.has(item) ? unselect(item) : select(item) 57 | } 58 | // Keyboard navigation 59 | const arrowNav = useArrowNavigation( 60 | reactive({ 61 | orientation, 62 | startOnFirstSelected: true, 63 | virtual, 64 | autoSelect: multiple.value ? false : autoSelect.value, 65 | }) 66 | ) 67 | 68 | // API 69 | const api: ListBoxAPI = { 70 | generateId: createCachedIdFn('listbox'), 71 | state: { 72 | selected, 73 | select, 74 | unselect, 75 | toggle, 76 | }, 77 | arrowNav, 78 | options: reactive({ 79 | multiple, 80 | orientation, 81 | virtual, 82 | autoSelect, 83 | }), 84 | } 85 | provide(listBoxAPIKey, api) 86 | 87 | return api 88 | } 89 | 90 | export const injectListBoxAPI = createInjector( 91 | listBoxAPIKey, 92 | 'injectListBoxAPI' 93 | ) 94 | 95 | export const listBoxProps = { 96 | modelValue: { 97 | type: Array, 98 | default: () => [], 99 | }, 100 | multiple: Boolean as PropType, 101 | orientation: { 102 | type: String as PropType<'horizontal' | 'vertical'>, 103 | }, 104 | autoSelect: { 105 | type: Boolean as PropType, 106 | default: false, 107 | }, 108 | virtual: { 109 | type: Boolean as PropType, 110 | default: true, 111 | }, 112 | } 113 | 114 | export const ListBox = defineComponent({ 115 | name: 'ListBox', 116 | props: listBoxProps, 117 | setup(props, { slots }) { 118 | const state = wrapProp(props, 'modelValue') 119 | const api = useListBox(state, props) 120 | const attributes = useArrowNavWrapper(api.arrowNav) 121 | return () => 122 | h( 123 | 'div', 124 | { 125 | role: 'listbox', 126 | ...attributes.value, 127 | ...(props.multiple ? { 'aria-multiselectable': true } : {}), 128 | }, 129 | slots.default?.(api) 130 | ) 131 | }, 132 | }) 133 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Popover/PopoverContent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | h, 4 | getCurrentInstance, 5 | ref, 6 | PropType, 7 | watch, 8 | watchEffect, 9 | nextTick, 10 | } from 'vue' 11 | import { 12 | useKeyIf, 13 | useClickOutside, 14 | getFirstFocusableChild, 15 | } from '@varia/composables' 16 | import { useDisclosureContent } from '../Disclosure' 17 | import { injectPopoverAPI } from './usePopover' 18 | import { 19 | createPopper, 20 | Options as PopperOptions, 21 | Instance as PopperInstance, 22 | } from '@popperjs/core' 23 | 24 | import { PopoverContentOptions, PopoverAPIKey, PopoverAPI } from '../types' 25 | 26 | export const PopoverContentProps = { 27 | tag: { 28 | type: String, 29 | default: 'DIV', 30 | }, 31 | apiKey: { 32 | type: Symbol as PropType, 33 | }, 34 | closeOnEscape: { type: Boolean as PropType, default: true }, 35 | closeOnClickOutside: { 36 | type: Boolean as PropType, 37 | default: true, 38 | }, 39 | focusOnOpen: { 40 | type: Boolean as PropType, 41 | }, 42 | returnFocusOnClose: { 43 | type: Boolean as PropType, 44 | default: true, 45 | }, 46 | popperOptions: { 47 | type: Object as PropType, 48 | }, 49 | } 50 | 51 | const defaults: PopoverContentOptions = { 52 | closeOnEscape: true, 53 | closeOnClickOutside: true, 54 | focusOnOpen: false, 55 | returnFocusOnClose: true, 56 | } 57 | 58 | export function usePopoverContent( 59 | _options: Partial = {}, 60 | api: PopoverAPI 61 | ) { 62 | const options = Object.assign({}, defaults, _options) 63 | const vm = getCurrentInstance() 64 | 65 | const el = ref() 66 | const { attributes } = useDisclosureContent(api, el) 67 | const { 68 | state: { selected: isOpen }, 69 | elements: { triggerEl }, 70 | } = api 71 | 72 | // Closing Behaviours 73 | const close = () => { 74 | vm && vm.emit('closed') 75 | isOpen.value = false 76 | } 77 | options.closeOnEscape && useKeyIf(ref(true), ['Escape'], close) 78 | options.closeOnClickOutside && useClickOutside([el, triggerEl], close) 79 | 80 | // Focus Lifecycle 81 | options.focusOnOpen && 82 | watch(isOpen, isOpen => { 83 | isOpen && 84 | el.value && 85 | nextTick(() => { 86 | const nextEl = getFirstFocusableChild(el.value!) 87 | nextEl && nextEl.focus() 88 | }) 89 | !isOpen && options.returnFocusOnClose && triggerEl.value?.focus() 90 | }) 91 | 92 | // Positioning the Popover using Popper.js 93 | let popperInstance: PopperInstance 94 | watchEffect(onCleanup => { 95 | if (isOpen.value && el.value && triggerEl.value) { 96 | popperInstance = createPopper( 97 | triggerEl.value, 98 | el.value, 99 | options.popperOptions 100 | ) 101 | // TODO: Do we need this? 102 | nextTick(() => popperInstance?.forceUpdate()) 103 | } 104 | onCleanup(() => { 105 | if (!isOpen.value && popperInstance) popperInstance.destroy() 106 | }) 107 | }) 108 | 109 | return { 110 | isOpen, 111 | close, 112 | attributes, 113 | focusFirstElement: () => { 114 | const nextEl = getFirstFocusableChild(el.value!) 115 | nextEl && nextEl.focus() 116 | }, 117 | } 118 | } 119 | 120 | export const PopoverContent = defineComponent({ 121 | name: 'PopoverContent', 122 | props: PopoverContentProps, 123 | setup(props, { slots }) { 124 | const api = injectPopoverAPI(props.apiKey) 125 | const state = usePopoverContent(props, api) 126 | return () => 127 | state.isOpen.value 128 | ? h(props.tag, state.attributes.value, slots.default?.(state)) 129 | : null 130 | }, 131 | }) 132 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Popover/PopoverTrigger.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, PropType, computed } from 'vue' 2 | import { useDisclosureTrigger } from '../Disclosure' 3 | import { ButtonProps } from '../Button' 4 | import { injectPopoverAPI } from './usePopover' 5 | 6 | import { ButtonOptions, PopoverAPI, PopoverAPIKey } from '../types' 7 | 8 | export function usePopoverTrigger(props: ButtonOptions, api: PopoverAPI) { 9 | const disclosureAttrs = useDisclosureTrigger( 10 | props, 11 | api, 12 | api.elements.triggerEl 13 | ) 14 | 15 | const attributes = computed(() => ({ 16 | ...disclosureAttrs.value, 17 | 'aria-has-popup': 'true', 18 | })) 19 | 20 | // TODO: implement arrow key nav to Popover? 21 | 22 | return attributes 23 | } 24 | 25 | export const PopoverTrigger = defineComponent({ 26 | name: 'PopoverTrigger', 27 | props: { 28 | ...ButtonProps, 29 | apiKey: { 30 | type: Symbol as PropType, 31 | }, 32 | }, 33 | setup(props, { slots }) { 34 | const api = injectPopoverAPI(props.apiKey) 35 | const attributes = usePopoverTrigger(props, api) 36 | return () => 37 | h(props.tag, attributes.value, slots.default?.(attributes.value)) 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Popover/index.ts: -------------------------------------------------------------------------------- 1 | export * from './PopoverContent' 2 | export * from './PopoverTrigger' 3 | export * from './usePopover' 4 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Popover/usePopover.ts: -------------------------------------------------------------------------------- 1 | import { Ref, provide, ref, defineComponent, PropType } from 'vue' 2 | import { useDisclosure } from '../Disclosure' 3 | import { PopoverAPIKey } from '../types' 4 | import { TemplRef, wrapProp, createInjector } from '@varia/composables' 5 | 6 | export const popoverAPIKey = Symbol('popoverAPI') as PopoverAPIKey 7 | 8 | function _usePopover(state: Ref) { 9 | const triggerEl: TemplRef = ref() 10 | const contentEl: TemplRef = ref() 11 | 12 | const disclosureAPI = useDisclosure(state) 13 | 14 | const api = { 15 | ...disclosureAPI, 16 | elements: { 17 | triggerEl, 18 | contentEl, 19 | }, 20 | } 21 | 22 | return api 23 | } 24 | 25 | export const usePopover = Object.assign(_usePopover, { 26 | withProvide(selected: Ref) { 27 | const api = _usePopover(selected) 28 | provide(popoverAPIKey, api) 29 | return api 30 | }, 31 | }) 32 | 33 | export const injectPopoverAPI = createInjector( 34 | popoverAPIKey, 35 | `injectPopoverAPI()` 36 | ) 37 | 38 | export const popoverProps = { 39 | modelValue: Boolean as PropType, 40 | } 41 | 42 | export const Popover = defineComponent({ 43 | name: 'Popover', 44 | props: popoverProps, 45 | emits: ['update:modelValue'], 46 | setup(props, { slots }) { 47 | const state = wrapProp(props, 'modelValue') 48 | usePopover.withProvide(state) 49 | return () => slots.default?.() 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/SkipToContent/index.ts: -------------------------------------------------------------------------------- 1 | import { ref, defineComponent, h, watch } from 'vue' 2 | import { TemplRef, wrapProp, useElementFocusObserver } from '@varia/composables' 3 | import { useClickable } from '../Clickable' 4 | 5 | // export function useSkipToContent(_el?: TemplRef) { 6 | // const el = _el ?? ref() 7 | 8 | // watch(hasFocus, () => void 0) 9 | // return { 10 | // hasFocus: hasFocus, 11 | // attributes: { 12 | // ref: el, 13 | // }, 14 | // } 15 | // } 16 | 17 | export const SkipToContent = defineComponent({ 18 | name: 'SkipToContent', 19 | props: { 20 | tag: String, 21 | contentId: { 22 | type: String, 23 | required: true, 24 | }, 25 | }, 26 | setup(props, { slots }) { 27 | const el: TemplRef = ref() 28 | const clickableAttrs = useClickable({}, el) 29 | const { hasFocus } = useElementFocusObserver(el) 30 | // This watch is required to work around a strange bug. 31 | watch(hasFocus, () => void 0) 32 | return () => { 33 | const tag = props.tag ? props.tag.toLowerCase() : 'a' 34 | return h( 35 | 'a', 36 | { 37 | href: props.contentId, 38 | 'data-varia-visually-hidden': hasFocus.value ? undefined : true, 39 | ...(tag === 'a' ? {} : clickableAttrs.value), 40 | ref: el, 41 | }, 42 | slots.default?.() 43 | ) 44 | } 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabbable/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-tabbable' 2 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabbable/use-tabbable.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, ref, toRaw, PropType, watchEffect } from 'vue' 2 | import { useEvent, TemplRef, isNativeTabbable } from '@varia/composables' 3 | import { TabbableOptions } from '../types' 4 | 5 | export const TabbableProps = { 6 | disabled: { 7 | type: Boolean as PropType, 8 | }, 9 | focusable: { 10 | type: Boolean as PropType, 11 | }, 12 | } 13 | 14 | const defaults = { 15 | disabled: false, 16 | focusable: undefined, 17 | } 18 | 19 | export function useTabbable(_options: TabbableOptions = {}, _el?: TemplRef) { 20 | const options = reactive(Object.assign({}, defaults, _options)) 21 | 22 | const el = _el || ref() 23 | 24 | const preventDefaults = (event: Event): true | undefined => { 25 | if (event.target !== toRaw(el.value)) return 26 | if (options.disabled) { 27 | event.stopImmediatePropagation() 28 | event.stopPropagation() 29 | event.preventDefault() 30 | return true 31 | } 32 | return undefined 33 | } 34 | const onClick = (event: Event) => { 35 | if (event.target !== toRaw(el.value)) return 36 | preventDefaults(event) || (el.value!.tabIndex > -1 && el.value?.focus()) 37 | } 38 | useEvent(document, 'mouseDown', preventDefaults, { capture: true }) 39 | useEvent(document, 'mouseOver', preventDefaults, { capture: true }) 40 | useEvent(document, 'click', onClick, { capture: true }) 41 | 42 | // I'd rather do this the "right" way: returning it as an attribute from this funcion 43 | // but it depends on wether the element's tagName which we only know after initial render, 44 | // when the `el` ref has been populated. 45 | // so we do it imperatively here: 46 | watchEffect(() => { 47 | const rawEl = el.value 48 | if (!rawEl) return 49 | if (!isNativeTabbable(rawEl)) { 50 | return 51 | } 52 | ;(rawEl as any).disabled = options.disabled && !options.focusable 53 | }) 54 | 55 | return computed(() => ({ 56 | ref: el, 57 | tabindex: options.disabled && !options.focusable ? undefined : 0, 58 | 'aria-disabled': options.disabled || undefined, 59 | })) 60 | } 61 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabs/Tab.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | defineComponent, 4 | onMounted, 5 | ref, 6 | ExtractPropTypes, 7 | PropType, 8 | h, 9 | mergeProps, 10 | } from 'vue' 11 | import { useArrowNavigationItem, createId } from '@varia/composables' 12 | import { injectTabsAPI, _tabsAPIKey } from './use-tabs' 13 | import { useClickable, ClickableProps } from '../Clickable' 14 | 15 | import { TabsAPI, TabsAPIKey } from '../types' 16 | 17 | export type useTabOptions = ExtractPropTypes 18 | export const TabProps = { 19 | tag: { 20 | type: [String, Object], 21 | default: 'SPAN', 22 | }, 23 | name: { 24 | type: String, 25 | required: true, 26 | }, 27 | tabsKey: { 28 | type: Symbol as PropType, 29 | }, 30 | ...ClickableProps, 31 | } 32 | 33 | export function useTab(props: useTabOptions, api: TabsAPI) { 34 | const el = ref() 35 | const id = 'tab_' + createId() 36 | // api.arrowNav.addIdToNavigation( 37 | // id, 38 | // computed(() => !!props.disabled) 39 | // ) 40 | const isSelected = computed(() => api.state.selected.value === props.name) 41 | const isDisabled = computed(() => !!props.disabled) 42 | 43 | // const hasFocus = computed(() => id === api.arrowNav.currentActiveId.value) 44 | const select = () => { 45 | !props.disabled && props.name && api.state.select(props.name) 46 | } 47 | 48 | onMounted(() => { 49 | el.value && isSelected.value && api.arrowNav.select(id) 50 | // Verify that this tab is a child of a role=tablist element 51 | // TODO: Should run in __DEV__ only 52 | if (el.value && !el.value?.closest('[role="tablist"]')) { 53 | console.warn(' has to be nested inside of a ``') 54 | } 55 | }) 56 | 57 | // Element Attributes 58 | const clickableAttrs = useClickable(props, el) 59 | const arrrowNavAttrs = useArrowNavigationItem( 60 | { id, isDisabled }, 61 | api.arrowNav 62 | ) 63 | const attributes = computed(() => 64 | mergeProps(clickableAttrs.value, arrrowNavAttrs.value, { 65 | id, 66 | role: 'tab' as const, 67 | onClick: select, 68 | 'aria-selected': isSelected.value, 69 | 'aria-controls': api.generateId(props.name!), 70 | }) 71 | ) 72 | 73 | return { isSelected, attributes } 74 | } 75 | 76 | export const Tab = defineComponent({ 77 | name: 'Tab', 78 | props: TabProps, 79 | setup(props, { slots }) { 80 | const api = injectTabsAPI() 81 | const { isSelected, attributes } = useTab(props, api!) 82 | return () => { 83 | return h( 84 | props.tag, 85 | attributes.value, 86 | slots.default?.({ 87 | isSelected: isSelected.value, 88 | attributes: attributes.value, 89 | }) 90 | ) 91 | } 92 | }, 93 | }) 94 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabs/TabList.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, PropType } from 'vue' 2 | import { useArrowNavWrapper } from '@varia/composables' 3 | import { injectTabsAPI } from './use-tabs' 4 | 5 | import { TabsAPIKey } from '../types' 6 | 7 | export const TabList = defineComponent({ 8 | name: 'TabList', 9 | props: { 10 | tag: { 11 | type: String, 12 | default: 'DIV', 13 | }, 14 | tabsKey: { 15 | type: Symbol as PropType, 16 | }, 17 | }, 18 | setup(props, { slots }) { 19 | const api = injectTabsAPI(props.tabsKey) 20 | const attributes = useArrowNavWrapper(api.arrowNav) 21 | return () => 22 | h( 23 | props.tag, 24 | { 25 | role: 'tablist', 26 | ...attributes.value, 27 | }, 28 | slots.default?.() 29 | ) 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabs/TabPanel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | defineComponent, 4 | ExtractPropTypes, 5 | PropType, 6 | h, 7 | inject, 8 | } from 'vue' 9 | import { injectTabsAPI, _tabsAPIKey } from './use-tabs' 10 | 11 | import { TabsAPI, TabsAPIKey } from '../types' 12 | 13 | export type TabPanelOptions = ExtractPropTypes 14 | 15 | export const TabPanelProps = { 16 | tag: { 17 | type: String, 18 | default: 'DIV', 19 | }, 20 | name: { 21 | type: String, 22 | required: true, 23 | }, 24 | tabsKey: { 25 | type: Symbol as PropType, 26 | }, 27 | } 28 | 29 | export function useTabPanel(props: TabPanelOptions, api: TabsAPI) { 30 | const isSelected = computed(() => api.state.selected.value === props.name) 31 | const attributes = computed(() => ({ 32 | role: 'tabpanel' as const, 33 | id: api.generateId(props.name!), 34 | hidden: !isSelected.value, 35 | tabindex: isSelected.value ? -1 : undefined, 36 | })) 37 | return { isSelected, attributes } 38 | } 39 | 40 | export const TabPanel = defineComponent({ 41 | name: 'TabPanel', 42 | props: TabPanelProps, 43 | setup(props, { slots }) { 44 | const api = injectTabsAPI(props.tabsKey) 45 | // const api = inject(_tabsAPIKey) 46 | const { isSelected, attributes } = useTabPanel(props, api!) 47 | return () => { 48 | return ( 49 | isSelected.value && 50 | h( 51 | props.tag, 52 | attributes.value, 53 | slots.default?.({ 54 | isSelected: isSelected.value, 55 | attributes: attributes.value, 56 | }) 57 | ) 58 | ) 59 | } 60 | }, 61 | }) 62 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabs/index.css: -------------------------------------------------------------------------------- 1 | [role='tab']:not([aria-disabled='true']) { 2 | cursor: pointer; 3 | } 4 | [role='tablist'] { 5 | outline: none; 6 | } 7 | [role='tablist']:focus [role='tab'][data-varia-focus='true'] { 8 | outline: 2px solid red; 9 | } 10 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-tabs' 2 | export * from './TabList' 3 | export * from './Tab' 4 | export * from './TabPanel' 5 | export * from './use-tabs' 6 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Tabs/use-tabs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InjectionKey, 3 | provide, 4 | ref, 5 | readonly, 6 | defineComponent, 7 | h, 8 | PropType, 9 | Ref, 10 | reactive, 11 | } from 'vue' 12 | import { 13 | createCachedIdFn, 14 | useArrowNavigation, 15 | TemplRef, 16 | wrapProp, 17 | useReactiveDefaults, 18 | createInjector, 19 | } from '@varia/composables' 20 | import './index.css' 21 | 22 | import { TabsOptions, TabsAPI } from '../types' 23 | 24 | export const _tabsAPIKey = Symbol('tabAPI') as InjectionKey 25 | 26 | const defaultOptions: TabsOptions = { 27 | customName: undefined, 28 | // Options for ArrowNavigation 29 | orientation: undefined, 30 | loop: true, 31 | startOnFirstSelected: true, 32 | autoSelect: false, 33 | virtual: false, 34 | } 35 | 36 | export function useTabs(_state: Ref, options: Partial) { 37 | const { 38 | customName, 39 | // Options for ArrowNavigation 40 | orientation, 41 | loop, 42 | startOnFirstSelected, 43 | autoSelect, 44 | virtual, 45 | } = useReactiveDefaults(options, defaultOptions) 46 | 47 | // Tab State 48 | const selectedTab = _state 49 | const select = (name: string) => { 50 | selectedTab.value = name 51 | } 52 | 53 | // Keyboard Navigation 54 | const el: TemplRef = ref() 55 | const arrowNav = useArrowNavigation( 56 | reactive({ 57 | orientation, 58 | loop, 59 | autoSelect, 60 | startOnFirstSelected, 61 | virtual, 62 | }), 63 | el 64 | ) 65 | 66 | // API 67 | const tabsAPI = { 68 | generateId: createCachedIdFn(options.customName || 'tabs'), 69 | state: { 70 | select, 71 | selected: readonly(selectedTab), 72 | }, 73 | arrowNav, 74 | options: reactive({ 75 | customName, 76 | orientation, 77 | loop, 78 | startOnFirstSelected, 79 | autoSelect, 80 | virtual, 81 | }), 82 | } 83 | const tabsAPIKey = 84 | customName && customName.value ? Symbol('customTabAPIKey') : _tabsAPIKey 85 | provide(tabsAPIKey, tabsAPI) 86 | 87 | return { 88 | ...tabsAPI, 89 | tabsKey: tabsAPIKey, 90 | } 91 | } 92 | 93 | export const injectTabsAPI = createInjector(_tabsAPIKey, `injectTabsAPI()`) 94 | 95 | export const tabsProps = { 96 | tag: { 97 | type: String, 98 | default: 'DIV', 99 | }, 100 | modelValue: { 101 | type: String, 102 | default: '', 103 | }, 104 | orientation: String as PropType<'horizontal' | 'vertical'>, 105 | loop: Boolean as PropType, 106 | startOnFirstSelected: Boolean as PropType, 107 | autoSelect: Boolean as PropType, 108 | virtual: Boolean as PropType, 109 | } 110 | 111 | export const Tabs = defineComponent({ 112 | name: 'Tabs', 113 | props: tabsProps, 114 | setup(props, { slots }) { 115 | const state = wrapProp(props, 'modelValue') 116 | useTabs(state, props) 117 | return () => h(props.tag, slots.default?.()) 118 | }, 119 | }) 120 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Teleport/Teleport.ts: -------------------------------------------------------------------------------- 1 | import { h, defineComponent } from 'vue' 2 | import { TeleportProps } from '../types' 3 | export const Teleport = defineComponent(function Teleport( 4 | props: TeleportProps, 5 | { attrs, slots } 6 | ) { 7 | return h( 8 | props.tag ?? 'DIV', 9 | { 10 | 'data-variant-teleport': attrs.target as string, 11 | ...(props.disabled ? {} : { style: 'display: none' }), 12 | }, 13 | [h('teleport', attrs, slots.default)] 14 | ) 15 | }) 16 | 17 | // @ts-ignore 18 | Teleport.props = { 19 | tag: String, 20 | } 21 | // @ts-ignore 22 | Teleport.name = 'Teleport' 23 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/Teleport/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Teleport' 2 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/ToggleButton/index.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, computed, mergeProps, PropType, Ref } from 'vue' 2 | import { useButton, ButtonProps } from '../Button' 3 | import { wrapProp } from '@varia/composables' 4 | import { ToggleButtonOptions } from '../types' 5 | 6 | export function useToggleButton( 7 | state: Ref, 8 | options: ToggleButtonOptions 9 | ) { 10 | const btnAttrs = useButton(options) 11 | const attributes = computed(() => 12 | mergeProps(btnAttrs.value, { 13 | 'aria-pressed': state.value, 14 | onClick: () => (state.value = !state.value), 15 | }) 16 | ) 17 | return attributes 18 | } 19 | 20 | const toggleButtonProps = { 21 | ...ButtonProps, 22 | modelValue: { 23 | type: Boolean as PropType, 24 | required: true, 25 | }, 26 | } 27 | export const ToggleButton = defineComponent({ 28 | name: 'ToggleButton', 29 | props: toggleButtonProps, 30 | setup(props, { slots }) { 31 | const state = wrapProp(props, 'modelValue') 32 | const attributes = useToggleButton(state, props) 33 | return () => 34 | h(props.tag || 'button', attributes.value, slots.default?.(attributes)) 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/VisuallyHidden/index.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, h, PropType } from 'vue' 2 | 3 | export const VisuallyHidden = defineComponent({ 4 | name: 'VisuallyHidden', 5 | props: { 6 | tag: String, 7 | active: { 8 | type: Boolean as PropType, 9 | default: true, 10 | }, 11 | }, 12 | setup(props, { slots }) { 13 | return () => 14 | h( 15 | props.tag || 'DIV', 16 | { 17 | 'data-varia-visually-hidden': props.active, 18 | }, 19 | slots.default?.() 20 | ) 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Clickable' 2 | export * from './Tabbable' 3 | 4 | export * from './Button' 5 | export * from './ToggleButton' 6 | 7 | // Composites about showing popups, dialogs etc. 8 | export * from './Disclosure' 9 | export * from './FocusTrap' 10 | export * from './Popover' 11 | export * from './Dialog' 12 | 13 | // Composite Widgets with a liust of children 14 | export * from './ListBox' 15 | export * from './Accordion' 16 | export * from './Tabs' 17 | // Utility Components 18 | export * from './VisuallyHidden' 19 | export * from './SkipToContent' 20 | 21 | export * from './types' 22 | 23 | export * from '@varia/composables' 24 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import { Ref, InjectionKey } from 'vue' 2 | import { ArrowNavigation, TemplRefType, TemplRef } from '@varia/composables' 3 | import { Options as PopperOptions } from '@popperjs/core' 4 | 5 | import { 6 | BaseAPI, 7 | SetStateAPI, 8 | SingleStateAPI, 9 | BooleanStateAPI, 10 | } from '@varia/composables' 11 | 12 | /** 13 | * Accordion 14 | */ 15 | export interface AccordionOptions { 16 | multiple?: boolean 17 | orientation?: 'horizontal' | 'vertical' 18 | customName?: string 19 | loop?: boolean 20 | [key: string]: any 21 | } 22 | 23 | export interface AccordionAPI extends BaseAPI { 24 | generateId: (n: string) => string 25 | state: SetStateAPI 26 | arrowNav: ArrowNavigation 27 | options: AccordionOptions 28 | } 29 | 30 | export type AccordionAPIKey = InjectionKey 31 | 32 | /** 33 | * Tabbable 34 | */ 35 | 36 | export interface TabbableOptions { 37 | disabled?: boolean | undefined 38 | focusable?: boolean | undefined 39 | // onClick?: (e: T) => any 40 | // onMouseDown?: (e: T) => any 41 | [key: string]: any 42 | } 43 | 44 | /** 45 | * Clickable 46 | */ 47 | export interface ClickableOptions extends TabbableOptions {} 48 | 49 | /** 50 | * Button 51 | */ 52 | export interface ButtonOptions extends ClickableOptions {} 53 | 54 | /** 55 | * ToggleButton 56 | */ 57 | export interface ToggleButtonOptions extends ClickableOptions { 58 | modelValue?: boolean | undefined 59 | } 60 | 61 | /** 62 | * Listbox 63 | */ 64 | 65 | export type ListBoxOptions = { 66 | multiple: boolean 67 | orientation: 'horizontal' | 'vertical' 68 | virtual: boolean 69 | autoSelect: boolean 70 | } 71 | 72 | export interface ListBoxAPI extends BaseAPI { 73 | generateId: (name: string) => string 74 | state: SetStateAPI 75 | arrowNav: ArrowNavigation 76 | options: ListBoxOptions 77 | } 78 | 79 | export type ListBoxAPIKey = InjectionKey 80 | 81 | /** 82 | * Tabs 83 | */ 84 | export interface TabsOptions { 85 | customName?: string 86 | orientation?: 'vertical' | 'horizontal' 87 | autoSelect?: boolean 88 | startOnFirstSelected?: boolean 89 | loop?: boolean 90 | virtual?: boolean 91 | } 92 | 93 | export interface TabsAPI extends BaseAPI { 94 | generateId: (name: string) => string 95 | state: SingleStateAPI 96 | arrowNav: ArrowNavigation 97 | options: TabsOptions 98 | } 99 | 100 | export type TabsAPIKey = InjectionKey 101 | 102 | /** 103 | * Disclosure 104 | */ 105 | export interface DisclosureOptions { 106 | skipProvide?: boolean 107 | customKey?: symbol | string 108 | } 109 | 110 | export interface DisclosureAPI extends BaseAPI { 111 | state: BooleanStateAPI 112 | options: { 113 | id: string 114 | } 115 | } 116 | export type DisclosureAPIKey = InjectionKey 117 | 118 | /** 119 | * Popover 120 | */ 121 | export interface PopoverContentOptions { 122 | returnFocusOnClose: boolean 123 | closeOnEscape: boolean 124 | closeOnClickOutside: boolean 125 | focusOnOpen: boolean 126 | popperOptions?: PopperOptions 127 | } 128 | 129 | export interface PopoverAPI extends DisclosureAPI { 130 | elements: { 131 | triggerEl: TemplRef 132 | contentEl: TemplRef 133 | } 134 | } 135 | export type PopoverAPIKey = InjectionKey 136 | 137 | export interface PopoverOptions { 138 | position?: string 139 | flip?: boolean 140 | autofocus?: boolean 141 | } 142 | 143 | /** 144 | * FocusTrap 145 | */ 146 | export interface FocusTrapOptions { 147 | activateOnMount?: boolean 148 | modelValue?: boolean 149 | useInert?: boolean 150 | [key: string]: any 151 | } 152 | 153 | /** 154 | * Teleport 155 | */ 156 | export interface TeleportProps extends Record { 157 | tag?: string 158 | } 159 | 160 | /** 161 | * Dialog 162 | */ 163 | 164 | export interface DialogOptions { 165 | modal?: boolean 166 | } 167 | 168 | export interface DialogAPI extends PopoverAPI { 169 | options: { 170 | id: string 171 | } & DialogOptions 172 | } 173 | export type DialogAPIKey = InjectionKey 174 | 175 | export interface DialogContentOptions 176 | extends Omit {} 177 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export * from './pick' 2 | -------------------------------------------------------------------------------- /packages/aria-widgets/src/utils/pick.ts: -------------------------------------------------------------------------------- 1 | import { computed, reactive, Ref } from 'vue' 2 | // Logic derrived from https://github.com/jonschlinkert/object.omit, 3 | // simplified and typed by this project 4 | export function omit( 5 | obj: O, 6 | props: K[] 7 | ): Omit { 8 | var keys = Object.keys(obj) as K[] 9 | var res: Partial> = {} 10 | 11 | for (var i = 0; i < keys.length; i++) { 12 | var key = keys[i] 13 | var val = obj[key] 14 | 15 | if (!props || props.indexOf(key) === -1) { 16 | // @ts-ignore 17 | res[key] = val 18 | } 19 | } 20 | return res as Omit 21 | } 22 | 23 | export function computedOmit( 24 | obj: O, 25 | keys: K[] 26 | ): Ref> { 27 | let newObj: Partial = reactive({}) 28 | return computed(() => { 29 | Object.assign(newObj, obj) 30 | for (let key of keys) { 31 | delete newObj[key] 32 | } 33 | return newObj as Omit 34 | }) 35 | } 36 | 37 | // Logic derrived from https://github.com/jonschlinkert/object.pick, 38 | // simplified and typed by this project 39 | export function pick( 40 | obj: O, 41 | keys: K[] 42 | ): Pick { 43 | var res: Partial> = {} 44 | 45 | var len = keys.length 46 | var idx = -1 47 | 48 | while (++idx < len) { 49 | var key = keys[idx] 50 | if (key in obj) { 51 | res[key] = obj[key] 52 | } 53 | } 54 | return res as Pick 55 | } 56 | 57 | export function computedPick( 58 | obj: O, 59 | keys: K[] 60 | ): Ref> { 61 | const newObj: Partial = reactive({}) 62 | return computed(() => { 63 | //TODO: This seems inefficient.... 64 | for (let key of Object.keys(newObj)) { 65 | delete newObj[key as keyof typeof newObj] 66 | } 67 | for (let key of keys) { 68 | newObj[key] = obj[key] 69 | } 70 | return newObj as Pick 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /packages/aria-widgets/templates/README.md: -------------------------------------------------------------------------------- 1 | # What is this directory for? 2 | 3 | These are templates for the diffent base types of components we have. 4 | 5 | There's no clever generators or anything, just copy&past, and then replace the generic "component" names with the name of the actgual component. 6 | 7 | We could and should consider using something like hygen 8 | -------------------------------------------------------------------------------- /packages/aria-widgets/templates/childComponent.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, PropType, h } from 'vue' 2 | import { ComponentAPIKey, ComponentAPI } from "../types"; 3 | import { injectComponentAPI } from './useComponent' 4 | 5 | interface ComponentOptions { 6 | 7 | } 8 | 9 | export function useComponent( 10 | options: ComponentOptions = {} 11 | Api: ComponentAPI, 12 | ) { 13 | const api = injectComponentAPI() 14 | 15 | return api 16 | } 17 | 18 | export const componentProps = { 19 | tag: { 20 | type: String, 21 | default: 'DIV', 22 | }, 23 | apiKey: { 24 | type: Symbol as PropType, 25 | }, 26 | } 27 | 28 | export const Component = defineComponent({ 29 | name: 'Component', 30 | props: componentProps, 31 | setup(props, { slots }) { 32 | const api = injectComponentAPI(props.apiKey) 33 | const attributes = useComponent(props, api) 34 | return () => h(props.tag ?? 'DIV', attributes.value, slots.default?.({ attributes: attributes.value})) 35 | }, 36 | }) -------------------------------------------------------------------------------- /packages/aria-widgets/templates/wrapperComponent.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, provide, Ref, PropType } from 'vue' 2 | 3 | import { ComponentOptions, ComponentAPIKey } from '../types' 4 | import { wrapProp, createInjector } from '@varia/composables' 5 | 6 | export const componentAPIKey = Symbol('componentAPI') as ComponentAPIKey 7 | export function useComponent( 8 | selected: Ref, 9 | options: ComponentOptions = {} 10 | ) { 11 | const api = {} 12 | provide(componentAPIKey, api) 13 | 14 | return api 15 | } 16 | 17 | export const componentProps = { 18 | modelValue: Boolean as PropType, 19 | } 20 | 21 | export const injectComponentAPI = createInjector( 22 | componentAPIKey, 23 | 'injectComponentAPI' 24 | ) 25 | 26 | export const Component = defineComponent({ 27 | name: 'Component', 28 | props: componentProps, 29 | setup(props, { slots }) { 30 | const state = wrapProp(props, 'modelValue') 31 | useComponent(state, props) 32 | return () => slots.default?.() 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /packages/aria-widgets/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../config/typescript/tsconfig.build.json", 3 | "include": ["./src"], 4 | "compilerOptions": { 5 | "outDir": "dist" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/aria-widgets/tsdx.config.js: -------------------------------------------------------------------------------- 1 | const postcss = require('rollup-plugin-postcss') 2 | const autoprefixer = require('autoprefixer') 3 | const path = require('path') 4 | module.exports = { 5 | rollup(config, options) { 6 | config.plugins.push( 7 | postcss({ 8 | plugins: [autoprefixer()], 9 | inject: false, 10 | // only write out CSS for the first bundle (avoids pointless extra files): 11 | extract: !!options.writeMeta && path.resolve('dist/index.css'), 12 | }) 13 | ) 14 | return config 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /packages/dev-server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'plugin:vue/essential', 5 | '@vue/typescript', 6 | 'prettier/@typescript-eslint', 7 | 'plugin:prettier/recommended', 8 | ], 9 | rules: { 10 | eqeqeq: 'off', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /packages/dev-server/App.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 90 | 91 | 119 | -------------------------------------------------------------------------------- /packages/dev-server/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Thorsten Lünborg 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /packages/dev-server/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | button[disabled], 2 | [role='button'][disabled], 3 | [role='button'][aria-disabled='true'] { 4 | cursor: default; 5 | } 6 | 7 | @tailwind base; 8 | 9 | @tailwind components; 10 | 11 | @tailwind utilities; 12 | -------------------------------------------------------------------------------- /packages/dev-server/components/SignupForm.vue: -------------------------------------------------------------------------------- 1 | 26 | 38 | 39 | -------------------------------------------------------------------------------- /packages/dev-server/index.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | -------------------------------------------------------------------------------- /packages/dev-server/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import router from './router/index' 4 | import '@varia/widgets/dist/index.css' 5 | import './assets/tailwind.css' 6 | 7 | createApp(App) 8 | .use(router) 9 | .mount('#app') 10 | -------------------------------------------------------------------------------- /packages/dev-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dev-server", 3 | "version": "0.0.0", 4 | "license": "Apache-2.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build" 8 | }, 9 | "dependencies": { 10 | "tailwindcss": "^1.4.5", 11 | "vue": "^3.0.0-beta.15", 12 | "@varia/composables": "*", 13 | "@varia/widgets": "*", 14 | "vue-router": "^4.0.0-alpha.13" 15 | }, 16 | "devDependencies": { 17 | "@vue/compiler-sfc": "^3.0.0-beta.15", 18 | "typescript": "^3.8.3", 19 | "vite": "^1.0.0-beta.5" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/dev-server/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /packages/dev-server/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, RouteRecord } from 'vue-router' 2 | import { routes } from './routes' 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(), 6 | routes, 7 | }) 8 | 9 | export default router 10 | 11 | // @ts-ignore 12 | if (import.meta.hot) { 13 | let removeRoutes: Array<() => void> = [] 14 | 15 | for (let route of routes) { 16 | removeRoutes.push(router.addRoute(route)) 17 | } 18 | 19 | // @ts-ignore 20 | import.meta.hot.acceptDeps( 21 | './routes.js', 22 | ({ routes }: { routes: RouteRecord[] }) => { 23 | for (let removeRoute of removeRoutes) removeRoute() 24 | removeRoutes = [] 25 | for (let route of routes) { 26 | removeRoutes.push(router.addRoute(route)) 27 | } 28 | router.replace('') 29 | } 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /packages/dev-server/router/routes.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw } from 'vue-router' 2 | // Views 3 | import Home from '../views/Home.vue' 4 | import ButtonsView from '../views/Buttons.vue' 5 | import TabsView from '../views/Tabs.vue' 6 | import ListBoxView from '../views/ListBox.vue' 7 | import PopoversView from '../views/Popovers.vue' 8 | import DialogsView from '../views/Dialogs.vue' 9 | import DisclosuresView from '../views/Disclosures.vue' 10 | import AccordionsView from '../views/Accordions.vue' 11 | import FocusTrapsView from '../views/FocusTraps.vue' 12 | 13 | export const routes: Array = [ 14 | { 15 | path: '/', 16 | name: 'Home', 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 18 | // @ts-ignore 19 | component: Home, 20 | }, 21 | { 22 | path: '/buttons', 23 | name: 'Buttons', 24 | component: ButtonsView, 25 | }, 26 | { 27 | path: '/tabs', 28 | name: 'Tabs', 29 | component: TabsView, 30 | }, 31 | { 32 | path: '/listbox', 33 | name: 'ListBox', 34 | component: ListBoxView, 35 | }, 36 | { 37 | path: '/disclosures', 38 | name: 'Disclosures', 39 | component: DisclosuresView, 40 | }, 41 | { 42 | path: '/accordions', 43 | name: 'Accordions', 44 | component: AccordionsView, 45 | }, 46 | { 47 | path: '/popovers', 48 | name: 'Popovers', 49 | component: PopoversView, 50 | }, 51 | { 52 | path: '/dialogs', 53 | name: 'Dialogs', 54 | component: DialogsView, 55 | }, 56 | { 57 | path: '/focustraps', 58 | name: 'FocusTraps', 59 | component: FocusTrapsView, 60 | }, 61 | ] 62 | -------------------------------------------------------------------------------- /packages/dev-server/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | theme: { 4 | extend: {}, 5 | }, 6 | variants: {}, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /packages/dev-server/views/Accordions.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 67 | 68 | 73 | -------------------------------------------------------------------------------- /packages/dev-server/views/Buttons.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 78 | 79 | 89 | -------------------------------------------------------------------------------- /packages/dev-server/views/Dialogs.vue: -------------------------------------------------------------------------------- 1 | 31 | 49 | 55 | -------------------------------------------------------------------------------- /packages/dev-server/views/Disclosures.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 37 | 38 | 47 | -------------------------------------------------------------------------------- /packages/dev-server/views/FocusTraps.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 57 | 58 | 63 | -------------------------------------------------------------------------------- /packages/dev-server/views/Home.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /packages/dev-server/views/ListBox.vue: -------------------------------------------------------------------------------- 1 | 63 | 88 | 101 | -------------------------------------------------------------------------------- /packages/dev-server/views/Popovers.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 68 | 69 | 82 | -------------------------------------------------------------------------------- /packages/dev-server/views/Tabs.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 109 | 110 | 125 | -------------------------------------------------------------------------------- /packages/dev-server/vite.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | optimizeDeps: { 3 | include: ['cuid'], 4 | exclude: ['@varia/widgets', '@varia/composables'], 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/docs/.vuepress/config/config.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /packages/docs/README.md: -------------------------------------------------------------------------------- 1 | # `docs` 2 | 3 | > TODO: description 4 | 5 | ## Usage 6 | 7 | ``` 8 | const docs = require('docs'); 9 | 10 | // TODO: DEMONSTRATE API 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/docs/content/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinusBorg/varia/25d79cbdd31d7d7768821b062d6841e516d16ac8/packages/docs/content/README.md -------------------------------------------------------------------------------- /packages/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "description": "Documentationf or vue-aria-* packages", 5 | "author": "Thorsten ", 6 | "homepage": "https://github.com/LinusBorg/vue-focus-management#readme", 7 | "license": "Apache-2.0", 8 | "main": "index.js", 9 | "scripts": { 10 | "dev": "vuepress dev docs", 11 | "build": "vuepress build docs" 12 | }, 13 | "devDependencies": { 14 | "vuepress": "^1.4.1" 15 | }, 16 | "publishConfig": { 17 | "registry": "https://registry.yarnpkg.com" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/LinusBorg/vue-focus-management.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/LinusBorg/vue-focus-management/issues" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/examples/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /packages/examples/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended', 10 | '@vue/prettier', 11 | '@vue/prettier/@typescript-eslint', 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2020, 15 | }, 16 | rules: { 17 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /packages/examples/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /packages/examples/README.md: -------------------------------------------------------------------------------- 1 | # examples 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Run your end-to-end tests 19 | ``` 20 | yarn test:e2e 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | yarn lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /packages/examples/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | } 4 | -------------------------------------------------------------------------------- /packages/examples/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /packages/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "test:e2e": "vue-cli-service test:e2e", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "core-js": "^3.6.4", 13 | "vue": "^3.0.0-beta.15", 14 | "@varia/composables": "*", 15 | "@varia/widgets": "*", 16 | "vue-router": "^4.0.0-alpha.13" 17 | }, 18 | "devDependencies": { 19 | "@typescript-eslint/eslint-plugin": "^2.26.0", 20 | "@typescript-eslint/parser": "^2.26.0", 21 | "@vue/cli-plugin-babel": "~4.3.0", 22 | "@vue/cli-plugin-e2e-cypress": "~4.3.0", 23 | "@vue/cli-plugin-eslint": "~4.3.0", 24 | "@vue/cli-plugin-router": "~4.3.0", 25 | "@vue/cli-plugin-typescript": "~4.3.0", 26 | "@vue/cli-service": "~4.3.0", 27 | "@vue/compiler-sfc": "^3.0.0-beta.15", 28 | "@vue/eslint-config-prettier": "^6.0.0", 29 | "@vue/eslint-config-typescript": "^5.0.2", 30 | "@vue/test-utils": "^2.0.0-alpha.7", 31 | "eslint": "^6.7.2", 32 | "eslint-plugin-prettier": "^3.1.1", 33 | "eslint-plugin-vue": "^7.0.0-alpha.7", 34 | "lint-staged": "^9.5.0", 35 | "prettier": "^1.19.1", 36 | "typescript": "^3.8.3", 37 | "vue-cli-plugin-tailwind": "~1.3.0", 38 | "vue-cli-plugin-vue-next": "~0.1.0" 39 | }, 40 | "gitHooks": { 41 | "pre-commit": "lint-staged" 42 | }, 43 | "lint-staged": { 44 | "*.{js,jsx,vue,ts,tsx}": [ 45 | "vue-cli-service lint", 46 | "git add" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/examples/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | 'vue-cli-plugin-tailwind/purgecss': {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/examples/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinusBorg/varia/25d79cbdd31d7d7768821b062d6841e516d16ac8/packages/examples/public/favicon.ico -------------------------------------------------------------------------------- /packages/examples/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/examples/src/App.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 90 | 91 | 119 | -------------------------------------------------------------------------------- /packages/examples/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LinusBorg/varia/25d79cbdd31d7d7768821b062d6841e516d16ac8/packages/examples/src/assets/logo.png -------------------------------------------------------------------------------- /packages/examples/src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | button[disabled], 2 | [role='button'][disabled], 3 | [role='button'][aria-disabled='true'] { 4 | cursor: default; 5 | } 6 | 7 | @tailwind base; 8 | 9 | @tailwind components; 10 | 11 | @tailwind utilities; 12 | -------------------------------------------------------------------------------- /packages/examples/src/components/SignupForm.vue: -------------------------------------------------------------------------------- 1 | 26 | 38 | 39 | -------------------------------------------------------------------------------- /packages/examples/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import { install as plugin } from '@varia/widgets' 5 | import '@varia/widgets/dist/index.css' 6 | import './assets/tailwind.css' 7 | createApp(App) 8 | .use(router) 9 | .use(plugin) 10 | .mount('#app') 11 | -------------------------------------------------------------------------------- /packages/examples/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { RouteRecordRaw, createRouter, createWebHistory } from 'vue-router' 2 | // Views 3 | import Home from '../views/Home.vue' 4 | import ButtonsView from '../views/Buttons.vue' 5 | import TabsView from '../views/Tabs.vue' 6 | import ListBoxView from '../views/ListBox.vue' 7 | import PopoversView from '../views/Popovers.vue' 8 | import DialogsView from '../views/Dialogs.vue' 9 | import DisclosuresView from '../views/Disclosures.vue' 10 | import AccordionsView from '../views/Accordions.vue' 11 | import FocusTrapsView from '../views/FocusTraps.vue' 12 | 13 | const routes: Array = [ 14 | { 15 | path: '/', 16 | name: 'Home', 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-ignore 18 | // @ts-ignore 19 | component: Home, 20 | }, 21 | { 22 | path: '/buttons', 23 | name: 'Buttons', 24 | component: ButtonsView, 25 | }, 26 | { 27 | path: '/tabs', 28 | name: 'Tabs', 29 | component: TabsView, 30 | }, 31 | { 32 | path: '/listbox', 33 | name: 'ListBox', 34 | component: ListBoxView, 35 | }, 36 | { 37 | path: '/disclosures', 38 | name: 'Disclosures', 39 | component: DisclosuresView, 40 | }, 41 | { 42 | path: '/accordions', 43 | name: 'Accordions', 44 | component: AccordionsView, 45 | }, 46 | { 47 | path: '/popovers', 48 | name: 'Popovers', 49 | component: PopoversView, 50 | }, 51 | { 52 | path: '/dialogs', 53 | name: 'Dialogs', 54 | component: DialogsView, 55 | }, 56 | { 57 | path: '/focustraps', 58 | name: 'FocusTraps', 59 | component: FocusTrapsView, 60 | }, 61 | ] 62 | 63 | const router = createRouter({ 64 | history: createWebHistory(), 65 | routes, 66 | }) 67 | 68 | router.afterEach(to => { 69 | const name = to.meta?.title ?? to.name 70 | document.title = `Varia Components - ${name}` 71 | // TODO: Implement announcer 72 | }) 73 | 74 | export default router 75 | -------------------------------------------------------------------------------- /packages/examples/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { ComponentOptions } from 'vue' 3 | let component: ComponentOptions 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /packages/examples/src/views/Accordions.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 67 | 68 | 73 | -------------------------------------------------------------------------------- /packages/examples/src/views/Buttons.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 78 | 79 | 89 | -------------------------------------------------------------------------------- /packages/examples/src/views/Dialogs.vue: -------------------------------------------------------------------------------- 1 | 31 | 49 | 55 | -------------------------------------------------------------------------------- /packages/examples/src/views/Disclosures.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 37 | 38 | 47 | -------------------------------------------------------------------------------- /packages/examples/src/views/FocusTraps.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 57 | 58 | 63 | -------------------------------------------------------------------------------- /packages/examples/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /packages/examples/src/views/ListBox.vue: -------------------------------------------------------------------------------- 1 | 63 | 88 | 101 | -------------------------------------------------------------------------------- /packages/examples/src/views/Popovers.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 68 | 69 | 82 | -------------------------------------------------------------------------------- /packages/examples/src/views/Tabs.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 109 | 110 | 125 | -------------------------------------------------------------------------------- /packages/examples/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | theme: { 4 | extend: {}, 5 | }, 6 | variants: {}, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /packages/examples/tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['cypress'], 3 | env: { 4 | mocha: true, 5 | 'cypress/globals': true, 6 | }, 7 | rules: { 8 | strict: 'off', 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /packages/examples/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable arrow-body-style */ 2 | // https://docs.cypress.io/guides/guides/plugins-guide.html 3 | 4 | // if you need a custom webpack configuration you can uncomment the following import 5 | // and then use the `file:preprocessor` event 6 | // as explained in the cypress docs 7 | // https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples 8 | 9 | // /* eslint-disable import/no-extraneous-dependencies, global-require */ 10 | // const webpack = require('@cypress/webpack-preprocessor') 11 | 12 | module.exports = (on, config) => { 13 | // on('file:preprocessor', webpack({ 14 | // webpackOptions: require('@vue/cli-service/webpack.config'), 15 | // watchOptions: {} 16 | // })) 17 | 18 | return Object.assign({}, config, { 19 | fixturesFolder: 'tests/e2e/fixtures', 20 | integrationFolder: 'tests/e2e/specs', 21 | screenshotsFolder: 'tests/e2e/screenshots', 22 | videosFolder: 'tests/e2e/videos', 23 | supportFile: 'tests/e2e/support/index.js', 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /packages/examples/tests/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe('My First Test', () => { 4 | it('Visits the app root url', () => { 5 | cy.visit('/') 6 | cy.contains('h1', 'Welcome to Your Vue.js + TypeScript App') 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /packages/examples/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /packages/examples/tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "sourceMap": true, 12 | "baseUrl": ".", 13 | "types": [ 14 | "webpack-env" 15 | ], 16 | "paths": { 17 | "@/*": [ 18 | "src/*" 19 | ] 20 | }, 21 | "lib": [ 22 | "esnext", 23 | "dom", 24 | "dom.iterable", 25 | "scripthost" 26 | ] 27 | }, 28 | "include": [ 29 | "src/**/*.ts", 30 | "src/**/*.tsx", 31 | "src/**/*.vue", 32 | "tests/**/*.ts", 33 | "tests/**/*.tsx" 34 | ], 35 | "exclude": [ 36 | "node_modules" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /packages/examples/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | lintOnSave: false, 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./config/typescript/tsconfig.build.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "@varia/composables": ["./packages/aria-composables/src"], 6 | "@varia/widgets": ["./packages/aria-widgets/src"] 7 | }, 8 | "baseUrl": ".", 9 | "rootDir": "." 10 | } 11 | } 12 | --------------------------------------------------------------------------------