├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── app │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── app.routes.ts │ └── listbox.ts ├── index.html ├── main.ts └── styles.css ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.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 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2024 Google LLC. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TrySignals 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.0-rc.1. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page. 28 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "try-signals": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:application", 15 | "options": { 16 | "outputPath": "dist/try-signals", 17 | "index": "src/index.html", 18 | "browser": "src/main.ts", 19 | "polyfills": [ 20 | "zone.js" 21 | ], 22 | "tsConfig": "tsconfig.app.json", 23 | "assets": [ 24 | { 25 | "glob": "**/*", 26 | "input": "public" 27 | } 28 | ], 29 | "styles": [ 30 | "src/styles.css" 31 | ], 32 | "scripts": [] 33 | }, 34 | "configurations": { 35 | "production": { 36 | "budgets": [ 37 | { 38 | "type": "initial", 39 | "maximumWarning": "500kb", 40 | "maximumError": "1mb" 41 | }, 42 | { 43 | "type": "anyComponentStyle", 44 | "maximumWarning": "2kb", 45 | "maximumError": "4kb" 46 | } 47 | ], 48 | "outputHashing": "all" 49 | }, 50 | "development": { 51 | "optimization": false, 52 | "extractLicenses": false, 53 | "sourceMap": true 54 | } 55 | }, 56 | "defaultConfiguration": "production" 57 | }, 58 | "serve": { 59 | "builder": "@angular-devkit/build-angular:dev-server", 60 | "configurations": { 61 | "production": { 62 | "buildTarget": "try-signals:build:production" 63 | }, 64 | "development": { 65 | "buildTarget": "try-signals:build:development" 66 | } 67 | }, 68 | "defaultConfiguration": "development" 69 | }, 70 | "extract-i18n": { 71 | "builder": "@angular-devkit/build-angular:extract-i18n" 72 | }, 73 | "test": { 74 | "builder": "@angular-devkit/build-angular:karma", 75 | "options": { 76 | "polyfills": [ 77 | "zone.js", 78 | "zone.js/testing" 79 | ], 80 | "tsConfig": "tsconfig.spec.json", 81 | "assets": [ 82 | { 83 | "glob": "**/*", 84 | "input": "public" 85 | } 86 | ], 87 | "styles": [ 88 | "src/styles.css" 89 | ], 90 | "scripts": [] 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "try-signals", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "watch": "ng build --watch --configuration development", 9 | "test": "ng test" 10 | }, 11 | "private": true, 12 | "dependencies": { 13 | "@angular/animations": "^18.0.0-next.0", 14 | "@angular/common": "^18.0.0-next.0", 15 | "@angular/compiler": "^18.0.0-next.0", 16 | "@angular/core": "^18.0.0-next.0", 17 | "@angular/forms": "^18.0.0-next.0", 18 | "@angular/platform-browser": "^18.0.0-next.0", 19 | "@angular/platform-browser-dynamic": "^18.0.0-next.0", 20 | "@angular/router": "^18.0.0-next.0", 21 | "rxjs": "~7.8.0", 22 | "tslib": "^2.3.0", 23 | "zone.js": "~0.14.3" 24 | }, 25 | "devDependencies": { 26 | "@angular-devkit/build-angular": "^18.0.0-rc.1", 27 | "@angular/cli": "^18.0.0-rc.1", 28 | "@angular/compiler-cli": "^18.0.0-next.0", 29 | "@types/jasmine": "~5.1.0", 30 | "jasmine-core": "~5.1.0", 31 | "karma": "~6.4.0", 32 | "karma-chrome-launcher": "~3.2.0", 33 | "karma-coverage": "~2.2.0", 34 | "karma-jasmine": "~5.1.0", 35 | "karma-jasmine-html-reporter": "~2.1.0", 36 | "typescript": "~5.4.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jelbourn/signal-listbox-demo/b7ba115c416c142540ceebc8f227ee593cdc217d/public/favicon.ico -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jelbourn/signal-listbox-demo/b7ba115c416c142540ceebc8f227ee593cdc217d/src/app/app.component.css -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Hello, {{ title }} 11 | 12 | 13 | 18 | Onion 19 | Tomato 20 | Garlic 21 | Pepper 22 | 23 | 24 | 25 | Listbox value: {{listbox.value()}} 26 | selectedTopping: {{selectedTopping}} 27 | 28 | Change orientation 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(async () => { 6 | await TestBed.configureTestingModule({ 7 | imports: [AppComponent], 8 | }).compileComponents(); 9 | }); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | 17 | it(`should have the 'try-signals' title`, () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app.title).toEqual('try-signals'); 21 | }); 22 | 23 | it('should render title', () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | fixture.detectChanges(); 26 | const compiled = fixture.nativeElement as HTMLElement; 27 | expect(compiled.querySelector('h1')?.textContent).toContain('Hello, try-signals'); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, signal} from '@angular/core'; 2 | import {RouterOutlet} from '@angular/router'; 3 | import {Listbox, Option, Orientation} from './listbox'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | standalone: true, 8 | imports: [RouterOutlet, Listbox, Option], 9 | templateUrl: './app.component.html', 10 | styleUrl: './app.component.css' 11 | }) 12 | export class AppComponent { 13 | title = 'try-signals'; 14 | selectedTopping: string = 'onion'; 15 | protected orientation = signal('horizontal'); 16 | 17 | logValueChange(newValue: string | undefined) { 18 | console.log(newValue); 19 | } 20 | 21 | logOrientationChange(newOrientation: Orientation) { 22 | console.log(newOrientation); 23 | } 24 | 25 | toggleOrientation() { 26 | this.orientation.update(oldValue => 27 | oldValue === 'horizontal' ? 28 | 'vertical' : 29 | 'horizontal'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 2 | import { provideRouter } from '@angular/router'; 3 | 4 | import { routes } from './app.routes'; 5 | 6 | export const appConfig: ApplicationConfig = { 7 | providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] 8 | }; 9 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | export const routes: Routes = []; 4 | -------------------------------------------------------------------------------- /src/app/listbox.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | computed, 4 | contentChildren, 5 | effect, 6 | inject, 7 | input, 8 | model, 9 | output, signal 10 | } from '@angular/core'; 11 | 12 | export type Orientation = 'vertical' | 'horizontal'; 13 | 14 | @Component({ 15 | selector: 'jm-listbox', 16 | template: ``, 17 | standalone: true, 18 | host: { 19 | 'role': 'listbox', 20 | '[tabIndex]': 'disabled() || activeOption() ? -1 : 0', 21 | '(focus)' : 'activateOption()', 22 | }, 23 | styles: ` 24 | :host { 25 | display: flex; 26 | flex-direction: column; 27 | border: 1px solid black; 28 | padding: 4px; 29 | } 30 | 31 | :host:focus { 32 | outline: 2px solid darkblue; 33 | } 34 | `, 35 | }) 36 | export class Listbox { 37 | readonly value = model(); 38 | readonly disabled = input(false); 39 | readonly orientation = input('horizontal'); 40 | 41 | readonly options = contentChildren(Option); 42 | activeOption = signal | undefined>(undefined); 43 | 44 | readonly orientationChange = output(); 45 | 46 | constructor() { 47 | effect(() => { 48 | this.orientationChange.emit(this.orientation()); 49 | }); 50 | } 51 | 52 | // TODO: keyboard handling 53 | 54 | activateOption() { 55 | const selectedOption = this.options().find(o => o.isSelected()); 56 | if (selectedOption) { 57 | this.activeOption.set(selectedOption); 58 | } else { 59 | const firstEnabledOption = this.options().find(o => !o.disabled()); 60 | this.activeOption.set(firstEnabledOption); 61 | } 62 | 63 | // TODO: focus the active option 64 | } 65 | } 66 | 67 | 68 | @Component({ 69 | selector: 'jm-option', 70 | template: ` 71 | 72 | 73 | @if (isSelected()) { 74 | ✓ 75 | } 76 | `, 77 | standalone: true, 78 | host: { 79 | 'role': 'option', 80 | '[attr.aria-disabled]': 'isDisabled()', 81 | '[attr.aria-selected]': 'isSelected()', 82 | '[tabIndex]': 'isActive() ? 0 : -1', 83 | '(click)': 'select()', 84 | }, 85 | styles: ` 86 | :host { 87 | display: block; 88 | border: 1px solid black; 89 | margin: 4px; 90 | padding: 4px; 91 | cursor: pointer; 92 | } 93 | 94 | :host[aria-disabled="true"] { 95 | color: lightgray; 96 | border-color: lightgray; 97 | cursor: default; 98 | } 99 | 100 | :host:focus { 101 | outline: 1px dashed darkblue; 102 | } 103 | `, 104 | }) 105 | export class Option { 106 | private listbox = inject>(Listbox); 107 | 108 | readonly value = input.required(); 109 | readonly disabled = input(false); 110 | 111 | protected readonly isDisabled = 112 | computed(() => this.listbox.disabled() || this.disabled()); 113 | readonly isSelected = 114 | computed(() => this.value() === this.listbox.value()); 115 | readonly isActive = computed(() => this === this.listbox.activeOption()); 116 | 117 | protected select() { 118 | if (!this.isDisabled()) { 119 | this.listbox.value.set(this.value()); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TrySignals 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { appConfig } from './app/app.config'; 3 | import { AppComponent } from './app/app.component'; 4 | 5 | bootstrapApplication(AppComponent, appConfig) 6 | .catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /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": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "bundler", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022", 20 | "useDefineForClassFields": false, 21 | "lib": [ 22 | "ES2022", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /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": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------
Listbox value: {{listbox.value()}}
selectedTopping: {{selectedTopping}}