├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitpod.yml ├── .prettierignore ├── .prettierrc.json ├── README.md ├── TODO.md ├── angular.json ├── package-lock.json ├── package.json ├── postcss.config.js ├── projects ├── demo │ ├── .browserslistrc │ ├── .eslintrc.json │ ├── karma.conf.js │ ├── src │ │ ├── app │ │ │ ├── app.component.html │ │ │ ├── app.component.ts │ │ │ ├── app.module.ts │ │ │ ├── demo-components │ │ │ │ ├── styled-menu-cdk │ │ │ │ │ ├── styled-menu-cdk.component.html │ │ │ │ │ └── styled-menu-cdk.component.ts │ │ │ │ ├── styled-menu │ │ │ │ │ ├── styled-menu.component.html │ │ │ │ │ └── styled-menu.component.ts │ │ │ │ ├── styled-select │ │ │ │ │ ├── styled-select.component.html │ │ │ │ │ └── styled-select.component.ts │ │ │ │ ├── transition │ │ │ │ │ ├── transition.component.html │ │ │ │ │ └── transition.component.ts │ │ │ │ ├── transition2 │ │ │ │ │ ├── transition2.component.html │ │ │ │ │ └── transition2.component.ts │ │ │ │ ├── unstyled-menu │ │ │ │ │ ├── unstyled-menu.component.html │ │ │ │ │ └── unstyled-menu.component.ts │ │ │ │ └── unstyled-select │ │ │ │ │ ├── unstyled-select.component.html │ │ │ │ │ └── unstyled-select.component.ts │ │ │ ├── demo-container │ │ │ │ ├── demo-container.component.html │ │ │ │ └── demo-container.component.ts │ │ │ └── formattedSources.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ ├── styles.scss │ │ └── test.ts │ ├── tsconfig.app.json │ └── tsconfig.spec.json └── headlessui-angular │ ├── .eslintrc.json │ ├── karma-ci.conf.js │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── listbox │ │ │ └── listbox.ts │ │ ├── menu │ │ │ ├── menu.spec.ts │ │ │ └── menu.ts │ │ ├── transition │ │ │ ├── transition.ts │ │ │ └── transition2.ts │ │ └── util.ts │ ├── public-api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── publish.sh ├── scripts └── code-samples.mjs ├── tailwind.config.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["tsconfig.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "extends": [ 12 | "plugin:@angular-eslint/recommended", 13 | "plugin:@angular-eslint/template/process-inline-templates", 14 | "prettier" 15 | ] 16 | }, 17 | { 18 | "files": ["*.html"], 19 | "extends": ["plugin:@angular-eslint/template/recommended"], 20 | "rules": {} 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | persist-credentials: false 13 | - name: setup node 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: "12" 17 | - run: npm ci 18 | - run: npm run lint 19 | - run: npm run test:ci 20 | - run: npm run build 21 | - run: npm run build:demo -- --base-href /headlessui-angular/ 22 | env: 23 | CI: true 24 | 25 | - name: Deploy 26 | uses: JamesIves/github-pages-deploy-action@3.7.1 27 | with: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | BRANCH: JamesIves/github-pages-deploy-action@3.7.1 30 | FOLDER: dist/demo 31 | CLEAN: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: frontend 3 | init: npm install 4 | command: npm run serve:gitpod -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | /typings 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # headlessui-angular 2 | 3 | An attempt to bring [headlessui](https://headlessui.dev) to Angular. A set of completely unstyled, fully accessible UI components. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | # npm 9 | npm install headlessui-angular 10 | 11 | # Yarn 12 | yarn add headlessui-angular 13 | ``` 14 | 15 | ## Components 16 | 17 | _This project is still in early development. So far, only the menu and 18 | listbox component are available._ 19 | 20 | - [Menu (Dropdown)](#menu-dropdown) 21 | - [Listbox (Select) - Draft](#listbox-select) 22 | 23 | ## Menu (Dropdown) 24 | 25 | [Demo](https://ibirrer.github.io/headlessui-angular/#menu) 26 | 27 | From the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/menu/): _"A menu is a widget that offers a list of choices to the user, such as a set of actions. 28 | Menu widgets behave like native operating system menus, such as the menus 29 | that pull down from the menubars."_ 30 | 31 | ### Setup 32 | 33 | Import `MenuModule` to your angular module: 34 | 35 | ```ts 36 | import { MenuModule } from "headlessui-angular"; 37 | imports: [MenuModule]; 38 | ``` 39 | 40 | ### Basic example 41 | 42 | Menus are built using the `hlMenu`, `hlMenuButton`, `*hlMenuItems`, and `*hlMenuItem` directives. 43 | 44 | The menu button `*hlMenuButton` will automatically open/close the `*hlMenuItems` when clicked, and when the menu is open, the list of items receives focus and is automatically navigable via the keyboard. 45 | 46 | ```html 47 |
48 | 49 |
50 | 55 | Account settings 56 | 57 | 62 | Documentation 63 | 64 | 65 | Invite a friend (coming soon!) 66 | 67 |
68 |
69 | ``` 70 | 71 | ### Styling the active item 72 | 73 | This is a headless component so there are no styles included by default. Instead, the components expose useful information via let expressions that you can use to apply the styles you'd like to apply yourself. 74 | 75 | To style the active `hlMenuItem` you can read the `active` state, which tells you whether that menu item is the item that is currently focused via the mouse or keyboard. 76 | 77 | You can use this state to conditionally apply whatever active/focus styles you like, for instance a blue background like is typical in most operating systems. 78 | 79 | ```html 80 |
81 | 82 |
83 | 84 | 87 | Settings 88 | 89 | 90 |
91 | ``` 92 | 93 | ### Transitions 94 | 95 | To animate the opening/closing of the menu panel, use Angular's built-in animation capabilities. All you need to do is add the animation to your `*hlMenuItems` element. 96 | 97 | ```html 98 | @Component({ 99 | 100 | template: ` 101 |
102 | 103 | 104 |
105 | Item A 106 |
107 |
108 | ` animations: [ trigger('toggleAnimation', [ transition(':enter', [ style({ 109 | opacity: 0, transform: 'scale(0.95)' }), animate('100ms ease-out', style({ 110 | opacity: 1, transform: 'scale(1)' })), ]), transition(':leave', [ 111 | animate('75ms', style({ opacity: 0, transform: 'scale(0.95)' })), ]), ]), ] }) 112 | ``` 113 | 114 | ## Listbox (Select) 115 | DRAFT 116 | 117 | From the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/): _"A listbox widget presents a list of options and allows a user to select 118 | one or more of them."_ 119 | 120 | 121 | 122 | [Demo](https://ibirrer.github.io/headlessui-angular/#listbox) 123 | 124 | ### Setup 125 | 126 | Import `ListboxModule` to your angular module: 127 | 128 | ```ts 129 | import { ListboxModule } from "headlessui-angular"; 130 | imports: [ListboxModule]; 131 | ``` 132 | 133 | ## Develop 134 | 135 | ```sh 136 | git clone https://github.com/ibirrer/headlessui-angular.git 137 | cd headlessui-angular 138 | npm install 139 | npm start 140 | 141 | # open http://localhost:4200/ 142 | # edit demo: projects/demo 143 | # edit lib: projects/headlessui-angular 144 | ``` 145 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## Menu 4 | 5 | - [x] complete keyboard navigation and focus handling 6 | - [x] focus button after click on item 7 | - [x] choose with space and enter 8 | - [x] don't toggle it item is disabled 9 | - [x] search 10 | - [x] Keys.End, Home, PageUp, PageDown 11 | - [o] cleanup: extract api in interface (toggle, focus, ...) 12 | - [ ] /#account-settingsunregister all listeners on destroy 13 | - this is not needed according to https://stackoverflow.com/a/12528067 14 | - [ ] error if missing child/parent components 15 | - [ ] more tests 16 | - disabled menu items 17 | 18 | ## Listbox 19 | 20 | - [ ] aria properties 21 | - [x] focus selected on arrow down 22 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "headlessui-angular": { 7 | "projectType": "library", 8 | "root": "projects/headlessui-angular", 9 | "sourceRoot": "projects/headlessui-angular/src", 10 | "prefix": "hl", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "projects/headlessui-angular/ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "projects/headlessui-angular/tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "projects/headlessui-angular/tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "test": { 28 | "builder": "@angular-devkit/build-angular:karma", 29 | "options": { 30 | "main": "projects/headlessui-angular/src/test.ts", 31 | "tsConfig": "projects/headlessui-angular/tsconfig.spec.json", 32 | "karmaConfig": "projects/headlessui-angular/karma.conf.js" 33 | } 34 | }, 35 | "lint": { 36 | "builder": "@angular-eslint/builder:lint", 37 | "options": { 38 | "lintFilePatterns": [ 39 | "projects/headlessui-angular/**/*.ts", 40 | "projects/headlessui-angular/**/*.html" 41 | ] 42 | } 43 | } 44 | } 45 | }, 46 | "demo": { 47 | "projectType": "application", 48 | "schematics": { 49 | "@schematics/angular:component": { 50 | "style": "scss" 51 | }, 52 | "@schematics/angular:application": { 53 | "strict": true 54 | } 55 | }, 56 | "root": "projects/demo", 57 | "sourceRoot": "projects/demo/src", 58 | "prefix": "app", 59 | "architect": { 60 | "build": { 61 | "builder": "@angular-devkit/build-angular:browser", 62 | "options": { 63 | "outputPath": "dist/demo", 64 | "index": "projects/demo/src/index.html", 65 | "main": "projects/demo/src/main.ts", 66 | "polyfills": "projects/demo/src/polyfills.ts", 67 | "tsConfig": "projects/demo/tsconfig.app.json", 68 | "inlineStyleLanguage": "scss", 69 | "assets": [ 70 | "projects/demo/src/favicon.ico", 71 | "projects/demo/src/assets" 72 | ], 73 | "styles": ["projects/demo/src/styles.scss"], 74 | "scripts": [] 75 | }, 76 | "configurations": { 77 | "production": { 78 | "budgets": [ 79 | { 80 | "type": "initial", 81 | "maximumWarning": "500kb", 82 | "maximumError": "1mb" 83 | }, 84 | { 85 | "type": "anyComponentStyle", 86 | "maximumWarning": "2kb", 87 | "maximumError": "4kb" 88 | } 89 | ], 90 | "fileReplacements": [ 91 | { 92 | "replace": "projects/demo/src/environments/environment.ts", 93 | "with": "projects/demo/src/environments/environment.prod.ts" 94 | } 95 | ], 96 | "outputHashing": "all" 97 | }, 98 | "development": { 99 | "buildOptimizer": false, 100 | "optimization": false, 101 | "vendorChunk": true, 102 | "extractLicenses": false, 103 | "sourceMap": true, 104 | "namedChunks": true 105 | } 106 | }, 107 | "defaultConfiguration": "production" 108 | }, 109 | "serve": { 110 | "builder": "@angular-devkit/build-angular:dev-server", 111 | "configurations": { 112 | "production": { 113 | "browserTarget": "demo:build:production" 114 | }, 115 | "development": { 116 | "browserTarget": "demo:build:development" 117 | } 118 | }, 119 | "defaultConfiguration": "development" 120 | }, 121 | "extract-i18n": { 122 | "builder": "@angular-devkit/build-angular:extract-i18n", 123 | "options": { 124 | "browserTarget": "demo:build" 125 | } 126 | }, 127 | "test": { 128 | "builder": "@angular-devkit/build-angular:karma", 129 | "options": { 130 | "main": "projects/demo/src/test.ts", 131 | "polyfills": "projects/demo/src/polyfills.ts", 132 | "tsConfig": "projects/demo/tsconfig.spec.json", 133 | "karmaConfig": "projects/demo/karma.conf.js", 134 | "inlineStyleLanguage": "scss", 135 | "assets": [ 136 | "projects/demo/src/favicon.ico", 137 | "projects/demo/src/assets" 138 | ], 139 | "styles": ["projects/demo/src/styles.scss"], 140 | "scripts": [] 141 | } 142 | }, 143 | "lint": { 144 | "builder": "@angular-eslint/builder:lint", 145 | "options": { 146 | "lintFilePatterns": [ 147 | "projects/demo/**/*.ts", 148 | "projects/demo/**/*.html" 149 | ] 150 | } 151 | } 152 | } 153 | } 154 | }, 155 | "defaultProject": "headlessui-angular" 156 | } 157 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headlessui-angular", 3 | "version": "0.0.9", 4 | "license": "MIT", 5 | "description": "Headless UI components for angular", 6 | "scripts": { 7 | "ng": "ng", 8 | "start": "ng serve", 9 | "serve:gitpod": "ng serve --public-host https://4200-${GITPOD_WORKSPACE_ID}.${GITPOD_WORKSPACE_CLUSTER_HOST}", 10 | "build": "cross-env NODE_ENV=production ng build && cpy README.md dist/headlessui-angular", 11 | "build:demo": "cross-env NODE_ENV=production ng build demo", 12 | "watch": "ng build --watch --configuration development", 13 | "test:watch": "ng test headlessui-angular", 14 | "test": "ng test headlessui-angular --watch=false", 15 | "test:ci": "ng test headlessui-angular --karma-config=projects/headlessui-angular/karma-ci.conf.js", 16 | "lint": "ng lint headlessui-angular" 17 | }, 18 | "private": true, 19 | "dependencies": { 20 | "@angular/animations": "~13", 21 | "@angular/cdk": "~13", 22 | "@angular/common": "~13", 23 | "@angular/compiler": "~13", 24 | "@angular/core": "~13", 25 | "@angular/forms": "~13", 26 | "@angular/platform-browser": "~13", 27 | "@angular/platform-browser-dynamic": "~13", 28 | "@angular/router": "~13", 29 | "@ng-icons/core": "^16.0.0", 30 | "@ng-icons/heroicons": "^16.0.0", 31 | "rxjs": "~6", 32 | "tslib": "~2", 33 | "zone.js": "~0.11.5" 34 | }, 35 | "devDependencies": { 36 | "@angular-devkit/build-angular": "~13", 37 | "@angular-eslint/builder": "~13", 38 | "@angular-eslint/eslint-plugin": "~13", 39 | "@angular-eslint/eslint-plugin-template": "~13", 40 | "@angular-eslint/schematics": "~13", 41 | "@angular-eslint/template-parser": "~13", 42 | "@angular/cli": "~13", 43 | "@angular/compiler-cli": "~13", 44 | "@types/jasmine": "~3", 45 | "@types/node": "~12", 46 | "@typescript-eslint/eslint-plugin": "~5", 47 | "@typescript-eslint/parser": "~5", 48 | "autoprefixer": "~10", 49 | "cpy-cli": "~4", 50 | "cross-env": "~7", 51 | "eslint": "~8", 52 | "eslint-config-prettier": "~8", 53 | "eslint-plugin-import": "~2", 54 | "jasmine-core": "~3", 55 | "karma": "~6", 56 | "karma-chrome-launcher": "~3", 57 | "karma-coverage": "~2", 58 | "karma-jasmine": "~4", 59 | "karma-jasmine-html-reporter": "~1", 60 | "ng-packagr": "~13", 61 | "postcss": "~8", 62 | "prettier": "~2", 63 | "prismjs": "^1.28.0", 64 | "puppeteer": "~14", 65 | "tailwindcss": "~3", 66 | "typescript": "~4.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /projects/demo/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 18 | -------------------------------------------------------------------------------- /projects/demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": [ 9 | "projects/demo/tsconfig.app.json", 10 | "projects/demo/tsconfig.spec.json" 11 | ], 12 | "createDefaultProgram": true 13 | }, 14 | "rules": { 15 | "@angular-eslint/component-selector": [ 16 | "error", 17 | { 18 | "type": "element", 19 | "prefix": "app", 20 | "style": "kebab-case" 21 | } 22 | ], 23 | "@angular-eslint/directive-selector": [ 24 | "error", 25 | { 26 | "type": "attribute", 27 | "prefix": "app", 28 | "style": "camelCase" 29 | } 30 | ] 31 | } 32 | }, 33 | { 34 | "files": ["*.html"], 35 | "rules": {} 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /projects/demo/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: "", 7 | frameworks: ["jasmine", "@angular-devkit/build-angular"], 8 | plugins: [ 9 | require("karma-jasmine"), 10 | require("karma-chrome-launcher"), 11 | require("karma-jasmine-html-reporter"), 12 | require("karma-coverage"), 13 | require("@angular-devkit/build-angular/plugins/karma"), 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true, // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require("path").join(__dirname, "../../coverage/demo"), 29 | subdir: ".", 30 | reporters: [{ type: "html" }, { type: "text-summary" }], 31 | }, 32 | reporters: ["progress", "kjhtml"], 33 | port: 9876, 34 | colors: true, 35 | logLevel: config.LOG_INFO, 36 | autoWatch: true, 37 | browsers: ["Chrome"], 38 | singleRun: false, 39 | restartOnFileChange: true, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Menu 4 |

5 |
6 | 7 | 12 | 13 | 14 | 15 | 16 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 |
33 | 34 |

38 | Select (Listbox) 39 |

Draft

40 |

41 | 42 |
43 | 44 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 |
61 | 62 |

66 | Transition 67 |

68 | 69 |
70 | 75 | 76 | 77 |
78 | 79 |

83 | Transition 2 84 |

85 | 86 |
87 | 93 |
94 |
95 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | ChangeDetectionStrategy, 4 | Component, 5 | Inject, 6 | } from '@angular/core'; 7 | import * as formattedSources from './formattedSources'; 8 | import { DOCUMENT, Location } from '@angular/common'; 9 | 10 | @Component({ 11 | selector: 'app-root', 12 | templateUrl: './app.component.html', 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | }) 15 | export class AppComponent implements AfterViewInit { 16 | formattedSources = formattedSources; 17 | show = true; 18 | 19 | constructor( 20 | private location: Location, 21 | @Inject(DOCUMENT) private document: Document 22 | ) {} 23 | 24 | ngAfterViewInit(): void { 25 | const element = this.document.getElementById(this.location.path()); 26 | setTimeout(() => { 27 | element?.scrollIntoView({ behavior: 'smooth' }); 28 | }, 100); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { OverlayModule } from '@angular/cdk/overlay'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | import { ListboxModule } from 'projects/headlessui-angular/src/lib/listbox/listbox'; 6 | import { MenuModule } from 'projects/headlessui-angular/src/public-api'; 7 | import { AppComponent } from './app.component'; 8 | import { DemoContainerComponent } from './demo-container/demo-container.component'; 9 | import { StyledMenuComponent } from './demo-components/styled-menu/styled-menu.component'; 10 | import { StyledMenuCdkComponent } from './demo-components/styled-menu-cdk/styled-menu-cdk.component'; 11 | import { UnstyledMenuComponent } from './demo-components/unstyled-menu/unstyled-menu.component'; 12 | import { UnstyledSelectComponent } from './demo-components/unstyled-select/unstyled-select.component'; 13 | import { StyledSelectComponent } from './demo-components/styled-select/styled-select.component'; 14 | import { NgIconsModule } from '@ng-icons/core'; 15 | import { HeroCheck, HeroSelector } from '@ng-icons/heroicons/outline'; 16 | import { 17 | HashLocationStrategy, 18 | Location, 19 | LocationStrategy, 20 | } from '@angular/common'; 21 | import { TransitionModule } from '../../../headlessui-angular/src/lib/transition/transition'; 22 | import { TransitionComponent } from './demo-components/transition/transition.component'; 23 | import { TransitionModule2 } from '../../../headlessui-angular/src/lib/transition/transition2'; 24 | import { Transition2Component } from './demo-components/transition2/transition2.component'; 25 | 26 | @NgModule({ 27 | declarations: [ 28 | AppComponent, 29 | DemoContainerComponent, 30 | StyledMenuComponent, 31 | StyledMenuCdkComponent, 32 | UnstyledMenuComponent, 33 | UnstyledSelectComponent, 34 | StyledSelectComponent, 35 | TransitionComponent, 36 | Transition2Component, 37 | ], 38 | imports: [ 39 | BrowserModule, 40 | BrowserAnimationsModule, 41 | MenuModule, 42 | ListboxModule, 43 | TransitionModule, 44 | TransitionModule2, 45 | OverlayModule, 46 | NgIconsModule.withIcons({ HeroSelector, HeroCheck }), 47 | ], 48 | providers: [ 49 | Location, 50 | { provide: LocationStrategy, useClass: HashLocationStrategy }, 51 | ], 52 | bootstrap: [AppComponent], 53 | }) 54 | export class AppModule {} 55 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/styled-menu-cdk/styled-menu-cdk.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
10 | 11 | 24 | 25 | 30 |
36 |
37 |

Signed in as

38 |

39 | tom@example.com 40 |

41 |
42 |
43 | 51 | Account settings 52 | 53 | 54 | 62 | Support 63 | 64 | 65 | 69 | New feature (soon) 70 | 71 | 72 | 80 | License 81 | 82 |
83 | 84 | 96 |
97 |
98 |
99 |
100 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/styled-menu-cdk/styled-menu-cdk.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { animate, style, transition, trigger } from '@angular/animations'; 3 | 4 | @Component({ 5 | selector: 'app-styled-menu-cdk', 6 | animations: [ 7 | trigger('toggleAnimation', [ 8 | transition(':enter', [ 9 | style({ opacity: 0, transform: 'scale(0.95)' }), 10 | animate('100ms ease-out', style({ opacity: 1, transform: 'scale(1)' })), 11 | ]), 12 | transition(':leave', [ 13 | animate('75ms', style({ opacity: 0, transform: 'scale(0.95)' })), 14 | ]), 15 | ]), 16 | ], 17 | templateUrl: 'styled-menu-cdk.component.html', 18 | }) 19 | export class StyledMenuCdkComponent {} 20 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/styled-menu/styled-menu.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 17 | 18 | 19 |
30 |
34 |
35 |

Signed in as

36 |

37 | tom@example.com 38 |

39 |
40 |
41 | 49 | Account settings 50 | 51 | 52 | 60 | Support 61 | 62 | 63 | 67 | New feature (soon) 68 | 69 | 70 | 78 | License 79 | 80 |
81 | 82 | 94 |
95 |
96 |
97 |
98 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/styled-menu/styled-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-styled-menu', 5 | templateUrl: 'styled-menu.component.html', 6 | }) 7 | export class StyledMenuComponent {} 8 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/styled-select/styled-select.component.html: -------------------------------------------------------------------------------- 1 |
7 |
8 | 23 |
    28 | 29 |
  • 40 | {{ person.name }} 45 | 46 | 50 | 55 | 56 |
  • 57 |
    58 |
59 |
60 |
61 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/styled-select/styled-select.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { animate, style, transition, trigger } from '@angular/animations'; 3 | 4 | @Component({ 5 | selector: 'app-styled-select', 6 | animations: [ 7 | trigger('toggleAnimation', [ 8 | transition(':enter', [ 9 | style({ opacity: 0, transform: 'scale(0.95)' }), 10 | animate('100ms ease-out', style({ opacity: 1, transform: 'scale(1)' })), 11 | ]), 12 | transition(':leave', [ 13 | animate('75ms', style({ opacity: 0, transform: 'scale(0.95)' })), 14 | ]), 15 | ]), 16 | ], 17 | templateUrl: 'styled-select.component.html', 18 | }) 19 | export class StyledSelectComponent { 20 | people: Person[] = [ 21 | { id: 1, name: 'Durward Reynolds', unavailable: false }, 22 | { id: 2, name: 'Kenton Towne', unavailable: false }, 23 | { id: 3, name: 'Therese Wunsch', unavailable: false }, 24 | { id: 4, name: 'Benedict Kessler', unavailable: true }, 25 | { id: 5, name: 'Katelyn Rohan', unavailable: false }, 26 | ]; 27 | 28 | selectedPerson: Person | null = this.people[0]; 29 | 30 | setSelectedPerson(person: Person | null) { 31 | this.selectedPerson = person; 32 | } 33 | } 34 | 35 | interface Person { 36 | id: number; 37 | name: string; 38 | unavailable: boolean; 39 | } 40 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/transition/transition.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
15 |
16 | 17 |
18 |
19 | 25 |
26 | {{ shown ? "shown" : "hidden" }} 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/transition/transition.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-transition', 5 | templateUrl: 'transition.component.html', 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | }) 8 | export class TransitionComponent { 9 | shown = true; 10 | toggle() { 11 | this.shown = !this.shown; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/transition2/transition2.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
15 |
16 | 17 |
18 |
19 | 25 |
26 | {{ shown ? "shown" : "hidden" }} 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/transition2/transition2.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-transition-2', 5 | templateUrl: 'transition2.component.html', 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | }) 8 | export class Transition2Component { 9 | shown = true; 10 | toggle() { 11 | this.shown = !this.shown; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/unstyled-menu/unstyled-menu.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 10 | Account settings 11 | 12 | 18 | Documentation 19 | 20 | 21 | Invite a friend (coming soon!) 22 | 23 |
24 |
25 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/unstyled-menu/unstyled-menu.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-unstyled-menu', 5 | templateUrl: 'unstyled-menu.component.html', 6 | }) 7 | export class UnstyledMenuComponent {} 8 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/unstyled-select/unstyled-select.component.html: -------------------------------------------------------------------------------- 1 |
6 | 9 | 26 |
27 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-components/unstyled-select/unstyled-select.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-unstyled-select', 5 | templateUrl: 'unstyled-select.component.html', 6 | }) 7 | export class UnstyledSelectComponent { 8 | people: Person[] = [ 9 | { id: 1, name: 'Durward Reynolds', unavailable: false }, 10 | { id: 2, name: 'Kenton Towne', unavailable: false }, 11 | { id: 3, name: 'Therese Wunsch', unavailable: false }, 12 | { id: 4, name: 'Benedict Kessler', unavailable: true }, 13 | { id: 5, name: 'Katelyn Rohan', unavailable: false }, 14 | ]; 15 | 16 | selectedPerson: Person | null = this.people[0]; 17 | 18 | setSelectedPerson(person: Person | null) { 19 | this.selectedPerson = person; 20 | } 21 | } 22 | 23 | interface Person { 24 | id: number; 25 | name: string; 26 | unavailable: boolean; 27 | } 28 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-container/demo-container.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

{{ name }}

4 | 5 | 6 |
7 | 17 | 18 | 28 | 29 | 39 |
40 |
41 | 42 |
48 |
54 | 55 |
56 | 57 |
58 | 59 | 60 |
61 |
62 |
63 |
64 |
65 | 66 | 67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | -------------------------------------------------------------------------------- /projects/demo/src/app/demo-container/demo-container.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostBinding, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-demo-container', 5 | templateUrl: './demo-container.component.html', 6 | }) 7 | export class DemoContainerComponent { 8 | @Input() name!: string; 9 | @Input() htmlSource!: string; 10 | @Input() typescriptSource!: string; 11 | display: 'Preview' | 'Html' | 'Typescript' = 'Preview'; 12 | 13 | @HostBinding('class') hostStyle = 'block'; 14 | } 15 | -------------------------------------------------------------------------------- /projects/demo/src/app/formattedSources.ts: -------------------------------------------------------------------------------- 1 | export const styledMenuHtml = `<div class="flex justify-center">\n <div hlMenu [static]="true" #menu="hlMenu" class="relative">\n <span class="rounded-md shadow-sm">\n <button\n hlMenuButton\n class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"\n >\n <span>Options</span>\n <svg class="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">\n <path\n fill-rule="evenodd"\n d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"\n clip-rule="evenodd"\n ></path>\n </svg>\n </button>\n </span>\n\n <div\n *hlTransition="\n menu.expanded;\n enter: 'transition ease-out duration-100';\n enterFrom: 'transform opacity-0 scale-95';\n enterTo: 'transform opacity-100 scale-100';\n leave: 'transition ease-in duration-75';\n leaveFrom: 'transform opacity-100 scale-100';\n leaveTo: 'transform opacity-0 scale-95'\n "\n >\n <div\n *hlMenuItems\n class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"\n >\n <div class="px-4 py-3">\n <p class="text-sm leading-5">Signed in as</p>\n <p class="text-sm font-medium leading-5 text-gray-900 truncate">\n tom@example.com\n </p>\n </div>\n <div class="py-1">\n <a\n *hlMenuItem="let item"\n href="./#account-settings"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n Account settings\n </a>\n\n <a\n *hlMenuItem="let item"\n href="./#support"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n Support\n </a>\n\n <span\n *hlMenuItem="let item; disabled: true"\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left text-gray-700 cursor-not-allowed opacity-50"\n >\n New feature (soon)\n </span>\n\n <a\n *hlMenuItem="let item"\n href="./#license"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n License\n </a>\n </div>\n\n <div class="py-1">\n <a\n *hlMenuItem="let item"\n href="./#sign-out"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n Sign out\n </a>\n </div>\n </div>\n </div>\n </div>\n</div>\n` 2 | export const styledMenuTypescript = `import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-styled-menu',\n templateUrl: 'styled-menu.component.html',\n})\nexport class StyledMenuComponent {}\n` 3 | export const styledMenuCdkHtml = `<!-- Styled Menu using angular cdk overlay: https://material.angular.io/cdk/overlay/overview -->\n<div class="relative flex justify-center">\n <div\n hlMenu\n [static]="true"\n #menu="hlMenu"\n cdkOverlayOrigin\n #trigger="cdkOverlayOrigin"\n >\n <span class="rounded-md shadow-sm">\n <button\n hlMenuButton\n class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"\n >\n <span>Options</span>\n <svg class="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">\n <path\n fill-rule="evenodd"\n d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"\n clip-rule="evenodd"\n ></path>\n </svg>\n </button>\n </span>\n <ng-template\n cdkConnectedOverlay\n [cdkConnectedOverlayOrigin]="trigger"\n [cdkConnectedOverlayOpen]="menu.expanded"\n >\n <div\n id="m1"\n *hlMenuItems\n class="w-56 mt-2 mb-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"\n >\n <div class="px-4 py-3">\n <p class="text-sm leading-5">Signed in as</p>\n <p class="text-sm font-medium leading-5 text-gray-900 truncate">\n tom@example.com\n </p>\n </div>\n <div class="py-1">\n <a\n *hlMenuItem="let item"\n href="./#account-settings"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n Account settings\n </a>\n\n <a\n *hlMenuItem="let item"\n href="./#support"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n Support\n </a>\n\n <span\n *hlMenuItem="let item; disabled: true"\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left text-gray-700 cursor-not-allowed opacity-50"\n >\n New feature (soon)\n </span>\n\n <a\n *hlMenuItem="let item"\n href="./#license"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n License\n </a>\n </div>\n\n <div class="py-1">\n <a\n *hlMenuItem="let item"\n href="./#sign-out"\n [class]="\n item.active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'\n "\n class="flex justify-between w-full px-4 py-2 text-sm leading-5 text-left"\n >\n Sign out\n </a>\n </div>\n </div>\n </ng-template>\n </div>\n</div>\n` 4 | export const styledMenuCdkTypescript = `import { Component } from '@angular/core';\nimport { animate, style, transition, trigger } from '@angular/animations';\n\n@Component({\n selector: 'app-styled-menu-cdk',\n animations: [\n trigger('toggleAnimation', [\n transition(':enter', [\n style({ opacity: 0, transform: 'scale(0.95)' }),\n animate('100ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),\n ]),\n transition(':leave', [\n animate('75ms', style({ opacity: 0, transform: 'scale(0.95)' })),\n ]),\n ]),\n ],\n templateUrl: 'styled-menu-cdk.component.html',\n})\nexport class StyledMenuCdkComponent {}\n` 5 | export const styledSelectHtml = `<div\n class="relative flex justify-center"\n hlListbox\n [value]="selectedPerson"\n (valueChange)="setSelectedPerson($event)"\n>\n <div class="relative w-72">\n <button\n hlListboxButton\n class="relative w-full cursor-default rounded-md bg-white py-2 pl-3 pr-10 text-left border border-gray-300 focus:outline-none focus-visible:border-gray-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-300 sm:text-sm"\n >\n <span class="block truncate">{{ selectedPerson?.name }}</span>\n <span\n class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"\n >\n <ng-icon\n name="hero-selector"\n class="text-xl text-gray-400"\n aria-hidden="true"\n ></ng-icon>\n </span>\n </button>\n <ul\n *hlListboxOptions\n @toggleAnimation\n class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"\n >\n <ng-container *ngFor="let person of people">\n <li\n *hlListboxOption="\n let option;\n value: person;\n disabled: person.unavailable\n "\n class="relative cursor-default select-none py-2 pl-10 pr-4"\n [class]="\n option.active ? 'bg-gray-100 text-gray-900' : 'text-gray-900'\n "\n >\n <span\n class="block truncate"\n [class.text-gray-300]="person.unavailable"\n >{{ person.name }}</span\n >\n\n <span\n *ngIf="option.selected"\n class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-600"\n >\n <ng-icon\n name="hero-check"\n class="text-xl text-gray-800"\n aria-hidden="true"\n ></ng-icon>\n </span>\n </li>\n </ng-container>\n </ul>\n </div>\n</div>\n` 6 | export const styledSelectTypescript = `import { Component } from '@angular/core';\nimport { animate, style, transition, trigger } from '@angular/animations';\n\n@Component({\n selector: 'app-styled-select',\n animations: [\n trigger('toggleAnimation', [\n transition(':enter', [\n style({ opacity: 0, transform: 'scale(0.95)' }),\n animate('100ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),\n ]),\n transition(':leave', [\n animate('75ms', style({ opacity: 0, transform: 'scale(0.95)' })),\n ]),\n ]),\n ],\n templateUrl: 'styled-select.component.html',\n})\nexport class StyledSelectComponent {\n people: Person[] = [\n { id: 1, name: 'Durward Reynolds', unavailable: false },\n { id: 2, name: 'Kenton Towne', unavailable: false },\n { id: 3, name: 'Therese Wunsch', unavailable: false },\n { id: 4, name: 'Benedict Kessler', unavailable: true },\n { id: 5, name: 'Katelyn Rohan', unavailable: false },\n ];\n\n selectedPerson: Person | null = this.people[0];\n\n setSelectedPerson(person: Person | null) {\n this.selectedPerson = person;\n }\n}\n\ninterface Person {\n id: number;\n name: string;\n unavailable: boolean;\n}\n` 7 | export const transitionHtml = `<div>\n <div class="w-40 h-40 mx-auto">\n <div\n *hlTransition="\n shown;\n enter: 'transform transition duration-[400ms]';\n enterFrom: 'opacity-0 rotate-[-120deg] scale-50';\n enterTo: 'opacity-100 rotate-0 scale-100';\n leave: 'transform duration-200 transition ease-in-out';\n leaveFrom: 'opacity-100 rotate-0 scale-100';\n leaveTo: 'opacity-0 scale-95'\n "\n class="p-2 bg-yellow-500 text-white h-full rounded-xl shadow-lg"\n ></div>\n </div>\n\n <div class="mt-12 flex justify-center">\n <div>\n <button\n class="rounded-md border border-gray-300 bg-white px-6 py-2 text-gray-700 shadow-sm hover:bg-gray-50"\n (click)="toggle()"\n >\n Toggle\n </button>\n <div class="text-center mt-2 text-gray-400">\n {{ shown ? "shown" : "hidden" }}\n </div>\n </div>\n </div>\n</div>\n` 8 | export const transitionTypescript = `import { ChangeDetectionStrategy, Component } from '@angular/core';\n\n@Component({\n selector: 'app-transition',\n templateUrl: 'transition.component.html',\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class TransitionComponent {\n shown = true;\n toggle() {\n this.shown = !this.shown;\n }\n}\n` 9 | export const unstyledMenuHtml = `<div hlMenu>\n <button hlMenuButton>More</button>\n <div *hlMenuItems>\n <a\n *hlMenuItem="let item"\n class="block"\n [class.bg-blue-500]="item.active"\n href="./#account-settings"\n >\n Account settings\n </a>\n <a\n *hlMenuItem="let item"\n class="block"\n [class.bg-blue-500]="item.active"\n href="./#account-settings"\n >\n Documentation\n </a>\n <span *hlMenuItem="let item; disabled: true" class="block">\n Invite a friend (coming soon!)\n </span>\n </div>\n</div>\n` 10 | export const unstyledMenuTypescript = `import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-unstyled-menu',\n templateUrl: 'unstyled-menu.component.html',\n})\nexport class UnstyledMenuComponent {}\n` 11 | export const unstyledSelectHtml = `<div\n hlListbox\n [value]="selectedPerson"\n (valueChange)="setSelectedPerson($event)"\n>\n <button hlListboxButton>\n {{ selectedPerson?.name }}\n </button>\n <ul *hlListboxOptions>\n <ng-container *ngFor="let person of people">\n <li\n *hlListboxOption="\n let option;\n value: person;\n disabled: person.unavailable\n "\n [class.bg-blue-500]="option.active"\n >\n <span class="w-5 inline-block">\n <ng-container *ngIf="option.selected"></ng-container>\n </span>\n {{ person.name }}\n </li>\n </ng-container>\n </ul>\n</div>\n` 12 | export const unstyledSelectTypescript = `import { Component } from '@angular/core';\n\n@Component({\n selector: 'app-unstyled-select',\n templateUrl: 'unstyled-select.component.html',\n})\nexport class UnstyledSelectComponent {\n people: Person[] = [\n { id: 1, name: 'Durward Reynolds', unavailable: false },\n { id: 2, name: 'Kenton Towne', unavailable: false },\n { id: 3, name: 'Therese Wunsch', unavailable: false },\n { id: 4, name: 'Benedict Kessler', unavailable: true },\n { id: 5, name: 'Katelyn Rohan', unavailable: false },\n ];\n\n selectedPerson: Person | null = this.people[0];\n\n setSelectedPerson(person: Person | null) {\n this.selectedPerson = person;\n }\n}\n\ninterface Person {\n id: number;\n name: string;\n unavailable: boolean;\n}\n` -------------------------------------------------------------------------------- /projects/demo/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibirrer/headlessui-angular/f0848be6288f3379004cc426d1cf771d75e213b3/projects/demo/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/demo/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /projects/demo/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /projects/demo/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibirrer/headlessui-angular/f0848be6288f3379004cc426d1cf771d75e213b3/projects/demo/src/favicon.ico -------------------------------------------------------------------------------- /projects/demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Demo 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /projects/demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch((err) => console.error(err)); 14 | -------------------------------------------------------------------------------- /projects/demo/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | /*************************************************************************************************** 51 | * APPLICATION IMPORTS 52 | */ 53 | -------------------------------------------------------------------------------- /projects/demo/src/styles.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @import "~@angular/cdk/overlay-prebuilt.css"; 6 | -------------------------------------------------------------------------------- /projects/demo/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting, 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context( 12 | path: string, 13 | deep?: boolean, 14 | filter?: RegExp 15 | ): { 16 | keys(): string[]; 17 | (id: string): T; 18 | }; 19 | }; 20 | 21 | // First, initialize the Angular testing environment. 22 | getTestBed().initTestEnvironment( 23 | BrowserDynamicTestingModule, 24 | platformBrowserDynamicTesting(), 25 | { teardown: { destroyAfterEach: true } } 26 | ); 27 | 28 | // Then we find all the tests. 29 | const context = require.context('./', true, /\.spec\.ts$/); 30 | // And load the modules. 31 | context.keys().map(context); 32 | -------------------------------------------------------------------------------- /projects/demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/demo/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "files": ["src/test.ts", "src/polyfills.ts"], 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/headlessui-angular/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "parserOptions": { 8 | "project": ["./projects/headlessui-angular/tsconfig.lib.json", "./projects/headlessui-angular/tsconfig.spec.json"], 9 | "createDefaultProgram": true 10 | }, 11 | "rules": { 12 | "@angular-eslint/component-selector": [ 13 | "error", 14 | { 15 | "type": "element", 16 | "prefix": "hl", 17 | "style": "kebab-case" 18 | } 19 | ], 20 | "@angular-eslint/directive-selector": [ 21 | "error", 22 | { 23 | "type": "attribute", 24 | "prefix": "hl", 25 | "style": "camelCase" 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "files": ["*.html"], 32 | "rules": {} 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /projects/headlessui-angular/karma-ci.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | process.env.CHROME_BIN = require("puppeteer").executablePath(); 5 | 6 | module.exports = function (config) { 7 | config.set({ 8 | basePath: "", 9 | frameworks: ["jasmine", "@angular-devkit/build-angular"], 10 | plugins: [ 11 | require("karma-jasmine"), 12 | require("karma-chrome-launcher"), 13 | require("karma-jasmine-html-reporter"), 14 | require("karma-coverage"), 15 | require("@angular-devkit/build-angular/plugins/karma"), 16 | ], 17 | client: { 18 | jasmine: { 19 | // you can add configuration options for Jasmine here 20 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 21 | // for example, you can disable the random execution with `random: false` 22 | // or set a specific seed with `seed: 4321` 23 | }, 24 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 25 | }, 26 | jasmineHtmlReporter: { 27 | suppressAll: true, // removes the duplicated traces 28 | }, 29 | coverageReporter: { 30 | dir: require("path").join(__dirname, "../../coverage/headlessui-angular"), 31 | subdir: ".", 32 | reporters: [{ type: "html" }, { type: "text-summary" }], 33 | }, 34 | reporters: ["progress", "kjhtml"], 35 | port: 9876, 36 | colors: true, 37 | logLevel: config.LOG_INFO, 38 | autoWatch: false, 39 | browsers: ["ChromeHeadless"], 40 | singleRun: true, 41 | restartOnFileChange: true, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /projects/headlessui-angular/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: "", 7 | frameworks: ["jasmine", "@angular-devkit/build-angular"], 8 | plugins: [ 9 | require("karma-jasmine"), 10 | require("karma-chrome-launcher"), 11 | require("karma-jasmine-html-reporter"), 12 | require("karma-coverage"), 13 | require("@angular-devkit/build-angular/plugins/karma"), 14 | ], 15 | client: { 16 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true, // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require("path").join(__dirname, "../../coverage/headlessui-angular"), 29 | subdir: ".", 30 | reporters: [{ type: "html" }, { type: "text-summary" }], 31 | }, 32 | reporters: ["progress", "kjhtml"], 33 | port: 9876, 34 | colors: true, 35 | logLevel: config.LOG_INFO, 36 | autoWatch: true, 37 | browsers: ["Chrome"], 38 | singleRun: false, 39 | restartOnFileChange: true, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /projects/headlessui-angular/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/headlessui-angular", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/headlessui-angular/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headlessui-angular", 3 | "version": "0.0.9", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/ibirrer/headlessui-angular.git" 8 | }, 9 | "peerDependencies": { 10 | "@angular/common": ">=13.0.0", 11 | "@angular/core": ">=13.0.0" 12 | }, 13 | "dependencies": { 14 | "tslib": ">=2.0.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/lib/listbox/listbox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | Directive, 4 | ElementRef, 5 | EmbeddedViewRef, 6 | EventEmitter, 7 | Input, 8 | NgModule, 9 | OnInit, 10 | Output, 11 | Renderer2, 12 | TemplateRef, 13 | ViewContainerRef, 14 | } from '@angular/core'; 15 | import { generateId } from '../util'; 16 | 17 | /// LISTBOX - Spec: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/ 18 | 19 | @Directive({ 20 | selector: '[hlListbox]', 21 | exportAs: '[hlListbox]', 22 | }) 23 | export class ListboxDirective { 24 | @Input() 25 | static = false; 26 | 27 | @Input() 28 | value: T | null = null; 29 | 30 | @Output() 31 | valueChange: EventEmitter = new EventEmitter(); 32 | 33 | expanded = false; 34 | windowClickUnlisten!: () => void; 35 | 36 | listboxButton!: ListboxButtonDirective; 37 | listboxOptionsPanel!: ListboxOptionsPanelDirective; 38 | listboxOptions: ListboxOptionDirective[] = []; 39 | activeOption: ListboxOptionDirective | null = null; 40 | searchQuery = ''; 41 | searchDebounce: ReturnType | null = null; 42 | 43 | constructor( 44 | private renderer: Renderer2, 45 | private changeDetection: ChangeDetectorRef 46 | ) {} 47 | 48 | toggle( 49 | focusAfterExpand: FocusType | null = null, 50 | focusButtonOnClose = true 51 | ) { 52 | if (this.expanded) { 53 | // close options panel 54 | this.expanded = false; 55 | this.listboxOptionsPanel.collapse(); 56 | this.listboxButton.element.removeAttribute('aria-controls'); 57 | this.listboxButton.element.removeAttribute('expanded'); 58 | this.listboxOptions = []; 59 | this.activeOption = null; 60 | this.windowClickUnlisten(); 61 | if (focusButtonOnClose) { 62 | this.listboxButton.focus(); 63 | } 64 | this.changeDetection.markForCheck(); 65 | } else { 66 | // open options panel 67 | this.expanded = true; 68 | this.changeDetection.markForCheck(); 69 | 70 | setTimeout(() => { 71 | this.listboxOptionsPanel.expand(); 72 | this.listboxOptionsPanel.focus(); 73 | if (this.listboxOptionsPanel.element != null) { 74 | this.listboxButton.element.setAttribute( 75 | 'aria-controls', 76 | this.listboxOptionsPanel.element.id 77 | ); 78 | } 79 | this.listboxButton.element.setAttribute('expanded', 'true'); 80 | this.windowClickUnlisten = this.initListeners(); 81 | if (focusAfterExpand) { 82 | setTimeout(() => this.focusOption(focusAfterExpand)); 83 | } 84 | }); 85 | } 86 | } 87 | 88 | select(value: T | null) { 89 | this.valueChange.emit(value); 90 | this.listboxOptions.forEach((option) => { 91 | option.select(option.hlListboxOptionValue === value); 92 | }); 93 | } 94 | 95 | isSelected(value: T | null): boolean { 96 | return this.value === value; 97 | } 98 | 99 | focusOption(focusType: FocusType) { 100 | const activeOption = this.calculateFocusedOption(focusType); 101 | if (activeOption === this.activeOption) { 102 | return; 103 | } 104 | this.activeOption = activeOption; 105 | this.listboxOptions.forEach((option) => { 106 | if (this.activeOption) { 107 | this.listboxOptionsPanel.element?.setAttribute( 108 | 'aria-activedescendant', 109 | this.activeOption.element.id 110 | ); 111 | } else { 112 | this.listboxOptionsPanel.element?.removeAttribute( 113 | 'aria-activedescendant' 114 | ); 115 | } 116 | option.setActive(option === this.activeOption); 117 | }); 118 | } 119 | 120 | clickActive() { 121 | this.activeOption?.element.click(); 122 | } 123 | 124 | search(value: string) { 125 | if (this.searchDebounce) { 126 | clearTimeout(this.searchDebounce); 127 | } 128 | this.searchDebounce = setTimeout(() => (this.searchQuery = ''), 350); 129 | 130 | this.searchQuery += value.toLocaleLowerCase(); 131 | const matchingOption = this.listboxOptions.find((option) => { 132 | const optionText = option.element.textContent?.trim().toLocaleLowerCase(); 133 | return ( 134 | optionText?.startsWith(this.searchQuery) && 135 | !option.hlListboxOptionDisabled 136 | ); 137 | }); 138 | 139 | if (matchingOption === undefined || matchingOption === this.activeOption) { 140 | return; 141 | } 142 | 143 | this.focusOption({ kind: 'FocusSpecific', option: matchingOption }); 144 | } 145 | 146 | private calculateFocusedOption( 147 | focusType: FocusType 148 | ): ListboxOptionDirective | null { 149 | const enabledOptions = this.listboxOptions.filter( 150 | (option) => !option.hlListboxOptionDisabled 151 | ); 152 | 153 | switch (focusType.kind) { 154 | case 'FocusSpecific': 155 | return focusType.option; 156 | 157 | case 'FocusValue': 158 | const option = this.listboxOptions.find( 159 | (o) => o.hlListboxOptionValue === focusType.value 160 | ); 161 | if (option) { 162 | return option; 163 | } 164 | return null; 165 | 166 | case 'FocusNothing': 167 | return null; 168 | 169 | case 'FocusFirst': 170 | return enabledOptions[0]; 171 | 172 | case 'FocusLast': 173 | return enabledOptions[enabledOptions.length - 1]; 174 | 175 | case 'FocusNext': 176 | if (this.activeOption === null) { 177 | return enabledOptions[0]; 178 | } else { 179 | const nextIndex = Math.min( 180 | enabledOptions.indexOf(this.activeOption) + 1, 181 | enabledOptions.length - 1 182 | ); 183 | return enabledOptions[nextIndex]; 184 | } 185 | 186 | case 'FocusPrevious': 187 | if (this.activeOption === null) { 188 | return enabledOptions[enabledOptions.length - 1]; 189 | } else { 190 | const previousIndex = Math.max( 191 | enabledOptions.indexOf(this.activeOption) - 1, 192 | 0 193 | ); 194 | return enabledOptions[previousIndex]; 195 | } 196 | } 197 | } 198 | 199 | private initListeners(): () => void { 200 | return this.renderer.listen(window, 'click', (event: MouseEvent) => { 201 | const target = event.target as HTMLElement; 202 | const active = document.activeElement; 203 | 204 | if ( 205 | this.listboxButton.element.contains(target) || 206 | this.listboxOptionsPanel?.element?.contains(target) 207 | ) { 208 | return; 209 | } 210 | 211 | const clickedTargetIsFocusable = 212 | active !== document.body && active?.contains(target); 213 | 214 | // do not focus button if the clicked element is itself focusable 215 | this.toggle(null, !clickedTargetIsFocusable); 216 | }); 217 | } 218 | } 219 | 220 | // LISTBOX OPTION BUTTON 221 | 222 | @Directive({ 223 | selector: '[hlListboxButton]', 224 | }) 225 | export class ListboxButtonDirective implements OnInit { 226 | element!: HTMLElement; 227 | 228 | constructor( 229 | elementRef: ElementRef, 230 | private listbox: ListboxDirective, 231 | private renderer: Renderer2 232 | ) { 233 | this.element = elementRef.nativeElement; 234 | listbox.listboxButton = this; 235 | } 236 | 237 | ngOnInit(): void { 238 | this.initAttributes(this.element); 239 | 240 | this.renderer.listen(this.element, 'click', () => { 241 | this.listbox.toggle(); 242 | }); 243 | 244 | this.renderer.listen(this.element, 'keydown', (event: KeyboardEvent) => { 245 | switch (event.key) { 246 | case ' ': // Space 247 | case 'Enter': 248 | case 'ArrowDown': 249 | event.preventDefault(); 250 | if (this.listbox.value) { 251 | this.listbox.toggle({ 252 | kind: 'FocusValue', 253 | value: this.listbox.value, 254 | }); 255 | } else { 256 | this.listbox.toggle({ kind: 'FocusFirst' }); 257 | } 258 | break; 259 | 260 | case 'ArrowUp': 261 | event.preventDefault(); 262 | if (this.listbox.value) { 263 | this.listbox.toggle({ 264 | kind: 'FocusValue', 265 | value: this.listbox.value, 266 | }); 267 | } else { 268 | this.listbox.toggle({ kind: 'FocusPrevious' }); 269 | } 270 | break; 271 | } 272 | }); 273 | } 274 | 275 | focus() { 276 | setTimeout(() => this.element?.focus()); 277 | } 278 | 279 | private initAttributes(element: HTMLElement) { 280 | element.id = `headlessui-listbox-button-${generateId()}`; 281 | element.setAttribute('type', 'button'); 282 | element.setAttribute('aria-haspopup', 'true'); 283 | } 284 | } 285 | 286 | /// LISTBOX OPTIONS PANEL 287 | 288 | @Directive({ 289 | selector: '[hlListboxOptions]', 290 | }) 291 | export class ListboxOptionsPanelDirective implements OnInit { 292 | element: HTMLElement | null = null; 293 | 294 | constructor( 295 | private templateRef: TemplateRef, 296 | private viewContainerRef: ViewContainerRef, 297 | private listbox: ListboxDirective, 298 | private renderer: Renderer2 299 | ) { 300 | this.listbox.listboxOptionsPanel = this; 301 | } 302 | 303 | ngOnInit(): void { 304 | if (this.listbox.static) { 305 | this.expandInternal(); 306 | } 307 | } 308 | 309 | expand() { 310 | if (!this.listbox.static) { 311 | this.expandInternal(); 312 | } 313 | } 314 | 315 | collapse() { 316 | if (!this.listbox.static) { 317 | this.viewContainerRef.clear(); 318 | this.element = null; 319 | } 320 | } 321 | 322 | focus() { 323 | this.element?.focus({ preventScroll: true }); 324 | } 325 | 326 | private expandInternal() { 327 | const view = this.viewContainerRef.createEmbeddedView(this.templateRef); 328 | const element = view.rootNodes[0]; 329 | this.initAttributes(element); 330 | this.initListeners(element); 331 | this.element = element; 332 | view.markForCheck(); 333 | } 334 | 335 | private initAttributes(element: HTMLElement) { 336 | element.tabIndex = -1; 337 | element.id = `headlessui-listbox-options-${generateId()}`; 338 | element.setAttribute('role', 'listbox'); 339 | element.setAttribute( 340 | 'aria-labelledby', 341 | this.listbox.listboxButton.element.id 342 | ); 343 | } 344 | 345 | private initListeners(element: HTMLElement) { 346 | this.renderer.listen(element, 'keydown', (event: KeyboardEvent) => { 347 | switch (event.key) { 348 | case ' ': // Space 349 | if (this.listbox.searchQuery !== '') { 350 | event.preventDefault(); 351 | this.listbox.search(event.key); 352 | } else { 353 | event.preventDefault(); 354 | this.listbox.clickActive(); 355 | } 356 | break; 357 | case 'Enter': 358 | event.preventDefault(); 359 | this.listbox.clickActive(); 360 | break; 361 | 362 | case 'ArrowDown': 363 | event.preventDefault(); 364 | this.listbox.focusOption({ kind: 'FocusNext' }); 365 | break; 366 | 367 | case 'ArrowUp': 368 | event.preventDefault(); 369 | this.listbox.focusOption({ kind: 'FocusPrevious' }); 370 | break; 371 | 372 | case 'Home': 373 | case 'PageUp': 374 | event.preventDefault(); 375 | this.listbox.focusOption({ kind: 'FocusFirst' }); 376 | break; 377 | 378 | case 'End': 379 | case 'PageDown': 380 | event.preventDefault(); 381 | this.listbox.focusOption({ kind: 'FocusLast' }); 382 | break; 383 | 384 | case 'Escape': 385 | event.preventDefault(); 386 | this.listbox.toggle(); 387 | break; 388 | 389 | case 'Tab': 390 | event.preventDefault(); 391 | break; 392 | 393 | default: 394 | if (event.key.length === 1) { 395 | this.listbox.search(event.key); 396 | } 397 | } 398 | }); 399 | } 400 | } 401 | 402 | // LISTBOX OPTION 403 | 404 | @Directive({ 405 | selector: '[hlListboxOption]', 406 | }) 407 | export class ListboxOptionDirective implements OnInit { 408 | @Input() 409 | hlListboxOptionDisabled = false; 410 | 411 | @Input() 412 | hlListboxOptionValue: T | null = null; 413 | 414 | element!: HTMLElement; 415 | context = { active: false, selected: false }; 416 | 417 | private view!: EmbeddedViewRef; 418 | 419 | constructor( 420 | private templateRef: TemplateRef, 421 | private viewContainerRef: ViewContainerRef, 422 | private listbox: ListboxDirective, 423 | private renderer: Renderer2 424 | ) { 425 | this.listbox.listboxOptions.push(this); 426 | } 427 | 428 | ngOnInit(): void { 429 | this.context.selected = this.listbox.isSelected(this.hlListboxOptionValue); 430 | this.view = this.viewContainerRef.createEmbeddedView(this.templateRef, { 431 | $implicit: this.context, 432 | }); 433 | this.element = this.view.rootNodes[0]; 434 | this.initAttributes(this.element); 435 | this.initListeners(this.element); 436 | } 437 | 438 | setActive(active: boolean) { 439 | this.context.active = active; 440 | this.view.markForCheck(); 441 | } 442 | 443 | select(selected: boolean) { 444 | this.context.selected = selected; 445 | this.view.markForCheck(); 446 | } 447 | 448 | private initAttributes(element: HTMLElement) { 449 | element.id = `headlessui-listbox-option-${generateId()}`; 450 | element.tabIndex = -1; 451 | element.setAttribute('role', 'listboxoption'); 452 | if (this.hlListboxOptionDisabled) { 453 | this.element.setAttribute('aria-disabled', 'true'); 454 | } else { 455 | this.element.removeAttribute('aria-disabled'); 456 | } 457 | } 458 | 459 | private initListeners(element: HTMLElement) { 460 | this.renderer.listen(element, 'pointermove', () => 461 | this.listbox.focusOption({ kind: 'FocusSpecific', option: this }) 462 | ); 463 | 464 | this.renderer.listen(element, 'pointerleave', () => 465 | this.listbox.focusOption({ kind: 'FocusNothing' }) 466 | ); 467 | 468 | this.renderer.listen(element, 'click', (event) => { 469 | if (this.hlListboxOptionDisabled) { 470 | event.preventDefault(); 471 | return; 472 | } 473 | this.listbox.select(this.hlListboxOptionValue); 474 | this.listbox.toggle(); 475 | }); 476 | } 477 | } 478 | 479 | type FocusFirst = { kind: 'FocusFirst' }; 480 | type FocusLast = { kind: 'FocusLast' }; 481 | type FocusPrevious = { kind: 'FocusPrevious' }; 482 | type FocusNext = { kind: 'FocusNext' }; 483 | type FocusNothing = { kind: 'FocusNothing' }; 484 | type FocusSpecific = { 485 | kind: 'FocusSpecific'; 486 | option: ListboxOptionDirective; 487 | }; 488 | type FocusValue = { kind: 'FocusValue'; value: T }; 489 | 490 | type FocusType = 491 | | FocusFirst 492 | | FocusLast 493 | | FocusPrevious 494 | | FocusNext 495 | | FocusNothing 496 | | FocusSpecific 497 | | FocusValue; 498 | 499 | @NgModule({ 500 | imports: [], 501 | exports: [ 502 | ListboxDirective, 503 | ListboxButtonDirective, 504 | ListboxOptionsPanelDirective, 505 | ListboxOptionDirective, 506 | ], 507 | declarations: [ 508 | ListboxDirective, 509 | ListboxButtonDirective, 510 | ListboxOptionsPanelDirective, 511 | ListboxOptionDirective, 512 | ], 513 | providers: [], 514 | }) 515 | export class ListboxModule {} 516 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/lib/menu/menu.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | DebugElement, 5 | } from '@angular/core'; 6 | import { 7 | ComponentFixture, 8 | fakeAsync, 9 | TestBed, 10 | tick, 11 | } from '@angular/core/testing'; 12 | import { By } from '@angular/platform-browser'; 13 | import { resetIdCounter } from '../util'; 14 | import { 15 | MenuButtonDirective, 16 | MenuDirective, 17 | MenuItemDirective, 18 | MenuItemsPanelDirective, 19 | } from './menu'; 20 | 21 | describe('MenuTestComponent', () => { 22 | let component: MenuTestComponent; 23 | let fixture: ComponentFixture; 24 | 25 | beforeEach(async () => { 26 | await TestBed.configureTestingModule({ 27 | declarations: [ 28 | MenuTestComponent, 29 | MenuDirective, 30 | MenuButtonDirective, 31 | MenuItemsPanelDirective, 32 | MenuItemDirective, 33 | ], 34 | }).compileComponents(); 35 | }); 36 | 37 | beforeEach(() => { 38 | resetIdCounter(); 39 | fixture = TestBed.createComponent(MenuTestComponent); 40 | component = fixture.componentInstance; 41 | fixture.detectChanges(); 42 | }); 43 | 44 | it('should be possible to render a Menu without crashing', () => { 45 | expect(menuButton().attributes['id']).toBe('headlessui-menu-button-1'); 46 | expect(menuItems().length).toBe(0); 47 | expect(menuItemsPanel().length).toBe(0); 48 | }); 49 | 50 | it('should be possible to toggle the menu', fakeAsync(() => { 51 | click(menuButton()); 52 | tick(); 53 | fixture.detectChanges(); 54 | expect(menuItemsPanel().length).toBe(1); 55 | expect(menuItems().length).toBe(3); 56 | expect(menuButton().attributes['aria-controls']).toBe( 57 | 'headlessui-menu-items-2' 58 | ); 59 | expect(menuButton().attributes['expanded']).toBe('true'); 60 | click(menuButton()); 61 | tick(); 62 | expect(menuItems().length).toBe(0); 63 | expect(menuItemsPanel().length).toBe(0); 64 | expect(menuButton().attributes['aria-controls']).toBeUndefined(); 65 | expect(menuButton().attributes['expanded']).toBeUndefined(); 66 | })); 67 | 68 | it('should be possible to navigate the menu with arrow down', fakeAsync(() => { 69 | arrowDown(menuButton()); 70 | fixture.detectChanges(); 71 | tick(0, { processNewMacroTasksSynchronously: false }); 72 | fixture.detectChanges(); 73 | tick(0, { processNewMacroTasksSynchronously: false }); 74 | expect(menuItemsPanel().length).toBe(1); 75 | tick(); 76 | fixture.detectChanges(); 77 | expect(menuItemsPanel().length).toBe(1); 78 | expect(menuItems().length).toBe(3); 79 | expect(menuButton().attributes['aria-controls']).toBe( 80 | 'headlessui-menu-items-2' 81 | ); 82 | expect(menuButton().attributes['expanded']).toBe('true'); 83 | 84 | // run delayed focus of first element 85 | tick(); 86 | fixture.detectChanges(); 87 | 88 | expect(menuItemsState()).toEqual([true, false, false]); 89 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 90 | 'headlessui-menu-item-3' 91 | ); 92 | 93 | arrowDown(menuItemsPanel()[0]); 94 | tick(); 95 | fixture.detectChanges(); 96 | expect(menuItemsState()).toEqual([false, true, false]); 97 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 98 | 'headlessui-menu-item-4' 99 | ); 100 | 101 | arrowDown(menuItemsPanel()[0]); 102 | tick(); 103 | fixture.detectChanges(); 104 | expect(menuItemsState()).toEqual([false, false, true]); 105 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 106 | 'headlessui-menu-item-5' 107 | ); 108 | 109 | arrowDown(menuItemsPanel()[0]); 110 | tick(); 111 | fixture.detectChanges(); 112 | expect(menuItemsState()).toEqual([false, false, true]); 113 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 114 | 'headlessui-menu-item-5' 115 | ); 116 | })); 117 | 118 | it('should be possible to navigate the menu with arrow up', fakeAsync(() => { 119 | arrowUp(menuButton()); 120 | fixture.detectChanges(); 121 | tick(0, { processNewMacroTasksSynchronously: false }); 122 | fixture.detectChanges(); 123 | tick(0, { processNewMacroTasksSynchronously: false }); 124 | expect(menuItemsPanel().length).toBe(1); 125 | expect(menuItems().length).toBe(3); 126 | expect(menuButton().attributes['aria-controls']).toBe( 127 | 'headlessui-menu-items-2' 128 | ); 129 | expect(menuButton().attributes['expanded']).toBe('true'); 130 | 131 | // run delayed focus of first element 132 | tick(); 133 | fixture.detectChanges(); 134 | 135 | expect(menuItemsState()).toEqual([false, false, true]); 136 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 137 | 'headlessui-menu-item-5' 138 | ); 139 | 140 | arrowUp(menuItemsPanel()[0]); 141 | fixture.detectChanges(); 142 | expect(menuItemsState()).toEqual([false, true, false]); 143 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 144 | 'headlessui-menu-item-4' 145 | ); 146 | 147 | arrowUp(menuItemsPanel()[0]); 148 | fixture.detectChanges(); 149 | expect(menuItemsState()).toEqual([true, false, false]); 150 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 151 | 'headlessui-menu-item-3' 152 | ); 153 | 154 | arrowUp(menuItemsPanel()[0]); 155 | fixture.detectChanges(); 156 | expect(menuItemsState()).toEqual([true, false, false]); 157 | expect(menuItemsPanel()[0].attributes['aria-activedescendant']).toBe( 158 | 'headlessui-menu-item-3' 159 | ); 160 | })); 161 | 162 | it('should be possible to close the menu with the escape key', fakeAsync(() => { 163 | click(menuButton()); 164 | tick(); 165 | escape(menuItemsPanel()[0]); 166 | tick(); 167 | fixture.detectChanges(); 168 | expect(menuItemsPanel().length).toBe(0); 169 | expect(menuItems().length).toBe(0); 170 | })); 171 | 172 | // HELPERS 173 | 174 | function menuButton(): DebugElement { 175 | const menuButtons = fixture.debugElement.queryAll(By.css('button')); 176 | expect(menuButtons.length).toBe(1); 177 | return menuButtons[0]; 178 | } 179 | 180 | function menuItemsPanel(): DebugElement[] { 181 | return fixture.debugElement.queryAll(By.css('ul')); 182 | } 183 | 184 | function menuItems(): DebugElement[] { 185 | return fixture.debugElement.queryAll(By.css('li')); 186 | } 187 | 188 | function click(debugElement: DebugElement) { 189 | debugElement.triggerEventHandler('click', null); 190 | } 191 | 192 | function arrowDown(debugElement: DebugElement) { 193 | debugElement.nativeElement.dispatchEvent( 194 | new KeyboardEvent('keydown', { key: 'ArrowDown' }) 195 | ); 196 | } 197 | 198 | function arrowUp(debugElement: DebugElement) { 199 | debugElement.nativeElement.dispatchEvent( 200 | new KeyboardEvent('keydown', { key: 'ArrowUp' }) 201 | ); 202 | } 203 | 204 | function escape(debugElement: DebugElement) { 205 | debugElement.nativeElement.dispatchEvent( 206 | new KeyboardEvent('keydown', { key: 'Escape' }) 207 | ); 208 | } 209 | 210 | function menuItemsState(): boolean[] { 211 | return menuItems().map((item) => { 212 | if (item.nativeElement.innerText === 'true') { 213 | return true; 214 | } 215 | 216 | if (item.nativeElement.innerText === 'false') { 217 | return false; 218 | } 219 | 220 | throw new Error('illegal acitve state: ' + item.nativeElement.innerText); 221 | }); 222 | } 223 | }); 224 | 225 | @Component({ 226 | // eslint-disable-next-line @angular-eslint/component-selector 227 | selector: 'app-menu-test', 228 | changeDetection: ChangeDetectionStrategy.OnPush, 229 | template: `
230 | 231 |
    232 |
  • {{ item.active }}
  • 233 |
  • {{ item.active }}
  • 234 |
  • {{ item.active }}
  • 235 |
236 |
`, 237 | }) 238 | class MenuTestComponent {} 239 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/lib/menu/menu.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | Directive, 4 | ElementRef, 5 | EmbeddedViewRef, 6 | Input, 7 | NgModule, 8 | OnInit, 9 | Renderer2, 10 | TemplateRef, 11 | ViewContainerRef, 12 | } from '@angular/core'; 13 | import { generateId } from '../util'; 14 | 15 | /// MENU - Spec: https://www.w3.org/TR/wai-aria-practices-1.2/#menubutton 16 | 17 | @Directive({ 18 | selector: '[hlMenu]', 19 | exportAs: 'hlMenu', 20 | }) 21 | export class MenuDirective { 22 | @Input() 23 | static = false; 24 | 25 | expanded = false; 26 | windowClickUnlisten!: () => void; 27 | 28 | menuButton!: MenuButtonDirective; 29 | menuItemsPanel!: MenuItemsPanelDirective; 30 | menuItems: MenuItemDirective[] = []; 31 | activeItem: MenuItemDirective | null = null; 32 | searchQuery = ''; 33 | searchDebounce: ReturnType | null = null; 34 | 35 | constructor( 36 | private renderer: Renderer2, 37 | private changeDetection: ChangeDetectorRef 38 | ) {} 39 | 40 | toggle(focusAfterExpand: FocusType | null = null, focusButtonOnClose = true) { 41 | if (this.expanded) { 42 | // close items panel 43 | this.expanded = false; 44 | this.menuItemsPanel.collapse(); 45 | this.menuButton.element.removeAttribute('aria-controls'); 46 | this.menuButton.element.removeAttribute('expanded'); 47 | this.menuItems = []; 48 | this.activeItem = null; 49 | this.windowClickUnlisten(); 50 | if (focusButtonOnClose) { 51 | this.menuButton.focus(); 52 | } 53 | this.changeDetection.markForCheck(); 54 | } else { 55 | // open items panel 56 | this.expanded = true; 57 | this.changeDetection.markForCheck(); 58 | 59 | setTimeout(() => { 60 | this.menuItemsPanel.expand(); 61 | this.menuItemsPanel.focus(); 62 | if (this.menuItemsPanel.element != null) { 63 | this.menuButton.element.setAttribute( 64 | 'aria-controls', 65 | this.menuItemsPanel.element.id 66 | ); 67 | } 68 | this.menuButton.element.setAttribute('expanded', 'true'); 69 | this.windowClickUnlisten = this.initListeners(); 70 | if (focusAfterExpand) { 71 | setTimeout(() => this.focusItem(focusAfterExpand)); 72 | } 73 | }); 74 | } 75 | } 76 | 77 | focusItem(focusType: FocusType) { 78 | const activeItem = this.calculateFocusedItem(focusType); 79 | if (activeItem === this.activeItem) { 80 | return; 81 | } 82 | this.activeItem = activeItem; 83 | this.menuItems.forEach((item) => { 84 | if (this.activeItem) { 85 | this.menuItemsPanel.element?.setAttribute( 86 | 'aria-activedescendant', 87 | this.activeItem.element.id 88 | ); 89 | } else { 90 | this.menuItemsPanel.element?.removeAttribute('aria-activedescendant'); 91 | } 92 | item.setActive(item === this.activeItem); 93 | }); 94 | } 95 | 96 | clickActive() { 97 | this.activeItem?.element.click(); 98 | } 99 | 100 | search(value: string) { 101 | if (this.searchDebounce) { 102 | clearTimeout(this.searchDebounce); 103 | } 104 | this.searchDebounce = setTimeout(() => (this.searchQuery = ''), 350); 105 | 106 | this.searchQuery += value.toLocaleLowerCase(); 107 | const matchingItem = this.menuItems.find((item) => { 108 | const itemText = item.element.textContent?.trim().toLocaleLowerCase(); 109 | return itemText?.startsWith(this.searchQuery) && !item.hlMenuItemDisabled; 110 | }); 111 | 112 | if (matchingItem === undefined || matchingItem === this.activeItem) { 113 | return; 114 | } 115 | 116 | this.focusItem({ kind: 'FocusSpecific', item: matchingItem }); 117 | } 118 | 119 | private calculateFocusedItem(focusType: FocusType): MenuItemDirective | null { 120 | const enabledItems = this.menuItems.filter( 121 | (item) => !item.hlMenuItemDisabled 122 | ); 123 | 124 | switch (focusType.kind) { 125 | case 'FocusSpecific': 126 | return focusType.item; 127 | 128 | case 'FocusNothing': 129 | return null; 130 | 131 | case 'FocusFirst': 132 | return enabledItems[0]; 133 | 134 | case 'FocusLast': 135 | return enabledItems[enabledItems.length - 1]; 136 | 137 | case 'FocusNext': 138 | if (this.activeItem === null) { 139 | return enabledItems[0]; 140 | } else { 141 | const nextIndex = Math.min( 142 | enabledItems.indexOf(this.activeItem) + 1, 143 | enabledItems.length - 1 144 | ); 145 | return enabledItems[nextIndex]; 146 | } 147 | 148 | case 'FocusPrevious': 149 | if (this.activeItem === null) { 150 | return enabledItems[enabledItems.length - 1]; 151 | } else { 152 | const previousIndex = Math.max( 153 | enabledItems.indexOf(this.activeItem) - 1, 154 | 0 155 | ); 156 | return enabledItems[previousIndex]; 157 | } 158 | } 159 | } 160 | 161 | private initListeners(): () => void { 162 | return this.renderer.listen(window, 'click', (event: MouseEvent) => { 163 | const target = event.target as HTMLElement; 164 | const active = document.activeElement; 165 | 166 | if ( 167 | this.menuButton.element.contains(target) || 168 | this.menuItemsPanel?.element?.contains(target) 169 | ) { 170 | return; 171 | } 172 | 173 | const clickedTargetIsFocusable = 174 | active !== document.body && active?.contains(target); 175 | 176 | // do not focus button if the clicked element is itself focusable 177 | this.toggle(null, !clickedTargetIsFocusable); 178 | }); 179 | } 180 | } 181 | 182 | // MENU ITEM BUTTON 183 | 184 | @Directive({ 185 | selector: '[hlMenuButton]', 186 | }) 187 | export class MenuButtonDirective implements OnInit { 188 | element!: HTMLElement; 189 | 190 | constructor( 191 | elementRef: ElementRef, 192 | private menu: MenuDirective, 193 | private renderer: Renderer2 194 | ) { 195 | this.element = elementRef.nativeElement; 196 | menu.menuButton = this; 197 | } 198 | 199 | ngOnInit(): void { 200 | this.initAttributes(this.element); 201 | 202 | this.renderer.listen(this.element, 'click', () => { 203 | this.menu.toggle(); 204 | }); 205 | 206 | this.renderer.listen(this.element, 'keydown', (event: KeyboardEvent) => { 207 | switch (event.key) { 208 | case ' ': // Space 209 | case 'Enter': 210 | case 'ArrowDown': 211 | event.preventDefault(); 212 | this.menu.toggle({ kind: 'FocusFirst' }); 213 | break; 214 | 215 | case 'ArrowUp': 216 | event.preventDefault(); 217 | this.menu.toggle({ kind: 'FocusLast' }); 218 | break; 219 | } 220 | }); 221 | } 222 | 223 | focus() { 224 | setTimeout(() => this.element?.focus()); 225 | } 226 | 227 | private initAttributes(element: HTMLElement) { 228 | element.id = `headlessui-menu-button-${generateId()}`; 229 | element.setAttribute('type', 'button'); 230 | element.setAttribute('aria-haspopup', 'true'); 231 | } 232 | } 233 | 234 | /// MENU ITEMS PANEL 235 | 236 | @Directive({ 237 | selector: '[hlMenuItems]', 238 | }) 239 | export class MenuItemsPanelDirective implements OnInit { 240 | element: HTMLElement | null = null; 241 | 242 | constructor( 243 | private templateRef: TemplateRef, 244 | private viewContainerRef: ViewContainerRef, 245 | private menu: MenuDirective, 246 | private renderer: Renderer2 247 | ) { 248 | this.menu.menuItemsPanel = this; 249 | } 250 | 251 | ngOnInit(): void { 252 | if (this.menu.static) { 253 | this.expandInternal(); 254 | } 255 | } 256 | 257 | expand() { 258 | if (!this.menu.static) { 259 | this.expandInternal(); 260 | } 261 | } 262 | 263 | collapse() { 264 | if (!this.menu.static) { 265 | this.viewContainerRef.clear(); 266 | this.element = null; 267 | } 268 | } 269 | 270 | focus() { 271 | this.element?.focus({ preventScroll: true }); 272 | } 273 | 274 | private expandInternal() { 275 | const view = this.viewContainerRef.createEmbeddedView(this.templateRef); 276 | const element = view.rootNodes[0]; 277 | this.initAttributes(element); 278 | this.initListeners(element); 279 | this.element = element; 280 | view.markForCheck(); 281 | } 282 | 283 | private initAttributes(element: HTMLElement) { 284 | element.tabIndex = -1; 285 | element.id = `headlessui-menu-items-${generateId()}`; 286 | element.setAttribute('role', 'menu'); 287 | element.setAttribute('aria-labelledby', this.menu.menuButton.element.id); 288 | } 289 | 290 | private initListeners(element: HTMLElement) { 291 | this.renderer.listen(element, 'keydown', (event: KeyboardEvent) => { 292 | switch (event.key) { 293 | case ' ': // Space 294 | if (this.menu.searchQuery !== '') { 295 | event.preventDefault(); 296 | this.menu.search(event.key); 297 | } else { 298 | event.preventDefault(); 299 | this.menu.clickActive(); 300 | } 301 | break; 302 | case 'Enter': 303 | event.preventDefault(); 304 | this.menu.clickActive(); 305 | break; 306 | 307 | case 'ArrowDown': 308 | event.preventDefault(); 309 | this.menu.focusItem({ kind: 'FocusNext' }); 310 | break; 311 | 312 | case 'ArrowUp': 313 | event.preventDefault(); 314 | this.menu.focusItem({ kind: 'FocusPrevious' }); 315 | break; 316 | 317 | case 'Home': 318 | case 'PageUp': 319 | event.preventDefault(); 320 | this.menu.focusItem({ kind: 'FocusFirst' }); 321 | break; 322 | 323 | case 'End': 324 | case 'PageDown': 325 | event.preventDefault(); 326 | this.menu.focusItem({ kind: 'FocusLast' }); 327 | break; 328 | 329 | case 'Escape': 330 | event.preventDefault(); 331 | this.menu.toggle(); 332 | break; 333 | 334 | case 'Tab': 335 | event.preventDefault(); 336 | break; 337 | 338 | default: 339 | if (event.key.length === 1) { 340 | this.menu.search(event.key); 341 | } 342 | } 343 | }); 344 | } 345 | } 346 | 347 | // MENU ITEM 348 | 349 | @Directive({ 350 | selector: '[hlMenuItem]', 351 | }) 352 | export class MenuItemDirective implements OnInit { 353 | @Input() 354 | hlMenuItemDisabled = false; 355 | 356 | element!: HTMLElement; 357 | 358 | private view!: EmbeddedViewRef; 359 | private context = { active: false }; 360 | 361 | constructor( 362 | private templateRef: TemplateRef, 363 | private viewContainerRef: ViewContainerRef, 364 | private menu: MenuDirective, 365 | private renderer: Renderer2 366 | ) { 367 | this.menu.menuItems.push(this); 368 | } 369 | 370 | ngOnInit(): void { 371 | this.view = this.viewContainerRef.createEmbeddedView(this.templateRef, { 372 | $implicit: this.context, 373 | }); 374 | this.element = this.view.rootNodes[0]; 375 | this.initAttributes(this.element); 376 | this.initListeners(this.element); 377 | } 378 | 379 | setActive(active: boolean) { 380 | this.context.active = active; 381 | this.view.markForCheck(); 382 | } 383 | 384 | private initAttributes(element: HTMLElement) { 385 | element.id = `headlessui-menu-item-${generateId()}`; 386 | element.tabIndex = -1; 387 | element.setAttribute('role', 'menuitem'); 388 | if (this.hlMenuItemDisabled) { 389 | this.element.setAttribute('aria-disabled', 'true'); 390 | } else { 391 | this.element.removeAttribute('aria-disabled'); 392 | } 393 | } 394 | 395 | private initListeners(element: HTMLElement) { 396 | this.renderer.listen(element, 'pointermove', () => 397 | this.menu.focusItem({ kind: 'FocusSpecific', item: this }) 398 | ); 399 | 400 | this.renderer.listen(element, 'pointerleave', () => 401 | this.menu.focusItem({ kind: 'FocusNothing' }) 402 | ); 403 | 404 | this.renderer.listen(element, 'click', (event) => { 405 | if (this.hlMenuItemDisabled) { 406 | event.preventDefault(); 407 | return; 408 | } 409 | this.menu.toggle(); 410 | }); 411 | } 412 | } 413 | 414 | type FocusFirst = { kind: 'FocusFirst' }; 415 | type FocusLast = { kind: 'FocusLast' }; 416 | type FocusPrevious = { kind: 'FocusPrevious' }; 417 | type FocusNext = { kind: 'FocusNext' }; 418 | type FocusNothing = { kind: 'FocusNothing' }; 419 | type FocusSpecific = { kind: 'FocusSpecific'; item: MenuItemDirective }; 420 | 421 | type FocusType = 422 | | FocusFirst 423 | | FocusLast 424 | | FocusPrevious 425 | | FocusNext 426 | | FocusNothing 427 | | FocusSpecific; 428 | 429 | @NgModule({ 430 | imports: [], 431 | exports: [ 432 | MenuDirective, 433 | MenuButtonDirective, 434 | MenuItemsPanelDirective, 435 | MenuItemDirective, 436 | ], 437 | declarations: [ 438 | MenuDirective, 439 | MenuButtonDirective, 440 | MenuItemsPanelDirective, 441 | MenuItemDirective, 442 | ], 443 | providers: [], 444 | }) 445 | export class MenuModule {} 446 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/lib/transition/transition.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectorRef, 3 | Directive, 4 | EmbeddedViewRef, 5 | Input, 6 | NgModule, 7 | TemplateRef, 8 | ViewContainerRef, 9 | } from '@angular/core'; 10 | 11 | @Directive({ 12 | selector: '[hlTransition]', 13 | exportAs: 'hlTransition', 14 | }) 15 | export class TransitionDirective { 16 | @Input() 17 | set hlTransitionEnter(classes: string) { 18 | this.enterClasses = splitClasses(classes); 19 | } 20 | 21 | @Input() 22 | set hlTransitionEnterFrom(classes: string) { 23 | this.enterFromClasses = splitClasses(classes); 24 | } 25 | 26 | @Input() 27 | set hlTransitionEnterTo(classes: string) { 28 | this.enterToClasses = splitClasses(classes); 29 | } 30 | 31 | @Input() 32 | set hlTransitionLeave(classes: string) { 33 | this.leaveClasses = splitClasses(classes); 34 | } 35 | 36 | @Input() 37 | set hlTransitionLeaveFrom(classes: string) { 38 | this.leaveFromClasses = splitClasses(classes); 39 | } 40 | 41 | @Input() 42 | set hlTransitionLeaveTo(classes: string) { 43 | this.leaveToClasses = splitClasses(classes); 44 | } 45 | 46 | @Input() 47 | set hlTransition(show: boolean) { 48 | if (show) { 49 | this.cancelLeaveAnimation = true; 50 | if (this.viewRef) { 51 | // element not removed because leave animation is still running 52 | const element = this.viewRef.rootNodes[0]; 53 | element.classList.remove( 54 | ...this.leaveClasses, 55 | ...this.leaveFromClasses, 56 | ...this.leaveToClasses 57 | ); 58 | } else { 59 | this.viewRef = this.viewContainer.createEmbeddedView(this.templateRef); 60 | if (this.initial) { 61 | this.initial = false; 62 | return; 63 | } 64 | } 65 | 66 | this.changeDetection.markForCheck(); 67 | 68 | const element = this.viewRef.rootNodes[0]; 69 | // prepare animation 70 | element.classList.add(...this.enterFromClasses); 71 | requestAnimationFrame(() => { 72 | // start animation 73 | element.classList.remove(...this.enterFromClasses); 74 | element.classList.add(...this.enterClasses, ...this.enterToClasses); 75 | }); 76 | } else { 77 | if (this.initial) { 78 | this.initial = false; 79 | return; 80 | } 81 | 82 | if (!this.viewRef) { 83 | console.error('viewRef not set'); 84 | return; 85 | } 86 | 87 | this.cancelLeaveAnimation = false; 88 | const element = this.viewRef.rootNodes[0]; 89 | 90 | // prepare animation by removing enter-classes and add leave- and leaveFrom-classes. 91 | element.classList.remove(...this.enterClasses, ...this.enterToClasses); 92 | element.classList.add(...this.leaveClasses, ...this.leaveFromClasses); 93 | const duration = getDuration(element); 94 | requestAnimationFrame(() => { 95 | // start animation by removing from- and add to-classes 96 | element.classList.remove(...this.leaveFromClasses); 97 | element.classList.add(...this.leaveToClasses); 98 | 99 | // start timeout to remove element after animation finished 100 | setTimeout(() => { 101 | if (this.cancelLeaveAnimation) { 102 | return; 103 | } 104 | this.viewContainer.clear(); 105 | this.viewRef = null; 106 | }, duration); 107 | }); 108 | } 109 | } 110 | 111 | private viewRef: EmbeddedViewRef | null = null; 112 | private cancelLeaveAnimation = true; 113 | 114 | private enterClasses: string[] = []; 115 | private enterFromClasses: string[] = []; 116 | private enterToClasses: string[] = []; 117 | 118 | private leaveClasses: string[] = []; 119 | private leaveFromClasses: string[] = []; 120 | private leaveToClasses: string[] = []; 121 | 122 | private initial = true; 123 | 124 | constructor( 125 | private viewContainer: ViewContainerRef, 126 | private templateRef: TemplateRef, 127 | private changeDetection: ChangeDetectorRef 128 | ) {} 129 | } 130 | 131 | @NgModule({ 132 | imports: [], 133 | exports: [TransitionDirective], 134 | declarations: [TransitionDirective], 135 | providers: [], 136 | }) 137 | export class TransitionModule {} 138 | 139 | function splitClasses(classes: string) { 140 | return classes.split(' ').filter((className) => className.trim().length > 1); 141 | } 142 | 143 | function getDuration(element: HTMLElement) { 144 | // Safari returns a comma separated list of values, so let's sort them and take the highest value. 145 | let { transitionDuration, transitionDelay } = getComputedStyle(element); 146 | 147 | let [durationMs, delayMs] = [transitionDuration, transitionDelay].map( 148 | (value) => { 149 | let [resolvedValue = 0] = value 150 | .split(',') 151 | // Remove falsy we can't work with 152 | .filter(Boolean) 153 | // Values are returned as `0.3s` or `75ms` 154 | .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) 155 | .sort((a, z) => z - a); 156 | 157 | return resolvedValue; 158 | } 159 | ); 160 | 161 | return durationMs + delayMs; 162 | } 163 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/lib/transition/transition2.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, Input, NgModule } from '@angular/core'; 2 | 3 | @Directive({ 4 | // eslint-disable-next-line @angular-eslint/directive-selector 5 | selector: 'transition', 6 | }) 7 | export class Transition2Directive { 8 | private readonly element!: HTMLElement; 9 | 10 | private cancelLeaveAnimation = false; 11 | private ignoreRemoveMutation = false; 12 | 13 | private enterClasses: string[] = []; 14 | private enterFromClasses: string[] = []; 15 | private enterToClasses: string[] = []; 16 | 17 | private leaveClasses: string[] = []; 18 | private leaveFromClasses: string[] = []; 19 | private leaveToClasses: string[] = []; 20 | 21 | private initial = true; 22 | 23 | @Input() 24 | set enter(classes: string) { 25 | this.enterClasses = splitClasses(classes); 26 | } 27 | 28 | @Input() 29 | set enterFrom(classes: string) { 30 | this.enterFromClasses = splitClasses(classes); 31 | } 32 | 33 | @Input() 34 | set enterTo(classes: string) { 35 | this.enterToClasses = splitClasses(classes); 36 | } 37 | 38 | @Input() 39 | set leave(classes: string) { 40 | this.leaveClasses = splitClasses(classes); 41 | } 42 | 43 | @Input() 44 | set leaveFrom(classes: string) { 45 | this.leaveFromClasses = splitClasses(classes); 46 | } 47 | 48 | @Input() 49 | set leaveTo(classes: string) { 50 | this.leaveToClasses = splitClasses(classes); 51 | } 52 | 53 | observer = new MutationObserver((mutations) => { 54 | const addedNodes = mutations[0].addedNodes; 55 | const removedNodes = mutations[0].removedNodes; 56 | 57 | if (addedNodes.length > 0) { 58 | this.ignoreRemoveMutation = false; 59 | const element = addedNodes[0] as HTMLElement; 60 | if (!(element instanceof HTMLElement)) { 61 | return; 62 | } 63 | 64 | // prepare animation 65 | element.classList.add(...this.enterFromClasses); 66 | requestAnimationFrame(() => { 67 | // start animation 68 | element.classList.remove(...this.enterFromClasses); 69 | element.classList.add(...this.enterClasses, ...this.enterToClasses); 70 | }); 71 | } 72 | 73 | if (removedNodes.length > 0 && !this.ignoreRemoveMutation) { 74 | const removedNode = removedNodes[0] as HTMLElement; 75 | const element = this.element.appendChild(removedNode); 76 | 77 | // prepare animation by removing enter-classes and add leave- and leaveFrom-classes. 78 | element.classList.remove(...this.enterClasses, ...this.enterToClasses); 79 | element.classList.add(...this.leaveClasses, ...this.leaveFromClasses); 80 | const duration = getDuration(element); 81 | setTimeout(() => { 82 | // start animation by removing from- and add to-classes 83 | element.classList.remove(...this.leaveFromClasses); 84 | element.classList.add(...this.leaveToClasses); 85 | 86 | // start timeout to remove element after animation finished 87 | setTimeout(() => { 88 | if (this.cancelLeaveAnimation) { 89 | return; 90 | } 91 | this.ignoreRemoveMutation = true; 92 | this.element.removeChild(removedNode); 93 | }, duration); 94 | }); 95 | } 96 | }); 97 | 98 | constructor(private elementRef: ElementRef) { 99 | this.element = this.elementRef.nativeElement; 100 | this.observer.observe(this.element, { 101 | attributes: true, 102 | childList: true, 103 | characterData: true, 104 | }); 105 | } 106 | } 107 | 108 | function splitClasses(classes: string) { 109 | return classes.split(' ').filter((className) => className.trim().length > 1); 110 | } 111 | 112 | function getDuration(element: HTMLElement) { 113 | // Safari returns a comma separated list of values, so let's sort them and take the highest value. 114 | let { transitionDuration, transitionDelay } = getComputedStyle(element); 115 | 116 | let [durationMs, delayMs] = [transitionDuration, transitionDelay].map( 117 | (value) => { 118 | let [resolvedValue = 0] = value 119 | .split(',') 120 | // Remove falsy we can't work with 121 | .filter(Boolean) 122 | // Values are returned as `0.3s` or `75ms` 123 | .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) 124 | .sort((a, z) => z - a); 125 | 126 | return resolvedValue; 127 | } 128 | ); 129 | 130 | return durationMs + delayMs; 131 | } 132 | 133 | function flush(element: HTMLElement) { 134 | // See https://stackoverflow.com/a/24195559 why this is needed 135 | window.getComputedStyle(element).opacity; 136 | } 137 | 138 | @NgModule({ 139 | imports: [], 140 | exports: [Transition2Directive], 141 | declarations: [Transition2Directive], 142 | providers: [], 143 | }) 144 | export class TransitionModule2 {} 145 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/lib/util.ts: -------------------------------------------------------------------------------- 1 | let id = 1; 2 | export const generateId = () => id++; 3 | /* for testing only */ 4 | export const resetIdCounter = () => (id = 1); 5 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/menu/menu'; 2 | export * from './lib/listbox/listbox'; 3 | export * from './lib/transition/transition'; 4 | -------------------------------------------------------------------------------- /projects/headlessui-angular/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { 7 | BrowserDynamicTestingModule, 8 | platformBrowserDynamicTesting, 9 | } from '@angular/platform-browser-dynamic/testing'; 10 | 11 | declare const require: { 12 | context( 13 | path: string, 14 | deep?: boolean, 15 | filter?: RegExp 16 | ): { 17 | (id: string): T; 18 | keys(): string[]; 19 | }; 20 | }; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting(), 26 | { teardown: { destroyAfterEach: true } } 27 | ); 28 | 29 | // Then we find all the tests. 30 | const context = require.context('./', true, /\.spec\.ts$/); 31 | // And load the modules. 32 | context.keys().map(context); 33 | -------------------------------------------------------------------------------- /projects/headlessui-angular/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [] 10 | }, 11 | "exclude": ["src/test.ts", "**/*.spec.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /projects/headlessui-angular/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/headlessui-angular/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "files": ["src/test.ts"], 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | echo "update package version" 2 | npm version patch --no-git-tag-version 3 | new_version=$(npm pkg get version | sed 's/"//g') 4 | npm --prefix projects/headlessui-angular version --no-git-tag-version "$new_version" 5 | 6 | echo "build and test" 7 | npm run build 8 | npm run build:demo 9 | npm run test:ci 10 | 11 | echo "commit and tag" 12 | git commit -am v"$new_version" 13 | git tag v"$new_version" 14 | 15 | echo "publish" 16 | cd dist/headlessui-angular 17 | npm login 18 | npm publish 19 | 20 | -------------------------------------------------------------------------------- /scripts/code-samples.mjs: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { promises as fs } from "fs"; 4 | import Prism from "prismjs"; 5 | 6 | const rootPath = "./projects/demo/src/app/demo-components"; 7 | 8 | const components = (await fs.readdir(rootPath, { withFileTypes: true })) 9 | .filter((item) => item.isDirectory()) 10 | .map((item) => item.name); 11 | 12 | const lines = await Promise.all( 13 | components.map(async (component) => { 14 | const variableName = component.replace(/-[a-z]/g, (x) => 15 | x[1].toUpperCase() 16 | ); 17 | 18 | const htmlFilePath = `${rootPath}/${component}/${component}.component.html`; 19 | const tsFilePath = `${rootPath}/${component}/${component}.component.ts`; 20 | const htmlFileSource = await fs.readFile(htmlFilePath, "utf8"); 21 | const tsFileSource = await fs.readFile(tsFilePath, "utf8"); 22 | const htmlFileSourceFormatted = highlight(htmlFileSource, "html"); 23 | const tsFileSourceFormatted = highlight(tsFileSource, "ts"); 24 | const htmlLine = `export const ${variableName}Html = \`${htmlFileSourceFormatted}\``; 25 | const tsLine = `export const ${variableName}Typescript = \`${tsFileSourceFormatted}\``; 26 | return [htmlLine, tsLine]; 27 | }) 28 | ); 29 | 30 | await fs.writeFile( 31 | "./projects/demo/src/app/formattedSources.ts", 32 | lines.flat().join("\n") 33 | ); 34 | 35 | function highlight(source, type) { 36 | let lang = Prism.languages.javascript; 37 | if (type === "html") { 38 | lang = Prism.languages.html; 39 | } 40 | 41 | return Prism.highlight(source, lang, type) 42 | .replaceAll(/\r\n|\r|\n/g, "\\n") 43 | .replaceAll("`", "\\`"); 44 | } 45 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./projects/demo/src/**/*.{js,ts,html}"], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "paths": { 13 | "headlessui-angular": [ 14 | "dist/headlessui-angular/headlessui-angular", 15 | "dist/headlessui-angular" 16 | ] 17 | }, 18 | "noFallthroughCasesInSwitch": true, 19 | "sourceMap": true, 20 | "declaration": false, 21 | "downlevelIteration": true, 22 | "experimentalDecorators": true, 23 | "moduleResolution": "node", 24 | "importHelpers": true, 25 | "target": "es2017", 26 | "module": "es2020", 27 | "lib": ["es2018", "dom"] 28 | }, 29 | "angularCompilerOptions": { 30 | "strictInjectionParameters": true, 31 | "strictInputAccessModifiers": true, 32 | "strictTemplates": true 33 | } 34 | } 35 | --------------------------------------------------------------------------------