From 9b8bb960011808da6cd1674a3b41793f0aa6dca2 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 31 May 2026 10:49:26 +0100 Subject: [PATCH] frontend(shared): polish color-picker, double-slider and select-add widgets --- .../color-picker/color-picker.component.ts | 41 ++-- .../double-slider/double-slider.component.ts | 52 +++-- .../shared/select-add/select-add.component.ts | 177 ++++++++++++++---- 3 files changed, 199 insertions(+), 71 deletions(-) diff --git a/frontend/src/app/components/shared/color-picker/color-picker.component.ts b/frontend/src/app/components/shared/color-picker/color-picker.component.ts index fe5104c..e12ee89 100644 --- a/frontend/src/app/components/shared/color-picker/color-picker.component.ts +++ b/frontend/src/app/components/shared/color-picker/color-picker.component.ts @@ -61,7 +61,10 @@ const FIXED_L = 0.55; display: block; padding: var(--medium-padding); @include card(); - box-shadow: $shadow-border; + border: 1px solid rgba($text-color, 0.14); + box-shadow: inset 0 0 0 1px rgba($light-color, 0.7); + background-color: rgba($text-color, 0.025); + box-sizing: border-box; } .picker { @@ -76,35 +79,40 @@ const FIXED_L = 0.55; grid-template-columns: repeat(12, 1fr); gap: 6px; + @media (max-width: $mobile-width) { + grid-template-columns: repeat(6, 1fr); + gap: var(--small-padding); + } + .swatch { all: unset; cursor: pointer; aspect-ratio: 1; border-radius: 4px; - box-shadow: $shadow-border; + box-shadow: 0 0 0 1px rgba($text-color, 0.18); transition: transform $short-animation-time, box-shadow $long-animation-time; &:hover, &:focus-visible { - box-shadow: $shadow; + box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5); transform: scale(1.1); } &.active { - box-shadow: $shadow; + box-shadow: 0 0 0 2px $light-color, 0 0 0 4px rgba($text-color, 0.5); transform: scale(1.15); - outline: 2px solid $light-color; - outline-offset: 1px; } } } .hue-slider { + padding: 8px 0; + input[type='range'] { -webkit-appearance: none; appearance: none; width: 100%; - height: 12px; + height: 16px; border-radius: 1000px; background: linear-gradient( to right, @@ -119,11 +127,15 @@ const FIXED_L = 0.55; outline: none; cursor: pointer; + &:focus-visible { + box-shadow: 0 0 0 3px rgba($text-color, 0.35); + } + &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - height: 24px; - width: 24px; + height: 32px; + width: 32px; border-radius: 1000px; background-color: var(--thumb-color, #{$light-color}); box-shadow: 0 0 0 2px #{$light-color}, #{$shadow}; @@ -135,8 +147,8 @@ const FIXED_L = 0.55; } &::-moz-range-thumb { - height: 24px; - width: 24px; + height: 32px; + width: 32px; border-radius: 1000px; background-color: var(--thumb-color, white); border: 2px solid white; @@ -153,7 +165,7 @@ const FIXED_L = 0.55; .preview { height: 40px; border-radius: var(--border-radius); - box-shadow: $shadow-border; + box-shadow: 0 0 0 1px rgba($text-color, 0.18); } `, }) @@ -173,9 +185,8 @@ export class ColorPickerComponent { return `hsl(${h}, 70%, 55%)`; } - toCss(c: HslColor): string { - return toCss(c); - } + /** Re-exported so the template can call the utility directly. */ + readonly toCss = toCss; pickHue(h: number): void { this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L }); diff --git a/frontend/src/app/components/shared/double-slider/double-slider.component.ts b/frontend/src/app/components/shared/double-slider/double-slider.component.ts index b0f34ae..f4db1e4 100644 --- a/frontend/src/app/components/shared/double-slider/double-slider.component.ts +++ b/frontend/src/app/components/shared/double-slider/double-slider.component.ts @@ -18,7 +18,7 @@ export interface DoubleSliderRange { * Two-thumb range slider — legacy "double-slider". * Hands an indexed range over an arbitrary values array; emits the * underlying values on each change. Labels magnetically lift as a thumb - * approaches them (rotated -45°), per the legacy. + * approaches them (rotated -30°), per the legacy. */ @Component({ selector: 'lt-double-slider', @@ -32,7 +32,7 @@ export interface DoubleSliderRange { id="ds-1" type="range" min="0" - [max]="MAX - 1" + [max]="maxIndex()" [value]="oneValue()" (input)="oneValue.set(+$any($event.target).value)" /> @@ -40,7 +40,7 @@ export interface DoubleSliderRange { id="ds-2" type="range" min="0" - [max]="MAX - 1" + [max]="maxIndex()" [value]="otherValue()" (input)="otherValue.set(+$any($event.target).value)" /> @@ -65,6 +65,11 @@ export interface DoubleSliderRange { position: relative; margin: calc(#{$slider-size} / 2) auto 0 auto; + @media (max-width: $mobile-width) { + max-width: 90vw; + margin-top: calc(#{$slider-size} / 2); + } + label { display: none; } input[type='range'] { @@ -145,6 +150,12 @@ export interface DoubleSliderRange { transition: transform $long-animation-time; white-space: nowrap; } + + @media (max-width: $mobile-width) { + font-size: var(--small-font-size); + margin-top: $slider-size; + span { margin-top: 10px; } + } } } `, @@ -157,10 +168,9 @@ export class DoubleSliderComponent { readonly rangeChange = output>(); - readonly MAX = 100; - readonly oneValue = signal(0); - readonly otherValue = signal(this.MAX - 1); + readonly otherValue = signal(0); + readonly maxIndex = computed(() => Math.max(0, this.values().length - 1)); private prevValuesLength = 0; @@ -187,42 +197,44 @@ export class DoubleSliderComponent { const hi = Math.max(a, b); untracked(() => { this.rangeChange.emit({ - from: vs[this.indexFromValue(lo)], - to: vs[this.indexFromValue(hi)], + from: vs[this.clampIndex(lo)], + to: vs[this.clampIndex(hi)], }); }); }); - // Snap the higher thumb to MAX - 1 when a new entry is appended. + // Snap the higher thumb to the newest value when a new entry is appended. effect(() => { const len = this.values().length; untracked(() => { + const max = Math.max(0, len - 1); if (len > this.prevValuesLength) { const a = this.oneValue(); const b = this.otherValue(); - if (a > b) this.oneValue.set(this.MAX - 1); - else this.otherValue.set(this.MAX - 1); + if (a > b) this.oneValue.set(max); + else this.otherValue.set(max); + } else { + if (this.oneValue() > max) this.oneValue.set(max); + if (this.otherValue() > max) this.otherValue.set(max); } this.prevValuesLength = len; }); }); } - private indexFromValue(value: number): number { - return Math.min( - this.values().length - 1, - Math.floor((value / this.MAX) * this.values().length), - ); + private clampIndex(value: number): number { + return Math.max(0, Math.min(this.values().length - 1, Math.round(value))); } /** * Magnetic label position: returns a CSS `transform` that lifts the label - * upward and rotates -45° as a thumb approaches. + * upward and rotates -30° as a thumb approaches. */ getOffset(index: number): string { - const labelIndex = index / Math.max(1, this.drawnLabels().length); - const a = this.oneValue() / this.MAX - 0.1; - const b = this.otherValue() / this.MAX - 0.1; + const labelIndex = index / Math.max(1, this.drawnLabels().length - 1); + const max = Math.max(1, this.maxIndex()); + const a = this.oneValue() / max - 0.1; + const b = this.otherValue() / max - 0.1; const dist = Math.min(Math.abs(labelIndex - a), Math.abs(labelIndex - b)); const ACTIVE_ZONE = 0.2; const base = 'translateX(-50%) rotate(-30deg) translateY(100%)'; diff --git a/frontend/src/app/components/shared/select-add/select-add.component.ts b/frontend/src/app/components/shared/select-add/select-add.component.ts index 7e01d73..baac459 100644 --- a/frontend/src/app/components/shared/select-add/select-add.component.ts +++ b/frontend/src/app/components/shared/select-add/select-add.component.ts @@ -4,8 +4,9 @@ import { input, output, signal, - OnChanges, - SimpleChanges, + ElementRef, + HostListener, + inject, } from '@angular/core'; @Component({ @@ -19,7 +20,15 @@ import { [class.always-shadow]="alwaysDropShadow()" >
-
+

{{ resolvedSelected() ?? placeholder() }}

@@ -30,10 +39,13 @@ import { } @else { -

{{ item }}

+ } }
@@ -41,11 +53,23 @@ import { type="text" #addInput placeholder="Add a value…" + maxlength="200" (keydown.enter)="onAdd(addInput.value); addInput.value = ''" /> - + @if (editable()) { - } @@ -58,6 +82,7 @@ import { @import '../../../../library/main'; $inner-padding: var(--medium-padding); + $dropdown-shadow: 0 4px 14px rgba($text-color, 0.16), $shadow-border; :host { display: block; @@ -80,13 +105,22 @@ import { align-items: center; position: relative; cursor: pointer; + min-height: 46px; + box-sizing: border-box; + gap: var(--small-padding); p { - display: inline-block; + display: block; @include sub-title-text(); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; } img.arrow { + flex: 0 0 auto; @include square(16px); transition: transform $long-animation-time; @@ -98,81 +132,129 @@ import { .bottom-container { width: 100%; - height: 300px; position: absolute; - overflow-y: hidden; + top: 100%; + left: 0; + right: 0; + overflow: visible; pointer-events: none; + z-index: 5; .bottom { - position: absolute; + position: relative; width: 100%; - pointer-events: all; + pointer-events: none; box-sizing: border-box; display: flex; flex-direction: column; align-items: flex-start; border-radius: 0 0 var(--border-radius) var(--border-radius); padding: $inner-padding; - padding-top: 0; - @include inner-spacing($inner-padding); + padding-top: var(--small-padding); + gap: var(--small-padding); // Default (closed) state — also the target of the close transition. background-color: transparent; box-shadow: none; - transform: translateY(-100%); + transform: translateY(-8px); + opacity: 0; visibility: hidden; + // Clip the top edge so the panel's shadow can't bleed back up into + // the chip area; sides + bottom get a 10px slack for the shadow. + clip-path: inset(0 -10px -10px -10px); // Delay the visibility change until after the slide animation finishes // so the panel stays visible while it animates closed. transition: transform $long-animation-time, + opacity $long-animation-time, background-color $long-animation-time, box-shadow $long-animation-time, visibility 0s $long-animation-time; &.open { visibility: visible; + pointer-events: all; transform: none; + opacity: 1; background-color: $light-color; - box-shadow: $shadow; - // Show shadow on left/right/bottom only; clip the top edge so the - // shadow doesn't bleed over the seam where .bottom meets .top. - clip-path: inset(0 -6px -6px -6px); + box-shadow: $dropdown-shadow; // On open, visibility flips immediately (no delay); transform + // colors + shadow animate over $long-animation-time. transition: transform $long-animation-time, + opacity $long-animation-time, background-color $long-animation-time, box-shadow $long-animation-time, visibility 0s 0s; } - p { + .option { @include sub-title-text(); - display: inline-block; + display: flex; + align-items: center; + width: 100%; + min-height: 36px; + margin: 0; + padding: 0; + border: 0; + background: transparent; text-align: left; cursor: pointer; + + &:after { + display: none; + } + + @media (max-width: $mobile-width) { + min-height: 42px; + } } input[type='text'] { @include sub-title-text(); width: 100%; + min-height: 36px; + box-sizing: border-box; + text-align: left; + + &::placeholder { + color: rgba($text-color, 0.72); + opacity: 1; + } + + @media (max-width: $mobile-width) { + min-height: 42px; + } } .add-row { - height: 32px; - @media (max-width: $mobile-width) { height: 24px; } + min-height: 40px; position: relative; width: 100%; display: flex; - align-items: center; + align-items: flex-end; gap: var(--small-padding); input[type='text'] { flex: 1; + min-height: 0; + padding: 0; + border-bottom: solid 2px transparent; + + &:focus, + &:focus-visible { + box-shadow: none; + border-bottom-color: $text-color; + } } button { margin: 0; - position: static; + position: relative; + flex: 0 0 auto; + + &.add-button { + align-self: flex-end; + } &.pen { opacity: 0.25; @@ -184,6 +266,10 @@ import { background: transparent; position: relative; + // Kill the global button's hover-grow underline pseudo-element + // for the icon-only edit control. + &:after { content: none; display: none; } + img { @include square(16px); } @@ -224,16 +310,18 @@ import { width: 100%; @include card(); z-index: 3; + box-sizing: border-box; transition: box-shadow $long-animation-time, height $long-animation-time, border-radius $long-animation-time; &.active { - box-shadow: $shadow; - // Show shadow on top/left/right only; clip the bottom edge so the - // shadow doesn't bleed over the seam where .top meets .bottom. - clip-path: inset(-6px -6px 0 -6px); + // Same shadow recipe as the panel below so the two halves read as + // one continuous card. Clip the bottom edge so this shadow doesn't + // bleed across the seam where .top meets .bottom. + box-shadow: $dropdown-shadow; + clip-path: inset(-10px -10px 0 -10px); } } @@ -251,25 +339,31 @@ import { } } + // Hover lifts the chip — but only when the dropdown is closed. When + // it's open the chip is already showing $dropdown-shadow and a hover + // override would make the top heavier than the panel below. &:hover { @media (min-width: $mobile-width) { - .background { box-shadow: $shadow; } + .background:not(.active) { box-shadow: $shadow; } } } &.shadow-border { .background.active { box-shadow: $shadow-border; - clip-path: inset(-6px -6px 0 -6px); + clip-path: inset(-10px -10px 0 -10px); + } + .bottom.open { + box-shadow: $shadow-border; } } &.shadow-border:hover { - .background { box-shadow: $shadow-border; } + .background:not(.active) { box-shadow: $shadow-border; } } &.always-shadow { - .background { box-shadow: $shadow; } + .background:not(.active) { box-shadow: $shadow; } // When open, clip the bottom so the always-on shadow doesn't bleed // over the seam; restore full shadow when closed. &:has(.bottom.open) .background { @@ -279,7 +373,7 @@ import { } `, }) -export class SelectAddComponent implements OnChanges { +export class SelectAddComponent { // ── New API (spec) ───────────────────────────────────────────────────────── /** List of string options */ readonly items = input([]); @@ -309,6 +403,7 @@ export class SelectAddComponent implements OnChanges { // ── Internal state ───────────────────────────────────────────────────────── readonly open = signal(false); readonly editing = signal(false); + private readonly host = inject(ElementRef); // Resolved values that merge old + new API protected resolvedItems(): string[] { @@ -320,7 +415,7 @@ export class SelectAddComponent implements OnChanges { protected resolvedSelected(): string | null { // New API: string const s = this.selected(); - if (s != null) return s; + if (s != null && s.trim()) return s; // Legacy API: index into options const idx = this.selectedIndex(); const opts = this.resolvedItems(); @@ -328,8 +423,18 @@ export class SelectAddComponent implements OnChanges { return null; } - ngOnChanges(_changes: SimpleChanges): void { - // Nothing to do — signals handle reactivity + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this.open()) return; + if (!this.host.nativeElement.contains(event.target as Node)) { + this.open.set(false); + this.editing.set(false); + } + } + + toggleOpen(event: Event): void { + event.stopPropagation(); + this.open.update((v) => !v); } onSelectItem(item: string): void {