feat(color): replace native color input with swatch picker

Replace the browser's native <input type="color"> with a custom color
picker component showing a 5x4 grid of preset swatches and a custom
color button that opens the native picker.

- New standalone InputColorPickerComponent with overlay panel
- 19 preset colors in rainbow hue order + 1 custom color button
- Formly ColorInputComponent wraps the new picker
- dialog-create-tag uses the new picker directly
- Escape key and backdrop click to close overlay
- Fixed positioning to avoid dialog overflow clipping
This commit is contained in:
Johannes Millan 2026-03-25 13:38:37 +01:00
parent 061acf4124
commit cd12556d49
13 changed files with 674 additions and 138 deletions

423
package-lock.json generated
View file

@ -645,6 +645,268 @@
"vite": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/expect": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz",
"integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"chai": "^6.2.2",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/pretty-format": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz",
"integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/runner": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz",
"integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@vitest/utils": "4.1.1",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/snapshot": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz",
"integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@vitest/pretty-format": "4.1.1",
"@vitest/utils": "4.1.1",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/spy": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz",
"integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/@vitest/utils": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz",
"integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@vitest/pretty-format": "4.1.1",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@angular-devkit/build-angular/node_modules/es-module-lexer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@angular-devkit/build-angular/node_modules/std-env": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz",
"integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/@angular-devkit/build-angular/node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/vitest": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz",
"integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@vitest/expect": "4.1.1",
"@vitest/mocker": "4.1.1",
"@vitest/pretty-format": "4.1.1",
"@vitest/runner": "4.1.1",
"@vitest/snapshot": "4.1.1",
"@vitest/spy": "4.1.1",
"@vitest/utils": "4.1.1",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.0.3",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.1",
"@vitest/browser-preview": "4.1.1",
"@vitest/browser-webdriverio": "4.1.1",
"@vitest/ui": "4.1.1",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/@angular-devkit/build-angular/node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz",
"integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@vitest/spy": "4.1.1",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@angular-devkit/build-webpack": {
"version": "0.2102.3",
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.2102.3.tgz",
@ -3450,6 +3712,24 @@
"node": ">=10.12.0"
}
},
"node_modules/@electron/asar/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@electron/asar/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@electron/asar/node_modules/commander": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
@ -3483,19 +3763,16 @@
}
},
"node_modules/@electron/asar/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "ISC",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"node": "*"
}
},
"node_modules/@electron/fuses": {
@ -3912,17 +4189,34 @@
"node": ">=16.4"
}
},
"node_modules/@electron/universal/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"node_modules/@electron/universal/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "MIT"
},
"node_modules/@electron/universal/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"balanced-match": "^1.0.0"
}
},
"node_modules/@electron/universal/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": "20 || >=22"
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@ -12675,6 +12969,23 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cacache/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/cacache/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/cacache/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -12704,6 +13015,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@ -12727,22 +13054,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/cacache/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/cacache/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
@ -17328,20 +17639,34 @@
"minimatch": "^5.0.1"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"node_modules/filelist/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "MIT"
},
"node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"balanced-match": "^1.0.0"
}
},
"node_modules/filelist/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"node": ">=10"
}
},
"node_modules/fill-range": {
@ -23232,6 +23557,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@ -24552,6 +24890,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View file

@ -1,11 +1,5 @@
<mat-form-field>
<mat-label>{{ to.label }}</mat-label>
<input
type="color"
class="color-input"
[formControl]="formControl"
[formlyAttributes]="field"
(focus)="onFocus($event)"
matInput
/>
</mat-form-field>
<input-color-picker
[label]="to.label"
[value]="formControl.value"
(valueChange)="onColorChange($event)"
/>

View file

@ -1,3 +1,5 @@
mat-form-field {
:host {
display: block;
width: 100%;
padding: var(--s) 0;
}

View file

@ -1,32 +1,19 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FieldType, FormlyModule } from '@ngx-formly/core';
import { IS_FIREFOX } from '../../../util/is-firefox';
import { IS_MOBILE } from '../../../util/is-mobile';
import { InputColorPickerComponent } from '../../../ui/input-color-picker/input-color-picker.component';
/**
* This component deliberately avoids Formly's field type abstractions for the
* Material UI components because the form field wrapper implementation uses
* a focus monitoring service that is counterproductive for certain native
* color input controls.
*/
@Component({
selector: 'color-input',
templateUrl: './color-input.component.html',
styleUrls: ['./color-input.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormlyModule, MatFormField, MatInput, MatLabel, ReactiveFormsModule],
imports: [FormlyModule, ReactiveFormsModule, InputColorPickerComponent],
})
export class ColorInputComponent extends FieldType {
onFocus(event: FocusEvent): void {
if (!IS_FIREFOX || IS_MOBILE) return;
// Desktop Firefox oddly fires another focus event when the native color
// input closes rather than a blur event, so determine whether a value
// change has occurred and force a blur to have it persisted
const input = event.target as HTMLInputElement;
if (input.value !== this.formControl.value) {
input.blur();
}
onColorChange(color: string): void {
this.formControl.setValue(color);
this.formControl.markAsTouched();
this.formControl.markAsDirty();
}
}

View file

@ -1,28 +1,26 @@
const WORK_CONTEXT_COLORS: string[] = [
'#29a1aa',
'#a05db1',
'#ef5350',
'#ec407a',
'#ab47bc',
'#7e57c2',
'#5c6bc0',
'#42a5f5',
'#29b6f6',
'#26c6da',
'#26a69a',
'#66bb6a',
'#9ccc65',
'#d4e157',
'#ffee58',
'#ffca28',
'#ffa726',
'#ff7043',
'#8d6e63',
'#bdbdbd',
'#78909c',
export const PRESET_COLORS: readonly string[] = [
'#ef5350', // red
'#ff7043', // deep orange
'#ffa726', // orange
'#ffca28', // amber
'#ffee58', // yellow
'#d4e157', // lime
'#9ccc65', // light green
'#66bb6a', // green
'#29a1aa', // teal
'#26c6da', // cyan
'#29b6f6', // light blue
'#42a5f5', // blue
'#5c6bc0', // indigo
'#7e57c2', // deep purple
'#a05db1', // purple
'#ab47bc', // purple alt
'#ec407a', // pink
'#8d6e63', // brown
'#78909c', // blue grey
];
export const getRandomWorkContextColor = (): string => {
const index = Math.floor(Math.random() * WORK_CONTEXT_COLORS.length);
return WORK_CONTEXT_COLORS[index];
const index = Math.floor(Math.random() * PRESET_COLORS.length);
return PRESET_COLORS[index];
};

View file

@ -8,10 +8,7 @@
<span>{{ T.PDS.SUMMARY_FOR | translate: { dayStr: dayStr } }}</span>
}
</h1>
<div
class="daily-summary-summary"
[class.is-loading-archive]="!isArchiveLoaded()"
>
<div class="daily-summary-summary">
<div class="summary-point">
<mat-icon>schedule</mat-icon>
<div class="summary-text">
@ -165,10 +162,8 @@
[isForToday]="isForToday"
[isShowYesterday]="isForToday && isIncludeYesterday"
></task-summary-tables>
} @else if (isArchiveLoaded()) {
<p>{{ T.PDS.NO_TASKS | translate }}</p>
} @else {
<mat-spinner style="margin: auto"></mat-spinner>
<p>{{ T.PDS.NO_TASKS | translate }}</p>
}
@if (tasks?.length) {
<tasks-by-tag

View file

@ -74,11 +74,6 @@ $summary-point-inner-margin: var(--s);
display: flex;
flex-wrap: wrap;
justify-content: center;
transition: opacity 0.2s ease;
&.is-loading-archive {
opacity: 0.6;
}
p {
margin: 5px;

View file

@ -8,7 +8,6 @@ import {
linkedSignal,
OnDestroy,
OnInit,
signal,
Signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
@ -30,7 +29,6 @@ import {
switchMap,
take,
takeUntil,
tap,
timeout,
withLatestFrom,
} from 'rxjs/operators';
@ -67,6 +65,7 @@ import { MsToClockStringPipe } from '../../ui/duration/ms-to-clock-string.pipe';
import { InlineInputComponent } from '../../ui/inline-input/inline-input.component';
import { InlineMarkdownComponent } from '../../ui/inline-markdown/inline-markdown.component';
import { MomentFormatPipe } from '../../ui/pipes/moment-format.pipe';
import { IS_TOUCH_ONLY } from '../../util/is-touch-only';
import { getPluralKey } from '../../util/get-plural-key';
import { shareReplayUntil } from '../../util/share-replay-until';
import { unToggleCheckboxesInMarkdownTxt } from '../../util/untoggle-checkboxes-in-markdown-txt';
@ -170,8 +169,6 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
shareReplay(1),
);
isArchiveLoaded = signal(false);
hasTasksForToday$: Observable<boolean> = this.tasksWorkedOnOrDoneOrRepeatableFlat$.pipe(
map((tasks) => tasks && !!tasks.length),
);
@ -314,16 +311,12 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
return;
}
this.tasksWorkedOnOrDoneOrRepeatableFlat$
.pipe(
first((tasks) => tasks.length > 0),
takeUntil(this._onDestroy$),
)
.subscribe(() => {
this._startCelebrationTimeout = window.setTimeout(() => {
this._celebrate();
}, 300);
});
this._startCelebrationTimeout = window.setTimeout(
() => {
this._celebrate();
},
IS_TOUCH_ONLY ? 1500 : 500,
);
}
ngOnDestroy(): void {
@ -500,7 +493,7 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
}
private _getDailySummaryTasksFlat$(dayStr: string): Observable<Task[]> {
this.isArchiveLoaded.set(false);
// TODO make more performant!!
const _isWorkedOnDoneOrDueToday = (() => {
if (this.isIncludeYesterday) {
const yesterday = new Date();
@ -621,7 +614,6 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
withLatestFrom(this.workContextService.activeWorkContextTypeAndId$),
map(_mapEntities),
map(_mapFilterToFlatToday),
tap(() => this.isArchiveLoaded.set(true)),
);
const todayTasks: Observable<TaskWithSubTasks[]> =
@ -631,10 +623,9 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
map(_mapFilterToFlatOrRepeatToday),
);
return combineLatest([
todayTasks,
archiveTasks.pipe(startWith([] as TaskWithSubTasks[])),
]).pipe(map(([today, archive]) => today.concat(archive)));
return combineLatest([todayTasks, archiveTasks]).pipe(
map(([t1, t2]) => t1.concat(t2)),
);
}
private _celebrate(): void {

View file

@ -44,15 +44,11 @@
</mat-autocomplete>
</mat-form-field>
<mat-form-field>
<mat-label>{{ T.F.TAG.FORM_BASIC.L_COLOR | translate }}</mat-label>
<input
[(ngModel)]="color"
name="color"
matInput
type="color"
/>
</mat-form-field>
<input-color-picker
[label]="T.F.TAG.FORM_BASIC.L_COLOR | translate"
[value]="color"
(valueChange)="color = $event"
/>
</form>
</mat-dialog-content>

View file

@ -22,6 +22,7 @@ import { DEFAULT_TAG_COLOR } from '../../features/work-context/work-context.cons
import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatOption } from '@angular/material/core';
import { MaterialIconsLoaderService } from '../material-icons-loader.service';
import { InputColorPickerComponent } from '../input-color-picker/input-color-picker.component';
export interface CreateTagData {
title?: string;
@ -47,6 +48,7 @@ export interface CreateTagData {
MatAutocomplete,
MatAutocompleteTrigger,
MatOption,
InputColorPickerComponent,
],
})
export class DialogCreateTagComponent {

View file

@ -0,0 +1,59 @@
@if (label()) {
<label class="color-label">{{ label() }}</label>
}
<button
#trigger
type="button"
class="color-trigger"
[style.background-color]="value()"
(click)="toggle()"
>
</button>
@if (isOpen()) {
<div class="backdrop" (click)="isOpen.set(false)"></div>
<div
class="color-panel"
[style.position]="'fixed'"
[style.top]="panelTop"
[style.left]="panelLeft"
>
<div class="color-grid">
@for (color of presetColors; track color) {
<button
type="button"
class="color-swatch"
[style.background-color]="color"
[class.selected]="value() === color"
(click)="selectColor(color)"
>
@if (value() === color) {
<mat-icon>check</mat-icon>
}
</button>
}
<button
type="button"
class="color-swatch custom-color-btn"
[style.background-color]="isPresetColor() ? '' : value()"
[class.selected]="!isPresetColor()"
(click)="openNativePicker()"
>
@if (!isPresetColor()) {
<mat-icon>check</mat-icon>
} @else {
<mat-icon>colorize</mat-icon>
}
</button>
</div>
</div>
}
<input
#nativeInput
type="color"
class="native-color-input"
[value]="value()"
(change)="onNativeChange($event)"
/>

View file

@ -0,0 +1,101 @@
:host {
display: inline-block;
position: relative;
margin-bottom: var(--s);
}
.color-label {
display: block;
margin-bottom: var(--s);
color: var(--text-color-muted);
font-size: 0.75rem;
}
.color-trigger {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--text-color-muted);
cursor: pointer;
padding: 0;
transition: var(--transition-fast);
&:hover {
transform: scale(1.1);
}
}
.backdrop {
position: fixed;
inset: 0;
z-index: calc(var(--z-backdrop) - 1);
}
.color-panel {
padding: var(--s);
background: var(--card-bg);
border-radius: 8px;
box-shadow: var(--whiteframe-shadow-8dp);
z-index: var(--z-backdrop);
}
.color-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--s-half);
}
.color-swatch {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: var(--transition-fast);
&:hover {
transform: scale(1.15);
}
&.selected {
border-color: var(--text-color);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #fff;
}
}
.custom-color-btn {
background-color: var(--card-bg);
border-color: var(--text-color-muted);
border-style: dashed;
mat-icon {
color: var(--text-color-muted);
}
&.selected {
border-style: solid;
border-color: var(--text-color);
mat-icon {
color: #fff;
}
}
}
.native-color-input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
height: 0;
}

View file

@ -0,0 +1,77 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
HostListener,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { PRESET_COLORS } from '../../features/work-context/work-context-color';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'input-color-picker',
templateUrl: './input-color-picker.component.html',
styleUrls: ['./input-color-picker.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon],
})
export class InputColorPickerComponent {
readonly value = input<string>('#000000');
readonly label = input<string>('');
readonly valueChange = output<string>();
readonly presetColors = PRESET_COLORS;
readonly nativeInput = viewChild<ElementRef<HTMLInputElement>>('nativeInput');
readonly trigger = viewChild<ElementRef<HTMLButtonElement>>('trigger');
readonly isOpen = signal(false);
readonly isPresetColor = computed(() => this.presetColors.includes(this.value()));
panelTop = '';
panelLeft = '';
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.isOpen()) {
this.isOpen.set(false);
}
}
toggle(): void {
if (!this.isOpen()) {
this._updatePanelPosition();
}
this.isOpen.update((v) => !v);
}
selectColor(color: string): void {
this.valueChange.emit(color);
this.isOpen.set(false);
}
openNativePicker(): void {
this.isOpen.set(false);
this.nativeInput()?.nativeElement.click();
}
onNativeChange(event: Event): void {
const el = event.target as HTMLInputElement;
this.valueChange.emit(el.value);
}
private _updatePanelPosition(): void {
const rect = this.trigger()?.nativeElement.getBoundingClientRect();
if (!rect) return;
const panelWidth = 196;
const spaceRight = window.innerWidth - rect.left;
const left =
spaceRight >= panelWidth ? rect.left : window.innerWidth - panelWidth - 8;
this.panelTop = `${rect.bottom + 4}px`;
this.panelLeft = `${left}px`;
}
}