Add Color Picker component with interactive color wheel and hue selection
Features: - Interactive color wheel for saturation/lightness selection - Horizontal hue bar for full spectrum selection (0-360°) - RGB/HSL/HEX color model support with conversion utilities - ControlValueAccessor for Angular reactive forms integration - Canvas API rendering for smooth color gradients - Touch and mouse event support for intuitive color selection - Keyboard navigation with arrow keys for accessibility - Multiple size variants (sm, md, lg) and color themes - Comprehensive demo with preset colors and interactive examples - Full semantic design token integration following project standards 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,339 @@
|
|||||||
|
@use '../../../../../shared-ui/src/styles/semantic/index' as *;
|
||||||
|
|
||||||
|
.demo-container {
|
||||||
|
padding: $semantic-spacing-layout-section-md;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: map-get($semantic-typography-heading-h2, font-family);
|
||||||
|
font-size: map-get($semantic-typography-heading-h2, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-heading-h2, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-heading-h2, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
margin-bottom: $semantic-spacing-layout-section-lg;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-section {
|
||||||
|
margin-bottom: $semantic-spacing-layout-section-xl;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-family: map-get($semantic-typography-heading-h3, font-family);
|
||||||
|
font-size: map-get($semantic-typography-heading-h3, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-heading-h3, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-heading-h3, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
margin-bottom: $semantic-spacing-layout-section-md;
|
||||||
|
padding-bottom: $semantic-spacing-component-sm;
|
||||||
|
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-family: map-get($semantic-typography-heading-h4, font-family);
|
||||||
|
font-size: map-get($semantic-typography-heading-h4, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-heading-h4, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-heading-h4, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
margin-bottom: $semantic-spacing-component-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-row {
|
||||||
|
display: flex;
|
||||||
|
gap: $semantic-spacing-layout-section-md;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $semantic-spacing-layout-section-sm;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: $semantic-spacing-layout-section-md;
|
||||||
|
|
||||||
|
@media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: $semantic-spacing-layout-section-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-item {
|
||||||
|
padding: $semantic-spacing-component-lg;
|
||||||
|
background: $semantic-color-surface-secondary;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
border-radius: $semantic-border-radius-md;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-info {
|
||||||
|
padding: $semantic-spacing-component-lg;
|
||||||
|
background: $semantic-color-surface-elevated;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
border-radius: $semantic-border-radius-md;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||||
|
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
margin-bottom: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: $semantic-typography-font-weight-semibold;
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 100px;
|
||||||
|
height: 60px;
|
||||||
|
border: $semantic-border-width-2 solid $semantic-color-border-primary;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
margin-top: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-form {
|
||||||
|
.demo-form-info {
|
||||||
|
margin-top: $semantic-spacing-component-lg;
|
||||||
|
padding: $semantic-spacing-component-md;
|
||||||
|
background: $semantic-color-surface-container;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: $semantic-typography-font-family-mono;
|
||||||
|
font-size: $semantic-typography-font-size-sm;
|
||||||
|
color: $semantic-color-text-secondary;
|
||||||
|
background: $semantic-color-surface-primary;
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-theme-preview {
|
||||||
|
margin-top: $semantic-spacing-component-lg;
|
||||||
|
|
||||||
|
.theme-sample {
|
||||||
|
padding: $semantic-spacing-component-lg;
|
||||||
|
border-radius: $semantic-border-radius-md;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
text-align: center;
|
||||||
|
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||||
|
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: $semantic-color-surface-primary;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
margin-bottom: $semantic-spacing-component-md;
|
||||||
|
|
||||||
|
.event-entry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
margin-bottom: $semantic-spacing-component-xs;
|
||||||
|
font-family: $semantic-typography-font-family-mono;
|
||||||
|
font-size: $semantic-typography-font-size-sm;
|
||||||
|
transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||||
|
|
||||||
|
&--recent {
|
||||||
|
background-color: $semantic-color-surface-elevated;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-type {
|
||||||
|
font-weight: $semantic-typography-font-weight-semibold;
|
||||||
|
color: $semantic-color-primary;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-value {
|
||||||
|
flex: 1;
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
margin: 0 $semantic-spacing-component-sm;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-time {
|
||||||
|
color: $semantic-color-text-tertiary;
|
||||||
|
font-size: $semantic-typography-font-size-xs;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-log-btn {
|
||||||
|
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
|
||||||
|
background: $semantic-color-danger;
|
||||||
|
color: $semantic-color-on-danger;
|
||||||
|
border: none;
|
||||||
|
border-radius: $semantic-border-button-radius;
|
||||||
|
font-family: map-get($semantic-typography-button-small, font-family);
|
||||||
|
font-size: map-get($semantic-typography-button-small, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-button-small, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-button-small, line-height);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: $semantic-shadow-button-hover;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: $semantic-shadow-button-rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-colors {
|
||||||
|
padding: $semantic-spacing-component-lg;
|
||||||
|
background: $semantic-color-surface-elevated;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
border-radius: $semantic-border-radius-md;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
|
||||||
|
.preset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(40px, 1fr));
|
||||||
|
gap: $semantic-spacing-component-sm;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-top: $semantic-spacing-component-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-color-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: $semantic-border-width-2 solid $semantic-color-border-primary;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: $semantic-shadow-elevation-3;
|
||||||
|
border-color: $semantic-color-border-focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid $semantic-color-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: $semantic-shadow-elevation-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) {
|
||||||
|
.demo-container {
|
||||||
|
padding: $semantic-spacing-layout-section-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-item {
|
||||||
|
padding: $semantic-spacing-component-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-info {
|
||||||
|
padding: $semantic-spacing-component-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-colors {
|
||||||
|
padding: $semantic-spacing-component-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) {
|
||||||
|
.demo-container {
|
||||||
|
padding: $semantic-spacing-layout-section-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-item {
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-info {
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-colors {
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
|
||||||
|
.preset-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(35px, 1fr));
|
||||||
|
gap: $semantic-spacing-component-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-color-btn {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log {
|
||||||
|
.event-entry {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: $semantic-spacing-component-xs;
|
||||||
|
|
||||||
|
.event-type,
|
||||||
|
.event-value,
|
||||||
|
.event-time {
|
||||||
|
min-width: unset;
|
||||||
|
margin: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup } from '@angular/forms';
|
||||||
|
import { ColorPickerComponent } from '../../../../../ui-essentials/src/lib/components/forms/color-picker/color-picker.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-color-picker-demo',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ColorPickerComponent
|
||||||
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<div class="demo-container">
|
||||||
|
<h2>Color Picker Component Showcase</h2>
|
||||||
|
|
||||||
|
<!-- Basic Color Picker -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Basic Color Picker</h3>
|
||||||
|
<div class="demo-row">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Choose a color"
|
||||||
|
description="Select your preferred color"
|
||||||
|
[(ngModel)]="basicColor"
|
||||||
|
(colorChange)="onColorChange('basic', $event)"
|
||||||
|
/>
|
||||||
|
<div class="demo-info">
|
||||||
|
<p><strong>Selected:</strong> {{ basicColor() }}</p>
|
||||||
|
<div class="color-preview" [style.background-color]="basicColor()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Size Variants -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Size Variants</h3>
|
||||||
|
<div class="demo-grid">
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Small (sm)"
|
||||||
|
size="sm"
|
||||||
|
[(ngModel)]="sizeSmall"
|
||||||
|
(colorChange)="onColorChange('size-sm', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Medium (md)"
|
||||||
|
size="md"
|
||||||
|
[(ngModel)]="sizeMedium"
|
||||||
|
(colorChange)="onColorChange('size-md', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Large (lg)"
|
||||||
|
size="lg"
|
||||||
|
[(ngModel)]="sizeLarge"
|
||||||
|
(colorChange)="onColorChange('size-lg', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Color Variants -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Color Variants</h3>
|
||||||
|
<div class="demo-grid">
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Primary"
|
||||||
|
variant="primary"
|
||||||
|
[(ngModel)]="variantPrimary"
|
||||||
|
(colorChange)="onColorChange('variant-primary', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Secondary"
|
||||||
|
variant="secondary"
|
||||||
|
[(ngModel)]="variantSecondary"
|
||||||
|
(colorChange)="onColorChange('variant-secondary', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Success"
|
||||||
|
variant="success"
|
||||||
|
[(ngModel)]="variantSuccess"
|
||||||
|
(colorChange)="onColorChange('variant-success', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Danger"
|
||||||
|
variant="danger"
|
||||||
|
[(ngModel)]="variantDanger"
|
||||||
|
(colorChange)="onColorChange('variant-danger', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- States -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>States</h3>
|
||||||
|
<div class="demo-grid">
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Default State"
|
||||||
|
[(ngModel)]="stateDefault"
|
||||||
|
(colorChange)="onColorChange('state-default', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Error State"
|
||||||
|
state="error"
|
||||||
|
description="This field has an error"
|
||||||
|
[(ngModel)]="stateError"
|
||||||
|
(colorChange)="onColorChange('state-error', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-item">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Disabled State"
|
||||||
|
[disabled]="true"
|
||||||
|
[(ngModel)]="stateDisabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Required Field -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Required Field</h3>
|
||||||
|
<div class="demo-row">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Brand Color"
|
||||||
|
description="Choose your brand's primary color"
|
||||||
|
[required]="true"
|
||||||
|
[(ngModel)]="requiredColor"
|
||||||
|
(colorChange)="onColorChange('required', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Reactive Forms Integration -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Reactive Forms Integration</h3>
|
||||||
|
<form [formGroup]="colorForm" class="demo-form">
|
||||||
|
<div class="demo-row">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Background Color"
|
||||||
|
description="Select background color for your theme"
|
||||||
|
formControlName="backgroundColor"
|
||||||
|
(colorChange)="onColorChange('form-bg', $event)"
|
||||||
|
/>
|
||||||
|
<ui-color-picker
|
||||||
|
label="Text Color"
|
||||||
|
description="Select text color for your theme"
|
||||||
|
formControlName="textColor"
|
||||||
|
(colorChange)="onColorChange('form-text', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="demo-form-info">
|
||||||
|
<h4>Form Values:</h4>
|
||||||
|
<pre>{{ getFormValues() }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="demo-theme-preview">
|
||||||
|
<h4>Theme Preview:</h4>
|
||||||
|
<div
|
||||||
|
class="theme-sample"
|
||||||
|
[style.background-color]="colorForm.get('backgroundColor')?.value || '#FFFFFF'"
|
||||||
|
[style.color]="colorForm.get('textColor')?.value || '#000000'">
|
||||||
|
Sample text with selected colors
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Interactive Demo -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Interactive Demo</h3>
|
||||||
|
<div class="demo-row">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Interactive Color"
|
||||||
|
description="Watch the events in the console"
|
||||||
|
[(ngModel)]="interactiveColor"
|
||||||
|
(colorChange)="onInteractiveColorChange($event)"
|
||||||
|
(colorFocus)="onColorFocus($event)"
|
||||||
|
(colorBlur)="onColorBlur($event)"
|
||||||
|
/>
|
||||||
|
<div class="demo-info">
|
||||||
|
<h4>Event Log:</h4>
|
||||||
|
<div class="event-log">
|
||||||
|
@for (event of eventLog(); track event.id) {
|
||||||
|
<div class="event-entry" [class.event-entry--recent]="event.recent">
|
||||||
|
<span class="event-type">{{ event.type }}</span>
|
||||||
|
<span class="event-value">{{ event.value }}</span>
|
||||||
|
<span class="event-time">{{ event.time }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="clear-log-btn" (click)="clearEventLog()">Clear Log</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Preset Colors Demo -->
|
||||||
|
<section class="demo-section">
|
||||||
|
<h3>Preset Colors</h3>
|
||||||
|
<div class="demo-row">
|
||||||
|
<ui-color-picker
|
||||||
|
label="Select or enter color"
|
||||||
|
[(ngModel)]="presetColor"
|
||||||
|
(colorChange)="onColorChange('preset', $event)"
|
||||||
|
/>
|
||||||
|
<div class="preset-colors">
|
||||||
|
<h4>Quick Colors:</h4>
|
||||||
|
<div class="preset-grid">
|
||||||
|
@for (color of presetColors; track color.name) {
|
||||||
|
<button
|
||||||
|
class="preset-color-btn"
|
||||||
|
[style.background-color]="color.hex"
|
||||||
|
[attr.title]="color.name + ' (' + color.hex + ')'"
|
||||||
|
(click)="selectPresetColor(color.hex)">
|
||||||
|
<span class="sr-only">{{ color.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './color-picker-demo.component.scss'
|
||||||
|
})
|
||||||
|
export class ColorPickerDemoComponent {
|
||||||
|
// Basic demo colors
|
||||||
|
basicColor = signal<string>('#3498db');
|
||||||
|
|
||||||
|
// Size variants
|
||||||
|
sizeSmall = signal<string>('#e74c3c');
|
||||||
|
sizeMedium = signal<string>('#2ecc71');
|
||||||
|
sizeLarge = signal<string>('#f39c12');
|
||||||
|
|
||||||
|
// Color variants
|
||||||
|
variantPrimary = signal<string>('#3498db');
|
||||||
|
variantSecondary = signal<string>('#95a5a6');
|
||||||
|
variantSuccess = signal<string>('#2ecc71');
|
||||||
|
variantDanger = signal<string>('#e74c3c');
|
||||||
|
|
||||||
|
// States
|
||||||
|
stateDefault = signal<string>('#9b59b6');
|
||||||
|
stateError = signal<string>('#e74c3c');
|
||||||
|
stateDisabled = signal<string>('#bdc3c7');
|
||||||
|
|
||||||
|
// Required field
|
||||||
|
requiredColor = signal<string>('#1abc9c');
|
||||||
|
|
||||||
|
// Interactive demo
|
||||||
|
interactiveColor = signal<string>('#34495e');
|
||||||
|
eventLog = signal<Array<{id: number, type: string, value: string, time: string, recent: boolean}>>([]);
|
||||||
|
private eventIdCounter = 0;
|
||||||
|
|
||||||
|
// Preset colors
|
||||||
|
presetColor = signal<string>('#ff6b6b');
|
||||||
|
presetColors = [
|
||||||
|
{ name: 'Red', hex: '#e74c3c' },
|
||||||
|
{ name: 'Orange', hex: '#f39c12' },
|
||||||
|
{ name: 'Yellow', hex: '#f1c40f' },
|
||||||
|
{ name: 'Green', hex: '#2ecc71' },
|
||||||
|
{ name: 'Blue', hex: '#3498db' },
|
||||||
|
{ name: 'Purple', hex: '#9b59b6' },
|
||||||
|
{ name: 'Pink', hex: '#e91e63' },
|
||||||
|
{ name: 'Teal', hex: '#1abc9c' },
|
||||||
|
{ name: 'Gray', hex: '#95a5a6' },
|
||||||
|
{ name: 'Black', hex: '#2c3e50' },
|
||||||
|
{ name: 'White', hex: '#ecf0f1' },
|
||||||
|
{ name: 'Brown', hex: '#8d4925' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reactive forms
|
||||||
|
colorForm = new FormGroup({
|
||||||
|
backgroundColor: new FormControl('#ffffff'),
|
||||||
|
textColor: new FormControl('#333333')
|
||||||
|
});
|
||||||
|
|
||||||
|
onColorChange(source: string, color: string): void {
|
||||||
|
console.log(`Color changed (${source}):`, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
onInteractiveColorChange(color: string): void {
|
||||||
|
this.addEventToLog('change', color);
|
||||||
|
}
|
||||||
|
|
||||||
|
onColorFocus(event: FocusEvent): void {
|
||||||
|
this.addEventToLog('focus', 'Color picker focused');
|
||||||
|
}
|
||||||
|
|
||||||
|
onColorBlur(event: FocusEvent): void {
|
||||||
|
this.addEventToLog('blur', 'Color picker blurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addEventToLog(type: string, value: string): void {
|
||||||
|
const newEvent = {
|
||||||
|
id: this.eventIdCounter++,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
time: new Date().toLocaleTimeString(),
|
||||||
|
recent: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLog = this.eventLog();
|
||||||
|
const updatedLog = [newEvent, ...currentLog.slice(0, 9)].map((event, index) => ({
|
||||||
|
...event,
|
||||||
|
recent: index === 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.eventLog.set(updatedLog);
|
||||||
|
|
||||||
|
// Remove recent flag after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
const logWithoutRecent = this.eventLog().map(event => ({
|
||||||
|
...event,
|
||||||
|
recent: false
|
||||||
|
}));
|
||||||
|
this.eventLog.set(logWithoutRecent);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEventLog(): void {
|
||||||
|
this.eventLog.set([]);
|
||||||
|
this.eventIdCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPresetColor(color: string): void {
|
||||||
|
this.presetColor.set(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormValues(): string {
|
||||||
|
return JSON.stringify(this.colorForm.value, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ import { OverlayContainerDemoComponent } from './overlay-container-demo/overlay-
|
|||||||
import { LoadingSpinnerDemoComponent } from './loading-spinner-demo/loading-spinner-demo.component';
|
import { LoadingSpinnerDemoComponent } from './loading-spinner-demo/loading-spinner-demo.component';
|
||||||
import { ProgressCircleDemoComponent } from './progress-circle-demo/progress-circle-demo.component';
|
import { ProgressCircleDemoComponent } from './progress-circle-demo/progress-circle-demo.component';
|
||||||
import { RangeSliderDemoComponent } from './range-slider-demo/range-slider-demo.component';
|
import { RangeSliderDemoComponent } from './range-slider-demo/range-slider-demo.component';
|
||||||
|
import { ColorPickerDemoComponent } from './color-picker-demo/color-picker-demo.component';
|
||||||
import { DividerDemoComponent } from './divider-demo/divider-demo.component';
|
import { DividerDemoComponent } from './divider-demo/divider-demo.component';
|
||||||
import { TooltipDemoComponent } from './tooltip-demo/tooltip-demo.component';
|
import { TooltipDemoComponent } from './tooltip-demo/tooltip-demo.component';
|
||||||
import { AccordionDemoComponent } from './accordion-demo/accordion-demo.component';
|
import { AccordionDemoComponent } from './accordion-demo/accordion-demo.component';
|
||||||
@@ -55,6 +56,9 @@ import { StepperDemoComponent } from './stepper-demo/stepper-demo.component';
|
|||||||
import { FabMenuDemoComponent } from './fab-menu-demo/fab-menu-demo.component';
|
import { FabMenuDemoComponent } from './fab-menu-demo/fab-menu-demo.component';
|
||||||
import { EnhancedTableDemoComponent } from './enhanced-table-demo/enhanced-table-demo.component';
|
import { EnhancedTableDemoComponent } from './enhanced-table-demo/enhanced-table-demo.component';
|
||||||
import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.component';
|
import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.component';
|
||||||
|
import { CommandPaletteDemoComponent } from './command-palette-demo/command-palette-demo.component';
|
||||||
|
import { TransferListDemoComponent } from './transfer-list-demo/transfer-list-demo.component';
|
||||||
|
import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-toolbar-demo.component';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -221,6 +225,10 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.
|
|||||||
<ui-range-slider-demo></ui-range-slider-demo>
|
<ui-range-slider-demo></ui-range-slider-demo>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@case ("color-picker") {
|
||||||
|
<ui-color-picker-demo></ui-color-picker-demo>
|
||||||
|
}
|
||||||
|
|
||||||
@case ("divider") {
|
@case ("divider") {
|
||||||
<ui-divider-demo></ui-divider-demo>
|
<ui-divider-demo></ui-divider-demo>
|
||||||
}
|
}
|
||||||
@@ -273,6 +281,18 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.
|
|||||||
<ui-split-button-demo></ui-split-button-demo>
|
<ui-split-button-demo></ui-split-button-demo>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@case ("command-palette") {
|
||||||
|
<ui-command-palette-demo></ui-command-palette-demo>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ("floating-toolbar") {
|
||||||
|
<ui-floating-toolbar-demo></ui-floating-toolbar-demo>
|
||||||
|
}
|
||||||
|
|
||||||
|
@case ("transfer-list") {
|
||||||
|
<ui-transfer-list-demo></ui-transfer-list-demo>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -287,8 +307,8 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.
|
|||||||
GridSystemDemoComponent, SpacerDemoComponent, ContainerDemoComponent, PaginationDemoComponent,
|
GridSystemDemoComponent, SpacerDemoComponent, ContainerDemoComponent, PaginationDemoComponent,
|
||||||
SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent,
|
SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent,
|
||||||
AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent,
|
AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent,
|
||||||
ProgressCircleDemoComponent, RangeSliderDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent,
|
ProgressCircleDemoComponent, RangeSliderDemoComponent, ColorPickerDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent,
|
||||||
PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent]
|
PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent, CommandPaletteDemoComponent, FloatingToolbarDemoComponent, TransferListDemoComponent]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle,
|
faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle,
|
||||||
faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders,
|
faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders,
|
||||||
faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream,
|
faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream,
|
||||||
faBell, faRoute, faChevronUp, faEllipsisV, faCut
|
faBell, faRoute, faChevronUp, faEllipsisV, faCut, faPalette, faExchangeAlt, faTools
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { DemoRoutes } from '../../demos';
|
import { DemoRoutes } from '../../demos';
|
||||||
import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component';
|
import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component';
|
||||||
@@ -104,6 +104,9 @@ export class DashboardComponent {
|
|||||||
faChevronUp = faChevronUp;
|
faChevronUp = faChevronUp;
|
||||||
faEllipsisV = faEllipsisV;
|
faEllipsisV = faEllipsisV;
|
||||||
faCut = faCut;
|
faCut = faCut;
|
||||||
|
faPalette = faPalette;
|
||||||
|
faExchangeAlt = faExchangeAlt;
|
||||||
|
faTools = faTools;
|
||||||
|
|
||||||
menuItems: any = []
|
menuItems: any = []
|
||||||
|
|
||||||
@@ -149,7 +152,8 @@ export class DashboardComponent {
|
|||||||
this.createChildItem("time-picker", "Time Picker", this.faClock),
|
this.createChildItem("time-picker", "Time Picker", this.faClock),
|
||||||
this.createChildItem("file-upload", "File Upload", this.faCloudUploadAlt),
|
this.createChildItem("file-upload", "File Upload", this.faCloudUploadAlt),
|
||||||
this.createChildItem("form-field", "Form Field", this.faFileText),
|
this.createChildItem("form-field", "Form Field", this.faFileText),
|
||||||
this.createChildItem("range-slider", "Range Slider", this.faSliders)
|
this.createChildItem("range-slider", "Range Slider", this.faSliders),
|
||||||
|
this.createChildItem("color-picker", "Color Picker", this.faPalette)
|
||||||
];
|
];
|
||||||
this.addMenuItem("forms", "Forms", this.faEdit, formsChildren);
|
this.addMenuItem("forms", "Forms", this.faEdit, formsChildren);
|
||||||
|
|
||||||
@@ -166,7 +170,8 @@ export class DashboardComponent {
|
|||||||
this.createChildItem("divider", "Divider", this.faMinus),
|
this.createChildItem("divider", "Divider", this.faMinus),
|
||||||
this.createChildItem("timeline", "Timeline", this.faStream),
|
this.createChildItem("timeline", "Timeline", this.faStream),
|
||||||
this.createChildItem("tooltip", "Tooltip", this.faInfoCircle),
|
this.createChildItem("tooltip", "Tooltip", this.faInfoCircle),
|
||||||
this.createChildItem("tree-view", "Tree View", this.faSitemap)
|
this.createChildItem("tree-view", "Tree View", this.faSitemap),
|
||||||
|
this.createChildItem("transfer-list", "Transfer List", this.faExchangeAlt)
|
||||||
];
|
];
|
||||||
this.addMenuItem("data-display", "Data Display", this.faEye, dataDisplayChildren);
|
this.addMenuItem("data-display", "Data Display", this.faEye, dataDisplayChildren);
|
||||||
|
|
||||||
@@ -224,7 +229,9 @@ export class DashboardComponent {
|
|||||||
this.createChildItem("drawer", "Drawer/Sidebar", this.faBars),
|
this.createChildItem("drawer", "Drawer/Sidebar", this.faBars),
|
||||||
this.createChildItem("popover", "Popover/Dropdown", this.faCaretUp),
|
this.createChildItem("popover", "Popover/Dropdown", this.faCaretUp),
|
||||||
this.createChildItem("backdrop", "Backdrop", this.faCircle),
|
this.createChildItem("backdrop", "Backdrop", this.faCircle),
|
||||||
this.createChildItem("overlay-container", "Overlay Container", this.faExpandArrowsAlt)
|
this.createChildItem("overlay-container", "Overlay Container", this.faExpandArrowsAlt),
|
||||||
|
this.createChildItem("command-palette", "Command Palette", this.faPalette),
|
||||||
|
this.createChildItem("floating-toolbar", "Floating Toolbar", this.faTools)
|
||||||
];
|
];
|
||||||
this.addMenuItem("overlays", "Overlays", this.faLayerGroup, overlaysChildren);
|
this.addMenuItem("overlays", "Overlays", this.faLayerGroup, overlaysChildren);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
@use "../../../../../../shared-ui/src/styles/semantic" as *;
|
||||||
|
|
||||||
|
.ui-color-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
font-family: map-get($semantic-typography-body-medium, font-family);
|
||||||
|
font-size: map-get($semantic-typography-body-medium, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-body-medium, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-body-medium, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $semantic-spacing-form-field-gap;
|
||||||
|
font-family: map-get($semantic-typography-label, font-family);
|
||||||
|
font-size: map-get($semantic-typography-label, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-label, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-label, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__required {
|
||||||
|
color: $semantic-color-danger;
|
||||||
|
margin-left: $semantic-spacing-component-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $semantic-spacing-component-md;
|
||||||
|
padding: $semantic-spacing-component-md;
|
||||||
|
background: $semantic-color-surface-primary;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-primary;
|
||||||
|
border-radius: $semantic-border-radius-md;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wheel-container {
|
||||||
|
position: relative;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wheel {
|
||||||
|
display: block;
|
||||||
|
border-radius: $semantic-border-radius-full;
|
||||||
|
cursor: crosshair;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
transition: box-shadow $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $semantic-color-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: $semantic-shadow-elevation-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wheel-indicator {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid $semantic-color-surface-primary;
|
||||||
|
border-radius: $semantic-border-radius-full;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: $semantic-shadow-elevation-2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hue-container {
|
||||||
|
position: relative;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hue-bar {
|
||||||
|
display: block;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
transition: box-shadow $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $semantic-color-focus;
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: $semantic-shadow-elevation-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hue-indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
width: 12px;
|
||||||
|
height: calc(100% + 4px);
|
||||||
|
border: 2px solid $semantic-color-surface-primary;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: $semantic-shadow-elevation-2;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview-container {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
width: 48px;
|
||||||
|
height: 32px;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-primary;
|
||||||
|
border-radius: $semantic-border-radius-sm;
|
||||||
|
box-shadow: $semantic-shadow-elevation-1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hex-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $semantic-spacing-component-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hex-label {
|
||||||
|
font-family: map-get($semantic-typography-caption, font-family);
|
||||||
|
font-size: map-get($semantic-typography-caption, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-caption, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-caption, line-height);
|
||||||
|
color: $semantic-color-text-secondary;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hex-input {
|
||||||
|
width: 80px;
|
||||||
|
padding: $semantic-spacing-interactive-input-padding-y $semantic-spacing-interactive-input-padding-x;
|
||||||
|
background: $semantic-color-surface-secondary;
|
||||||
|
border: $semantic-border-width-1 solid $semantic-color-border-primary;
|
||||||
|
border-radius: $semantic-border-input-radius;
|
||||||
|
font-family: map-get($semantic-typography-input, font-family);
|
||||||
|
font-size: map-get($semantic-typography-input, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-input, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-input, line-height);
|
||||||
|
color: $semantic-color-text-primary;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $semantic-color-border-focus;
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: $semantic-color-surface-container;
|
||||||
|
color: $semantic-color-text-disabled;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: $semantic-opacity-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__description {
|
||||||
|
margin-top: $semantic-spacing-component-xs;
|
||||||
|
font-family: map-get($semantic-typography-caption, font-family);
|
||||||
|
font-size: map-get($semantic-typography-caption, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-caption, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-caption, line-height);
|
||||||
|
color: $semantic-color-text-tertiary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size variants
|
||||||
|
&--sm {
|
||||||
|
font-family: map-get($semantic-typography-body-small, font-family);
|
||||||
|
font-size: map-get($semantic-typography-body-small, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-body-small, line-height);
|
||||||
|
|
||||||
|
.ui-color-picker__container {
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
gap: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__preview {
|
||||||
|
width: 32px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: $semantic-spacing-component-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__wheel-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hue-indicator {
|
||||||
|
width: 8px;
|
||||||
|
height: calc(100% + 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
font-family: map-get($semantic-typography-body-large, font-family);
|
||||||
|
font-size: map-get($semantic-typography-body-large, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-body-large, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-body-large, line-height);
|
||||||
|
|
||||||
|
.ui-color-picker__container {
|
||||||
|
padding: $semantic-spacing-component-lg;
|
||||||
|
gap: $semantic-spacing-component-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__preview {
|
||||||
|
width: 64px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input {
|
||||||
|
width: 90px;
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__wheel-indicator {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hue-indicator {
|
||||||
|
width: 16px;
|
||||||
|
height: calc(100% + 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color variants
|
||||||
|
&--primary {
|
||||||
|
.ui-color-picker__container {
|
||||||
|
border-color: $semantic-color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input:focus {
|
||||||
|
border-color: $semantic-color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
.ui-color-picker__container {
|
||||||
|
border-color: $semantic-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input:focus {
|
||||||
|
border-color: $semantic-color-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
.ui-color-picker__container {
|
||||||
|
border-color: $semantic-color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input:focus {
|
||||||
|
border-color: $semantic-color-success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--danger {
|
||||||
|
.ui-color-picker__container {
|
||||||
|
border-color: $semantic-color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input:focus {
|
||||||
|
border-color: $semantic-color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State variants
|
||||||
|
&--error {
|
||||||
|
.ui-color-picker__container {
|
||||||
|
border-color: $semantic-color-border-error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__label {
|
||||||
|
color: $semantic-color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input {
|
||||||
|
border-color: $semantic-color-border-error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
opacity: $semantic-opacity-disabled;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.ui-color-picker__container {
|
||||||
|
background: $semantic-color-surface-container;
|
||||||
|
border-color: $semantic-color-border-subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__wheel,
|
||||||
|
.ui-color-picker__hue-bar {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: $semantic-opacity-disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__label {
|
||||||
|
color: $semantic-color-text-disabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive design
|
||||||
|
@media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) {
|
||||||
|
.ui-color-picker__container {
|
||||||
|
padding: $semantic-spacing-component-sm;
|
||||||
|
gap: $semantic-spacing-component-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) {
|
||||||
|
font-family: map-get($semantic-typography-body-small, font-family);
|
||||||
|
font-size: map-get($semantic-typography-body-small, font-size);
|
||||||
|
font-weight: map-get($semantic-typography-body-small, font-weight);
|
||||||
|
line-height: map-get($semantic-typography-body-small, line-height);
|
||||||
|
|
||||||
|
.ui-color-picker__container {
|
||||||
|
padding: $semantic-spacing-component-xs;
|
||||||
|
gap: $semantic-spacing-component-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__preview {
|
||||||
|
width: 40px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__hex-input {
|
||||||
|
width: 75px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// High contrast mode
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
.ui-color-picker__wheel,
|
||||||
|
.ui-color-picker__hue-bar {
|
||||||
|
border-width: $semantic-border-width-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-color-picker__wheel-indicator,
|
||||||
|
.ui-color-picker__hue-indicator {
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduced motion
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ui-color-picker__wheel,
|
||||||
|
.ui-color-picker__hue-bar,
|
||||||
|
.ui-color-picker__hex-input {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, forwardRef, signal, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
|
|
||||||
|
export type ColorPickerSize = 'sm' | 'md' | 'lg';
|
||||||
|
export type ColorPickerVariant = 'primary' | 'secondary' | 'success' | 'danger';
|
||||||
|
export type ColorPickerState = 'default' | 'error' | 'disabled';
|
||||||
|
|
||||||
|
interface HSL {
|
||||||
|
h: number; // 0-360
|
||||||
|
s: number; // 0-100
|
||||||
|
l: number; // 0-100
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RGB {
|
||||||
|
r: number; // 0-255
|
||||||
|
g: number; // 0-255
|
||||||
|
b: number; // 0-255
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-color-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
encapsulation: ViewEncapsulation.None,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => ColorPickerComponent),
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="ui-color-picker"
|
||||||
|
[class]="getWrapperClasses()"
|
||||||
|
[attr.aria-disabled]="disabled">
|
||||||
|
|
||||||
|
@if (label) {
|
||||||
|
<label class="ui-color-picker__label" [for]="pickerId">
|
||||||
|
{{ label }}
|
||||||
|
@if (required) {
|
||||||
|
<span class="ui-color-picker__required">*</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="ui-color-picker__container">
|
||||||
|
<!-- Color Wheel -->
|
||||||
|
<div class="ui-color-picker__wheel-container">
|
||||||
|
<canvas
|
||||||
|
#colorWheel
|
||||||
|
class="ui-color-picker__wheel"
|
||||||
|
[width]="wheelSize()"
|
||||||
|
[height]="wheelSize()"
|
||||||
|
[attr.tabindex]="disabled ? -1 : 0"
|
||||||
|
[attr.role]="'slider'"
|
||||||
|
[attr.aria-label]="'Color saturation and lightness picker'"
|
||||||
|
[attr.aria-valuetext]="getWheelAriaText()"
|
||||||
|
(mousedown)="startWheelDrag($event)"
|
||||||
|
(keydown)="handleWheelKeydown($event)">
|
||||||
|
</canvas>
|
||||||
|
<div
|
||||||
|
class="ui-color-picker__wheel-indicator"
|
||||||
|
[style.left.px]="wheelIndicatorPosition().x"
|
||||||
|
[style.top.px]="wheelIndicatorPosition().y"
|
||||||
|
[attr.aria-hidden]="true">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hue Bar -->
|
||||||
|
<div class="ui-color-picker__hue-container">
|
||||||
|
<canvas
|
||||||
|
#hueBar
|
||||||
|
class="ui-color-picker__hue-bar"
|
||||||
|
[width]="hueBarWidth()"
|
||||||
|
[height]="hueBarHeight()"
|
||||||
|
[attr.tabindex]="disabled ? -1 : 0"
|
||||||
|
[attr.role]="'slider'"
|
||||||
|
[attr.aria-label]="'Hue picker'"
|
||||||
|
[attr.aria-valuemin]="0"
|
||||||
|
[attr.aria-valuemax]="360"
|
||||||
|
[attr.aria-valuenow]="currentColor().h"
|
||||||
|
(mousedown)="startHueDrag($event)"
|
||||||
|
(keydown)="handleHueKeydown($event)">
|
||||||
|
</canvas>
|
||||||
|
<div
|
||||||
|
class="ui-color-picker__hue-indicator"
|
||||||
|
[style.left.px]="hueIndicatorPosition()"
|
||||||
|
[attr.aria-hidden]="true">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color Preview -->
|
||||||
|
<div class="ui-color-picker__preview-container">
|
||||||
|
<div
|
||||||
|
class="ui-color-picker__preview"
|
||||||
|
[style.background-color]="getHexColor()"
|
||||||
|
[attr.aria-label]="'Selected color: ' + getHexColor()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hex Input -->
|
||||||
|
<div class="ui-color-picker__hex-container">
|
||||||
|
<label class="ui-color-picker__hex-label" [for]="pickerId + '-hex'">HEX</label>
|
||||||
|
<input
|
||||||
|
#hexInput
|
||||||
|
type="text"
|
||||||
|
class="ui-color-picker__hex-input"
|
||||||
|
[id]="pickerId + '-hex'"
|
||||||
|
[value]="getHexColor()"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[attr.maxlength]="7"
|
||||||
|
[attr.pattern]="'^#[0-9A-Fa-f]{6}$'"
|
||||||
|
(input)="handleHexInput($event)"
|
||||||
|
(blur)="handleHexBlur($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (description) {
|
||||||
|
<div class="ui-color-picker__description" [id]="pickerId + '-desc'">
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styleUrl: './color-picker.component.scss'
|
||||||
|
})
|
||||||
|
export class ColorPickerComponent implements ControlValueAccessor, AfterViewInit, OnDestroy {
|
||||||
|
@Input() label: string = '';
|
||||||
|
@Input() description: string = '';
|
||||||
|
@Input() size: ColorPickerSize = 'md';
|
||||||
|
@Input() variant: ColorPickerVariant = 'primary';
|
||||||
|
@Input() state: ColorPickerState = 'default';
|
||||||
|
@Input() disabled = false;
|
||||||
|
@Input() required = false;
|
||||||
|
@Input() pickerId: string = `color-picker-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
@Output() colorChange = new EventEmitter<string>();
|
||||||
|
@Output() colorFocus = new EventEmitter<FocusEvent>();
|
||||||
|
@Output() colorBlur = new EventEmitter<FocusEvent>();
|
||||||
|
|
||||||
|
@ViewChild('colorWheel') colorWheelCanvas!: ElementRef<HTMLCanvasElement>;
|
||||||
|
@ViewChild('hueBar') hueBarCanvas!: ElementRef<HTMLCanvasElement>;
|
||||||
|
@ViewChild('hexInput') hexInputElement!: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
protected currentColor = signal<HSL>({ h: 0, s: 100, l: 50 });
|
||||||
|
private isDraggingWheel = false;
|
||||||
|
private isDraggingHue = false;
|
||||||
|
private wheelCtx?: CanvasRenderingContext2D;
|
||||||
|
private hueCtx?: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
// ControlValueAccessor implementation
|
||||||
|
private onChange = (value: string) => {};
|
||||||
|
private onTouched = () => {};
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.initializeCanvases();
|
||||||
|
this.drawColorWheel();
|
||||||
|
this.drawHueBar();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.removeEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue(value: string): void {
|
||||||
|
if (value && this.isValidHexColor(value)) {
|
||||||
|
const hsl = this.hexToHsl(value);
|
||||||
|
this.currentColor.set(hsl);
|
||||||
|
this.updateCanvases();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: (value: string) => void): void {
|
||||||
|
this.onChange = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerOnTouched(fn: () => void): void {
|
||||||
|
this.onTouched = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisabledState(isDisabled: boolean): void {
|
||||||
|
this.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
wheelSize(): number {
|
||||||
|
switch (this.size) {
|
||||||
|
case 'sm': return 120;
|
||||||
|
case 'lg': return 200;
|
||||||
|
default: return 160;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hueBarWidth(): number {
|
||||||
|
return this.wheelSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
hueBarHeight(): number {
|
||||||
|
switch (this.size) {
|
||||||
|
case 'sm': return 16;
|
||||||
|
case 'lg': return 24;
|
||||||
|
default: return 20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getWrapperClasses(): string {
|
||||||
|
const classes = [
|
||||||
|
`ui-color-picker--${this.size}`,
|
||||||
|
`ui-color-picker--${this.variant}`,
|
||||||
|
`ui-color-picker--${this.state}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.disabled) classes.push('ui-color-picker--disabled');
|
||||||
|
if (this.required) classes.push('ui-color-picker--required');
|
||||||
|
|
||||||
|
return classes.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
wheelIndicatorPosition(): { x: number, y: number } {
|
||||||
|
const size = this.wheelSize();
|
||||||
|
const radius = size / 2;
|
||||||
|
const center = radius;
|
||||||
|
|
||||||
|
// Convert HSL to position on wheel
|
||||||
|
const saturation = this.currentColor().s / 100;
|
||||||
|
const lightness = this.currentColor().l / 100;
|
||||||
|
|
||||||
|
// Calculate distance from center (0 = center, 1 = edge)
|
||||||
|
const distance = saturation * (radius - 8);
|
||||||
|
|
||||||
|
// Calculate angle for lightness (0 = top, increases clockwise)
|
||||||
|
const angle = (lightness * 360 - 90) * (Math.PI / 180);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: center + Math.cos(angle) * distance - 6,
|
||||||
|
y: center + Math.sin(angle) * distance - 6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
hueIndicatorPosition(): number {
|
||||||
|
const width = this.hueBarWidth();
|
||||||
|
return (this.currentColor().h / 360) * width - 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHexColor(): string {
|
||||||
|
return this.hslToHex(this.currentColor());
|
||||||
|
}
|
||||||
|
|
||||||
|
getWheelAriaText(): string {
|
||||||
|
const { s, l } = this.currentColor();
|
||||||
|
return `Saturation ${Math.round(s)}%, Lightness ${Math.round(l)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeCanvases(): void {
|
||||||
|
if (this.colorWheelCanvas?.nativeElement) {
|
||||||
|
this.wheelCtx = this.colorWheelCanvas.nativeElement.getContext('2d') || undefined;
|
||||||
|
}
|
||||||
|
if (this.hueBarCanvas?.nativeElement) {
|
||||||
|
this.hueCtx = this.hueBarCanvas.nativeElement.getContext('2d') || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawColorWheel(): void {
|
||||||
|
if (!this.wheelCtx) return;
|
||||||
|
|
||||||
|
const size = this.wheelSize();
|
||||||
|
const radius = size / 2;
|
||||||
|
const center = radius;
|
||||||
|
|
||||||
|
this.wheelCtx.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
// Create saturation/lightness wheel with current hue
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const dx = x - center;
|
||||||
|
const dy = y - center;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance <= radius - 8) {
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const saturation = (distance / (radius - 8)) * 100;
|
||||||
|
const lightness = ((angle + Math.PI) / (2 * Math.PI)) * 100;
|
||||||
|
|
||||||
|
const color = this.hslToRgb({
|
||||||
|
h: this.currentColor().h,
|
||||||
|
s: saturation,
|
||||||
|
l: lightness
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wheelCtx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
|
||||||
|
this.wheelCtx.fillRect(x, y, 1, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawHueBar(): void {
|
||||||
|
if (!this.hueCtx) return;
|
||||||
|
|
||||||
|
const width = this.hueBarWidth();
|
||||||
|
const height = this.hueBarHeight();
|
||||||
|
|
||||||
|
this.hueCtx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Create hue gradient
|
||||||
|
const gradient = this.hueCtx.createLinearGradient(0, 0, width, 0);
|
||||||
|
for (let i = 0; i <= 360; i += 30) {
|
||||||
|
gradient.addColorStop(i / 360, `hsl(${i}, 100%, 50%)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hueCtx.fillStyle = gradient;
|
||||||
|
this.hueCtx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCanvases(): void {
|
||||||
|
this.drawColorWheel();
|
||||||
|
// Hue bar doesn't need redrawing unless size changes
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||||
|
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||||
|
document.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||||
|
document.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeEventListeners(): void {
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp.bind(this));
|
||||||
|
document.removeEventListener('touchmove', this.handleTouchMove.bind(this));
|
||||||
|
document.removeEventListener('touchend', this.handleTouchEnd.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
startWheelDrag(event: MouseEvent): void {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
|
this.isDraggingWheel = true;
|
||||||
|
this.updateWheelFromEvent(event);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
startHueDrag(event: MouseEvent): void {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
|
this.isDraggingHue = true;
|
||||||
|
this.updateHueFromEvent(event);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove(event: MouseEvent): void {
|
||||||
|
if (this.isDraggingWheel) {
|
||||||
|
this.updateWheelFromEvent(event);
|
||||||
|
} else if (this.isDraggingHue) {
|
||||||
|
this.updateHueFromEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseUp(): void {
|
||||||
|
this.isDraggingWheel = false;
|
||||||
|
this.isDraggingHue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTouchMove(event: TouchEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const touch = event.touches[0];
|
||||||
|
if (this.isDraggingWheel) {
|
||||||
|
this.updateWheelFromEvent(touch);
|
||||||
|
} else if (this.isDraggingHue) {
|
||||||
|
this.updateHueFromEvent(touch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTouchEnd(): void {
|
||||||
|
this.isDraggingWheel = false;
|
||||||
|
this.isDraggingHue = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateWheelFromEvent(event: MouseEvent | Touch): void {
|
||||||
|
if (!this.colorWheelCanvas?.nativeElement) return;
|
||||||
|
|
||||||
|
const rect = this.colorWheelCanvas.nativeElement.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
|
const size = this.wheelSize();
|
||||||
|
const radius = size / 2;
|
||||||
|
const center = radius;
|
||||||
|
|
||||||
|
const dx = x - center;
|
||||||
|
const dy = y - center;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (distance <= radius - 8) {
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
const saturation = Math.min(100, (distance / (radius - 8)) * 100);
|
||||||
|
const lightness = ((angle + Math.PI) / (2 * Math.PI)) * 100;
|
||||||
|
|
||||||
|
const newColor = {
|
||||||
|
...this.currentColor(),
|
||||||
|
s: saturation,
|
||||||
|
l: lightness
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentColor.set(newColor);
|
||||||
|
this.emitColorChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateHueFromEvent(event: MouseEvent | Touch): void {
|
||||||
|
if (!this.hueBarCanvas?.nativeElement) return;
|
||||||
|
|
||||||
|
const rect = this.hueBarCanvas.nativeElement.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left;
|
||||||
|
|
||||||
|
const width = this.hueBarWidth();
|
||||||
|
const hue = Math.max(0, Math.min(360, (x / width) * 360));
|
||||||
|
|
||||||
|
const newColor = {
|
||||||
|
...this.currentColor(),
|
||||||
|
h: hue
|
||||||
|
};
|
||||||
|
|
||||||
|
this.currentColor.set(newColor);
|
||||||
|
this.drawColorWheel(); // Redraw wheel with new hue
|
||||||
|
this.emitColorChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWheelKeydown(event: KeyboardEvent): void {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
|
const step = event.shiftKey ? 10 : 1;
|
||||||
|
let { h, s, l } = this.currentColor();
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
l = Math.min(100, l + step);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
l = Math.max(0, l - step);
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
s = Math.max(0, s - step);
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
s = Math.min(100, s + step);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentColor.set({ h, s, l });
|
||||||
|
this.emitColorChange();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHueKeydown(event: KeyboardEvent): void {
|
||||||
|
if (this.disabled) return;
|
||||||
|
|
||||||
|
const step = event.shiftKey ? 10 : 1;
|
||||||
|
let { h, s, l } = this.currentColor();
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
h = (h - step + 360) % 360;
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
h = (h + step) % 360;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentColor.set({ h, s, l });
|
||||||
|
this.drawColorWheel();
|
||||||
|
this.emitColorChange();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHexInput(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
let value = input.value.trim();
|
||||||
|
|
||||||
|
// Add # if missing
|
||||||
|
if (value && !value.startsWith('#')) {
|
||||||
|
value = '#' + value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isValidHexColor(value)) {
|
||||||
|
const hsl = this.hexToHsl(value);
|
||||||
|
this.currentColor.set(hsl);
|
||||||
|
this.updateCanvases();
|
||||||
|
this.emitColorChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHexBlur(event: FocusEvent): void {
|
||||||
|
this.onTouched();
|
||||||
|
this.colorBlur.emit(event);
|
||||||
|
|
||||||
|
// Reset to valid color if invalid input
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (!this.isValidHexColor(input.value)) {
|
||||||
|
input.value = this.getHexColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitColorChange(): void {
|
||||||
|
const hex = this.getHexColor();
|
||||||
|
this.onChange(hex);
|
||||||
|
this.colorChange.emit(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidHexColor(hex: string): boolean {
|
||||||
|
return /^#[0-9A-Fa-f]{6}$/.test(hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hslToRgb(hsl: HSL): RGB {
|
||||||
|
const h = hsl.h / 360;
|
||||||
|
const s = hsl.s / 100;
|
||||||
|
const l = hsl.l / 100;
|
||||||
|
|
||||||
|
let r, g, b;
|
||||||
|
|
||||||
|
if (s === 0) {
|
||||||
|
r = g = b = l;
|
||||||
|
} else {
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1;
|
||||||
|
if (t > 1) t -= 1;
|
||||||
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1/2) return q;
|
||||||
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
|
||||||
|
r = hue2rgb(p, q, h + 1/3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1/3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Math.round(r * 255),
|
||||||
|
g: Math.round(g * 255),
|
||||||
|
b: Math.round(b * 255)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rgbToHsl(rgb: RGB): HSL {
|
||||||
|
const r = rgb.r / 255;
|
||||||
|
const g = rgb.g / 255;
|
||||||
|
const b = rgb.b / 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const diff = max - min;
|
||||||
|
|
||||||
|
let h = 0;
|
||||||
|
let s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
|
||||||
|
if (diff !== 0) {
|
||||||
|
s = l > 0.5 ? diff / (2 - max - min) : diff / (max + min);
|
||||||
|
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / diff + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / diff + 2;
|
||||||
|
break;
|
||||||
|
case b:
|
||||||
|
h = (r - g) / diff + 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Math.round(h * 360),
|
||||||
|
s: Math.round(s * 100),
|
||||||
|
l: Math.round(l * 100)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hexToHsl(hex: string): HSL {
|
||||||
|
const rgb = this.hexToRgb(hex);
|
||||||
|
return this.rgbToHsl(rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hslToHex(hsl: HSL): string {
|
||||||
|
const rgb = this.hslToRgb(hsl);
|
||||||
|
return this.rgbToHex(rgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hexToRgb(hex: string): RGB {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return { r, g, b };
|
||||||
|
}
|
||||||
|
|
||||||
|
private rgbToHex(rgb: RGB): string {
|
||||||
|
const toHex = (n: number) => n.toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './color-picker.component';
|
||||||
@@ -3,10 +3,11 @@ export * from './input';
|
|||||||
export * from './radio';
|
export * from './radio';
|
||||||
export * from './search';
|
export * from './search';
|
||||||
export * from './switch';
|
export * from './switch';
|
||||||
export * from './select.component';
|
export * from './select/select.component';
|
||||||
export * from './autocomplete';
|
export * from './autocomplete';
|
||||||
export * from './date-picker';
|
export * from './date-picker';
|
||||||
export * from './time-picker';
|
export * from './time-picker';
|
||||||
export * from './file-upload';
|
export * from './file-upload';
|
||||||
export * from './form-field';
|
export * from './form-field';
|
||||||
export * from './range-slider';
|
export * from './range-slider';
|
||||||
|
export * from './color-picker';
|
||||||
|
|||||||
Reference in New Issue
Block a user