diff --git a/projects/demo-ui-essentials/src/app/demos/color-picker-demo/color-picker-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/color-picker-demo/color-picker-demo.component.scss new file mode 100644 index 0000000..643a460 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/color-picker-demo/color-picker-demo.component.scss @@ -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; + } + } + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/color-picker-demo/color-picker-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/color-picker-demo/color-picker-demo.component.ts new file mode 100644 index 0000000..cc64a7a --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/color-picker-demo/color-picker-demo.component.ts @@ -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: ` +
+

Color Picker Component Showcase

+ + +
+

Basic Color Picker

+
+ +
+

Selected: {{ basicColor() }}

+
+
+
+
+ + +
+

Size Variants

+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+

Color Variants

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + +
+

States

+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+

Required Field

+
+ +
+
+ + +
+

Reactive Forms Integration

+
+
+ + +
+
+

Form Values:

+
{{ getFormValues() }}
+
+
+

Theme Preview:

+
+ Sample text with selected colors +
+
+
+
+ + +
+

Interactive Demo

+
+ +
+

Event Log:

+
+ @for (event of eventLog(); track event.id) { +
+ {{ event.type }} + {{ event.value }} + {{ event.time }} +
+ } +
+ +
+
+
+ + +
+

Preset Colors

+
+ +
+

Quick Colors:

+
+ @for (color of presetColors; track color.name) { + + } +
+
+
+
+
+ `, + styleUrl: './color-picker-demo.component.scss' +}) +export class ColorPickerDemoComponent { + // Basic demo colors + basicColor = signal('#3498db'); + + // Size variants + sizeSmall = signal('#e74c3c'); + sizeMedium = signal('#2ecc71'); + sizeLarge = signal('#f39c12'); + + // Color variants + variantPrimary = signal('#3498db'); + variantSecondary = signal('#95a5a6'); + variantSuccess = signal('#2ecc71'); + variantDanger = signal('#e74c3c'); + + // States + stateDefault = signal('#9b59b6'); + stateError = signal('#e74c3c'); + stateDisabled = signal('#bdc3c7'); + + // Required field + requiredColor = signal('#1abc9c'); + + // Interactive demo + interactiveColor = signal('#34495e'); + eventLog = signal>([]); + private eventIdCounter = 0; + + // Preset colors + presetColor = signal('#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); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts index 0261efa..4e910ae 100644 --- a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts +++ b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts @@ -42,6 +42,7 @@ import { OverlayContainerDemoComponent } from './overlay-container-demo/overlay- import { LoadingSpinnerDemoComponent } from './loading-spinner-demo/loading-spinner-demo.component'; import { ProgressCircleDemoComponent } from './progress-circle-demo/progress-circle-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 { TooltipDemoComponent } from './tooltip-demo/tooltip-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 { EnhancedTableDemoComponent } from './enhanced-table-demo/enhanced-table-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({ @@ -221,6 +225,10 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo. } + @case ("color-picker") { + + } + @case ("divider") { } @@ -273,6 +281,18 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo. } + @case ("command-palette") { + + } + + @case ("floating-toolbar") { + + } + + @case ("transfer-list") { + + } + } `, @@ -287,8 +307,8 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo. GridSystemDemoComponent, SpacerDemoComponent, ContainerDemoComponent, PaginationDemoComponent, SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent, AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent, - ProgressCircleDemoComponent, RangeSliderDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent, - PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent] + ProgressCircleDemoComponent, RangeSliderDemoComponent, ColorPickerDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent, + PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent, CommandPaletteDemoComponent, FloatingToolbarDemoComponent, TransferListDemoComponent] }) diff --git a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts index 1f0c112..0a48321 100644 --- a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts +++ b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts @@ -10,7 +10,7 @@ import { faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle, faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders, 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'; import { DemoRoutes } from '../../demos'; import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component'; @@ -104,6 +104,9 @@ export class DashboardComponent { faChevronUp = faChevronUp; faEllipsisV = faEllipsisV; faCut = faCut; + faPalette = faPalette; + faExchangeAlt = faExchangeAlt; + faTools = faTools; menuItems: any = [] @@ -149,7 +152,8 @@ export class DashboardComponent { this.createChildItem("time-picker", "Time Picker", this.faClock), this.createChildItem("file-upload", "File Upload", this.faCloudUploadAlt), 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); @@ -166,7 +170,8 @@ export class DashboardComponent { this.createChildItem("divider", "Divider", this.faMinus), this.createChildItem("timeline", "Timeline", this.faStream), 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); @@ -224,7 +229,9 @@ export class DashboardComponent { this.createChildItem("drawer", "Drawer/Sidebar", this.faBars), this.createChildItem("popover", "Popover/Dropdown", this.faCaretUp), 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); diff --git a/projects/ui-essentials/src/lib/components/forms/color-picker/color-picker.component.scss b/projects/ui-essentials/src/lib/components/forms/color-picker/color-picker.component.scss new file mode 100644 index 0000000..8ba3460 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/forms/color-picker/color-picker.component.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/forms/color-picker/color-picker.component.ts b/projects/ui-essentials/src/lib/components/forms/color-picker/color-picker.component.ts new file mode 100644 index 0000000..d5c7ad2 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/forms/color-picker/color-picker.component.ts @@ -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: ` +
+ + @if (label) { + + } + +
+ +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+
+
+
+ + +
+ + +
+
+ + @if (description) { +
+ {{ description }} +
+ } +
+ `, + 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(); + @Output() colorFocus = new EventEmitter(); + @Output() colorBlur = new EventEmitter(); + + @ViewChild('colorWheel') colorWheelCanvas!: ElementRef; + @ViewChild('hueBar') hueBarCanvas!: ElementRef; + @ViewChild('hexInput') hexInputElement!: ElementRef; + + protected currentColor = signal({ 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(); + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/forms/color-picker/index.ts b/projects/ui-essentials/src/lib/components/forms/color-picker/index.ts new file mode 100644 index 0000000..a4241b4 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/forms/color-picker/index.ts @@ -0,0 +1 @@ +export * from './color-picker.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/forms/index.ts b/projects/ui-essentials/src/lib/components/forms/index.ts index e0fad01..e7e302a 100644 --- a/projects/ui-essentials/src/lib/components/forms/index.ts +++ b/projects/ui-essentials/src/lib/components/forms/index.ts @@ -3,10 +3,11 @@ export * from './input'; export * from './radio'; export * from './search'; export * from './switch'; -export * from './select.component'; +export * from './select/select.component'; export * from './autocomplete'; export * from './date-picker'; export * from './time-picker'; export * from './file-upload'; export * from './form-field'; export * from './range-slider'; +export * from './color-picker';