├── .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 |
81 |
More
82 |
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 |
Trigger
103 |
104 |
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 |
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 |
78 |
79 |
83 | Transition 2
84 |
85 |
86 |
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 |
15 | Options
16 |
17 |
22 |
23 |
24 |
25 |
30 |
36 |
37 |
Signed in as
38 |
39 | tom@example.com
40 |
41 |
42 |
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 |
8 | Options
9 |
10 |
15 |
16 |
17 |
18 |
19 |
30 |
34 |
35 |
Signed in as
36 |
37 | tom@example.com
38 |
39 |
40 |
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 |
12 | {{ selectedPerson?.name }}
13 |
16 |
21 |
22 |
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 |
16 |
17 |
18 |
19 |
23 | Toggle
24 |
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 |
16 |
17 |
18 |
19 |
23 | Toggle
24 |
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 |
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 |
7 | {{ selectedPerson?.name }}
8 |
9 |
10 |
11 |
19 |
20 | ✔
21 |
22 | {{ person.name }}
23 |
24 |
25 |
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 |
15 | Preview
16 |
17 |
18 |
26 | HTML
27 |
28 |
29 |
37 | TS
38 |
39 |
40 |
41 |
42 |
48 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
65 |
66 |
67 |
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
} ) \n
export class StyledMenuComponent { } \n`
3 | export const styledMenuCdkHtml = `\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' ; \n
import { 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
} ) \n
export 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' ; \n
import { 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
} ) \n
export 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\n
interface 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
} ) \n
export 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
} ) \n
export 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
} ) \n
export 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\n
interface 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 |
Trigger
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 |
--------------------------------------------------------------------------------