frontend(shared): polish color-picker, double-slider and select-add widgets

This commit is contained in:
Andras Schmelczer 2026-05-31 10:49:26 +01:00
parent 85d565ba7b
commit 9b8bb96001
3 changed files with 199 additions and 71 deletions

View file

@ -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 });

View file

@ -18,7 +18,7 @@ export interface DoubleSliderRange<T> {
* 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<T> {
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<T> {
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<T> {
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<T> {
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<DoubleSliderRange<unknown>>();
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%)';

View file

@ -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()"
>
<div class="background" [class.active]="open()"></div>
<div class="top" (click)="open.update(v => !v)">
<div
class="top"
role="button"
tabindex="0"
[attr.aria-expanded]="open()"
(click)="toggleOpen($event)"
(keydown.enter)="toggleOpen($event)"
(keydown.space)="$event.preventDefault(); toggleOpen($event)"
>
<p>{{ resolvedSelected() ?? placeholder() }}</p>
<img class="arrow" [class.upside-down]="open()" src="assets/arrow.svg" alt="" />
</div>
@ -30,10 +39,13 @@ import {
<input
type="text"
[value]="item"
maxlength="200"
(blur)="onRename(item, $any($event.target).value)"
/>
} @else {
<p (click)="onSelectItem(item)">{{ item }}</p>
<button class="option" type="button" (click)="onSelectItem(item)">
{{ item }}
</button>
}
}
<div class="add-row">
@ -41,11 +53,23 @@ import {
type="text"
#addInput
placeholder="Add a value…"
maxlength="200"
(keydown.enter)="onAdd(addInput.value); addInput.value = ''"
/>
<button (click)="onAdd(addInput.value); addInput.value = ''">Add</button>
<button
class="add-button"
type="button"
(click)="onAdd(addInput.value); addInput.value = ''"
>
Add
</button>
@if (editable()) {
<button class="pen" [class.active]="editing()" (click)="editing.update(v => !v)">
<button
class="pen"
type="button"
[class.active]="editing()"
(click)="editing.update(v => !v)"
>
<img src="assets/pen.svg" alt="Edit" />
</button>
}
@ -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<string[]>([]);
@ -309,6 +403,7 @@ export class SelectAddComponent implements OnChanges {
// ── Internal state ─────────────────────────────────────────────────────────
readonly open = signal(false);
readonly editing = signal(false);
private readonly host = inject(ElementRef<HTMLElement>);
// 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 {