├── .prettierrc.json
├── projects
├── demo
│ ├── src
│ │ ├── assets
│ │ │ └── .gitkeep
│ │ ├── environments
│ │ │ ├── environment.prod.ts
│ │ │ └── environment.ts
│ │ ├── favicon.ico
│ │ ├── styles.scss
│ │ ├── app
│ │ │ ├── demo-components
│ │ │ │ ├── styled-menu
│ │ │ │ │ ├── styled-menu.component.ts
│ │ │ │ │ └── styled-menu.component.html
│ │ │ │ ├── unstyled-menu
│ │ │ │ │ ├── unstyled-menu.component.ts
│ │ │ │ │ └── unstyled-menu.component.html
│ │ │ │ ├── transition
│ │ │ │ │ ├── transition.component.ts
│ │ │ │ │ └── transition.component.html
│ │ │ │ ├── transition2
│ │ │ │ │ ├── transition2.component.ts
│ │ │ │ │ └── transition2.component.html
│ │ │ │ ├── styled-menu-cdk
│ │ │ │ │ ├── styled-menu-cdk.component.ts
│ │ │ │ │ └── styled-menu-cdk.component.html
│ │ │ │ ├── unstyled-select
│ │ │ │ │ ├── unstyled-select.component.html
│ │ │ │ │ └── unstyled-select.component.ts
│ │ │ │ └── styled-select
│ │ │ │ │ ├── styled-select.component.ts
│ │ │ │ │ └── styled-select.component.html
│ │ │ ├── demo-container
│ │ │ │ ├── demo-container.component.ts
│ │ │ │ └── demo-container.component.html
│ │ │ ├── app.component.ts
│ │ │ ├── app.module.ts
│ │ │ ├── app.component.html
│ │ │ └── formattedSources.ts
│ │ ├── main.ts
│ │ ├── index.html
│ │ ├── test.ts
│ │ └── polyfills.ts
│ ├── tsconfig.app.json
│ ├── tsconfig.spec.json
│ ├── .browserslistrc
│ ├── .eslintrc.json
│ └── karma.conf.js
└── headlessui-angular
│ ├── src
│ ├── lib
│ │ ├── util.ts
│ │ ├── transition
│ │ │ ├── transition2.ts
│ │ │ └── transition.ts
│ │ ├── menu
│ │ │ ├── menu.spec.ts
│ │ │ └── menu.ts
│ │ └── listbox
│ │ │ └── listbox.ts
│ ├── public-api.ts
│ └── test.ts
│ ├── ng-package.json
│ ├── tsconfig.lib.prod.json
│ ├── tsconfig.spec.json
│ ├── tsconfig.lib.json
│ ├── package.json
│ ├── .eslintrc.json
│ ├── karma.conf.js
│ └── karma-ci.conf.js
├── .gitpod.yml
├── postcss.config.js
├── tailwind.config.js
├── .editorconfig
├── publish.sh
├── .eslintrc.json
├── TODO.md
├── .gitignore
├── .prettierignore
├── .github
└── workflows
│ └── ci.yml
├── tsconfig.json
├── scripts
└── code-samples.mjs
├── package.json
├── README.md
└── angular.json
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/projects/demo/src/assets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitpod.yml:
--------------------------------------------------------------------------------
1 | tasks:
2 | - name: frontend
3 | init: npm install
4 | command: npm run serve:gitpod
--------------------------------------------------------------------------------
/projects/demo/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true,
3 | };
4 |
--------------------------------------------------------------------------------
/projects/demo/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ibirrer/headlessui-angular/HEAD/projects/demo/src/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/projects/demo/src/styles.scss:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import "~@angular/cdk/overlay-prebuilt.css";
6 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Demo
6 |
7 |
8 |
9 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/projects/demo/src/app/demo-components/unstyled-menu/unstyled-menu.component.html:
--------------------------------------------------------------------------------
1 |
25 |
--------------------------------------------------------------------------------
/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/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/.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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/.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/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/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/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/transition/transition.component.html:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
18 |
19 |
23 | Toggle
24 |
25 |
26 | {{ shown ? "shown" : "hidden" }}
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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-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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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-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 |
--------------------------------------------------------------------------------
/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 |
69 | ```
70 |
71 | ### Styling the active item
72 |
73 | This is a headless component so there are no styles included by default. Instead, the components expose useful information via let expressions that you can use to apply the styles you'd like to apply yourself.
74 |
75 | To style the active `hlMenuItem` you can read the `active` state, which tells you whether that menu item is the item that is currently focused via the mouse or keyboard.
76 |
77 | You can use this state to conditionally apply whatever active/focus styles you like, for instance a blue background like is typical in most operating systems.
78 |
79 | ```html
80 |
81 |
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/demo/src/app/formattedSources.ts:
--------------------------------------------------------------------------------
1 | export const styledMenuHtml = `< div class = " flex justify-center" > \n < div hlMenu [static] = " true" #menu = " hlMenu" class = " relative" > \n < span class = " rounded-md shadow-sm" > \n < button \n hlMenuButton \n class = " inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800" \n > \n < span> Options</ span> \n < svg class = " w-5 h-5 ml-2 -mr-1" viewBox = " 0 0 20 20" fill = " currentColor" > \n < path \n fill-rule = " evenodd" \n d = " M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" \n clip-rule = " evenodd" \n > </ path> \n </ svg> \n </ button> \n </ span> \n\n < div \n *hlTransition = " \n menu.expanded;\n enter: ' transition ease-out duration-100' ;\n enterFrom: ' transform opacity-0 scale-95' ;\n enterTo: ' transform opacity-100 scale-100' ;\n leave: ' transition ease-in duration-75' ;\n leaveFrom: ' transform opacity-100 scale-100' ;\n leaveTo: ' transform opacity-0 scale-95' \n " \n > \n < div \n *hlMenuItems \n class = " absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" \n > \n < div class = " px-4 py-3" > \n < p class = " text-sm leading-5" > Signed in as</ p> \n < p class = " text-sm font-medium leading-5 text-gray-900 truncate" > \n tom@example.com\n </ p> \n </ div> \n < div class = " py-1" > \n < a \n *hlMenuItem = " let item" \n href = " ./#account-settings" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n Account settings\n </ a> \n\n < a \n *hlMenuItem = " let item" \n href = " ./#support" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n Support\n </ a> \n\n < span \n *hlMenuItem = " let item; disabled: true" \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left text-gray-700 cursor-not-allowed opacity-50" \n > \n New feature (soon)\n </ span> \n\n < a \n *hlMenuItem = " let item" \n href = " ./#license" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n License\n </ a> \n </ div> \n\n < div class = " py-1" > \n < a \n *hlMenuItem = " let item" \n href = " ./#sign-out" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n Sign out\n </ a> \n </ div> \n </ div> \n </ div> \n </ div> \n</ div> \n`
2 | export const styledMenuTypescript = `import { Component } from '@angular/core' ; \n\n@Component ( { \n selector : 'app-styled-menu' , \n templateUrl : 'styled-menu.component.html' , \n} ) \nexport class StyledMenuComponent { } \n`
3 | export const styledMenuCdkHtml = `\n< div class = " relative flex justify-center" > \n < div \n hlMenu \n [static] = " true" \n #menu = " hlMenu" \n cdkOverlayOrigin \n #trigger = " cdkOverlayOrigin" \n > \n < span class = " rounded-md shadow-sm" > \n < button \n hlMenuButton \n class = " inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800" \n > \n < span> Options</ span> \n < svg class = " w-5 h-5 ml-2 -mr-1" viewBox = " 0 0 20 20" fill = " currentColor" > \n < path \n fill-rule = " evenodd" \n d = " M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" \n clip-rule = " evenodd" \n > </ path> \n </ svg> \n </ button> \n </ span> \n < ng-template \n cdkConnectedOverlay \n [cdkConnectedOverlayOrigin] = " trigger" \n [cdkConnectedOverlayOpen] = " menu.expanded" \n > \n < div \n id = " m1" \n *hlMenuItems \n class = " w-56 mt-2 mb-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" \n > \n < div class = " px-4 py-3" > \n < p class = " text-sm leading-5" > Signed in as</ p> \n < p class = " text-sm font-medium leading-5 text-gray-900 truncate" > \n tom@example.com\n </ p> \n </ div> \n < div class = " py-1" > \n < a \n *hlMenuItem = " let item" \n href = " ./#account-settings" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n Account settings\n </ a> \n\n < a \n *hlMenuItem = " let item" \n href = " ./#support" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n Support\n </ a> \n\n < span \n *hlMenuItem = " let item; disabled: true" \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left text-gray-700 cursor-not-allowed opacity-50" \n > \n New feature (soon)\n </ span> \n\n < a \n *hlMenuItem = " let item" \n href = " ./#license" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n License\n </ a> \n </ div> \n\n < div class = " py-1" > \n < a \n *hlMenuItem = " let item" \n href = " ./#sign-out" \n [class] = " \n item.active ? ' bg-gray-100 text-gray-900' : ' text-gray-700' \n " \n class = " flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" \n > \n Sign out\n </ a> \n </ div> \n </ div> \n </ ng-template> \n </ div> \n</ div> \n`
4 | export const styledMenuCdkTypescript = `import { Component } from '@angular/core' ; \nimport { animate, style, transition, trigger } from '@angular/animations' ; \n\n@Component ( { \n selector : 'app-styled-menu-cdk' , \n animations : [ \n trigger ( 'toggleAnimation' , [ \n transition ( ':enter' , [ \n style ( { opacity : 0 , transform : 'scale(0.95)' } ) , \n animate ( '100ms ease-out' , style ( { opacity : 1 , transform : 'scale(1)' } ) ) , \n ] ) , \n transition ( ':leave' , [ \n animate ( '75ms' , style ( { opacity : 0 , transform : 'scale(0.95)' } ) ) , \n ] ) , \n ] ) , \n ] , \n templateUrl : 'styled-menu-cdk.component.html' , \n} ) \nexport class StyledMenuCdkComponent { } \n`
5 | export const styledSelectHtml = `< div \n class = " relative flex justify-center" \n hlListbox \n [value] = " selectedPerson" \n (valueChange) = " setSelectedPerson($event)" \n> \n < div class = " relative w-72" > \n < button \n hlListboxButton \n class = " relative w-full cursor-default rounded-md bg-white py-2 pl-3 pr-10 text-left border border-gray-300 focus:outline-none focus-visible:border-gray-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-blue-300 sm:text-sm" \n > \n < span class = " block truncate" > {{ selectedPerson?.name }}</ span> \n < span \n class = " pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2" \n > \n < ng-icon \n name = " hero-selector" \n class = " text-xl text-gray-400" \n aria-hidden = " true" \n > </ ng-icon> \n </ span> \n </ button> \n < ul \n *hlListboxOptions \n @toggleAnimation \n class = " absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" \n > \n < ng-container *ngFor = " let person of people" > \n < li \n *hlListboxOption = " \n let option;\n value: person;\n disabled: person.unavailable\n " \n class = " relative cursor-default select-none py-2 pl-10 pr-4" \n [class] = " \n option.active ? ' bg-gray-100 text-gray-900' : ' text-gray-900' \n " \n > \n < span \n class = " block truncate" \n [class.text-gray-300] = " person.unavailable" \n > {{ person.name }}</ span \n > \n\n < span \n *ngIf = " option.selected" \n class = " absolute inset-y-0 left-0 flex items-center pl-3 text-gray-600" \n > \n < ng-icon \n name = " hero-check" \n class = " text-xl text-gray-800" \n aria-hidden = " true" \n > </ ng-icon> \n </ span> \n </ li> \n </ ng-container> \n </ ul> \n </ div> \n</ div> \n`
6 | export const styledSelectTypescript = `import { Component } from '@angular/core' ; \nimport { animate, style, transition, trigger } from '@angular/animations' ; \n\n@Component ( { \n selector : 'app-styled-select' , \n animations : [ \n trigger ( 'toggleAnimation' , [ \n transition ( ':enter' , [ \n style ( { opacity : 0 , transform : 'scale(0.95)' } ) , \n animate ( '100ms ease-out' , style ( { opacity : 1 , transform : 'scale(1)' } ) ) , \n ] ) , \n transition ( ':leave' , [ \n animate ( '75ms' , style ( { opacity : 0 , transform : 'scale(0.95)' } ) ) , \n ] ) , \n ] ) , \n ] , \n templateUrl : 'styled-select.component.html' , \n} ) \nexport class StyledSelectComponent { \n people : Person[ ] = [ \n { id : 1 , name : 'Durward Reynolds' , unavailable : false } , \n { id : 2 , name : 'Kenton Towne' , unavailable : false } , \n { id : 3 , name : 'Therese Wunsch' , unavailable : false } , \n { id : 4 , name : 'Benedict Kessler' , unavailable : true } , \n { id : 5 , name : 'Katelyn Rohan' , unavailable : false } , \n ] ; \n\n selectedPerson : Person | null = this . people[ 0 ] ; \n\n setSelectedPerson ( person : Person | null ) { \n this . selectedPerson = person; \n } \n} \n\ninterface Person { \n id : number; \n name : string; \n unavailable : boolean; \n} \n`
7 | export const transitionHtml = `< div> \n < div class = " w-40 h-40 mx-auto" > \n < div \n *hlTransition = " \n shown;\n enter: ' transform transition duration-[400ms]' ;\n enterFrom: ' opacity-0 rotate-[-120deg] scale-50' ;\n enterTo: ' opacity-100 rotate-0 scale-100' ;\n leave: ' transform duration-200 transition ease-in-out' ;\n leaveFrom: ' opacity-100 rotate-0 scale-100' ;\n leaveTo: ' opacity-0 scale-95' \n " \n class = " p-2 bg-yellow-500 text-white h-full rounded-xl shadow-lg" \n > </ div> \n </ div> \n\n < div class = " mt-12 flex justify-center" > \n < div> \n < button \n class = " rounded-md border border-gray-300 bg-white px-6 py-2 text-gray-700 shadow-sm hover:bg-gray-50" \n (click) = " toggle()" \n > \n Toggle\n </ button> \n < div class = " text-center mt-2 text-gray-400" > \n {{ shown ? "shown" : "hidden" }}\n </ div> \n </ div> \n </ div> \n</ div> \n`
8 | export const transitionTypescript = `import { ChangeDetectionStrategy, Component } from '@angular/core' ; \n\n@Component ( { \n selector : 'app-transition' , \n templateUrl : 'transition.component.html' , \n changeDetection : ChangeDetectionStrategy. OnPush, \n} ) \nexport class TransitionComponent { \n shown = true ; \n toggle ( ) { \n this . shown = ! this . shown; \n } \n} \n`
9 | export const unstyledMenuHtml = `< div hlMenu > \n < button hlMenuButton > More</ button> \n < div *hlMenuItems > \n < a \n *hlMenuItem = " let item" \n class = " block" \n [class.bg-blue-500] = " item.active" \n href = " ./#account-settings" \n > \n Account settings\n </ a> \n < a \n *hlMenuItem = " let item" \n class = " block" \n [class.bg-blue-500] = " item.active" \n href = " ./#account-settings" \n > \n Documentation\n </ a> \n < span *hlMenuItem = " let item; disabled: true" class = " block" > \n Invite a friend (coming soon!)\n </ span> \n </ div> \n</ div> \n`
10 | export const unstyledMenuTypescript = `import { Component } from '@angular/core' ; \n\n@Component ( { \n selector : 'app-unstyled-menu' , \n templateUrl : 'unstyled-menu.component.html' , \n} ) \nexport class UnstyledMenuComponent { } \n`
11 | export const unstyledSelectHtml = `< div \n hlListbox \n [value] = " selectedPerson" \n (valueChange) = " setSelectedPerson($event)" \n> \n < button hlListboxButton > \n {{ selectedPerson?.name }}\n </ button> \n < ul *hlListboxOptions > \n < ng-container *ngFor = " let person of people" > \n < li \n *hlListboxOption = " \n let option;\n value: person;\n disabled: person.unavailable\n " \n [class.bg-blue-500] = " option.active" \n > \n < span class = " w-5 inline-block" > \n < ng-container *ngIf = " option.selected" > ✔</ ng-container> \n </ span> \n {{ person.name }}\n </ li> \n </ ng-container> \n </ ul> \n</ div> \n`
12 | export const unstyledSelectTypescript = `import { Component } from '@angular/core' ; \n\n@Component ( { \n selector : 'app-unstyled-select' , \n templateUrl : 'unstyled-select.component.html' , \n} ) \nexport class UnstyledSelectComponent { \n people : Person[ ] = [ \n { id : 1 , name : 'Durward Reynolds' , unavailable : false } , \n { id : 2 , name : 'Kenton Towne' , unavailable : false } , \n { id : 3 , name : 'Therese Wunsch' , unavailable : false } , \n { id : 4 , name : 'Benedict Kessler' , unavailable : true } , \n { id : 5 , name : 'Katelyn Rohan' , unavailable : false } , \n ] ; \n\n selectedPerson : Person | null = this . people[ 0 ] ; \n\n setSelectedPerson ( person : Person | null ) { \n this . selectedPerson = person; \n } \n} \n\ninterface Person { \n id : number; \n name : string; \n unavailable : boolean; \n} \n`
--------------------------------------------------------------------------------