Skip to content

Commit e9fbcc3

Browse files
committed
feat(component): add raw slider
1 parent f60963b commit e9fbcc3

File tree

8 files changed

+669
-0
lines changed

8 files changed

+669
-0
lines changed

packages/components/src/components.d.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { HeadingLevel } from "./types/index";
99
import { BannerType } from "./components/post-banner/banner-types";
1010
import { SwitchVariant } from "./components/post-language-switch/switch-variants";
1111
import { Placement } from "@floating-ui/dom";
12+
import { Orientation } from "./components/post-slider/orientation";
1213
export { HeadingLevel } from "./types/index";
1314
export { BannerType } from "./components/post-banner/banner-types";
1415
export { SwitchVariant } from "./components/post-language-switch/switch-variants";
1516
export { Placement } from "@floating-ui/dom";
17+
export { Orientation } from "./components/post-slider/orientation";
1618
export namespace Components {
1719
interface PostAccordion {
1820
/**
@@ -450,6 +452,37 @@ export namespace Components {
450452
*/
451453
"stars": number;
452454
}
455+
interface PostSlider {
456+
/**
457+
* The greatest value in the range of permitted values.
458+
* @default 100
459+
*/
460+
"max": number;
461+
/**
462+
* The lowest value in the range of permitted values.
463+
* @default 0
464+
*/
465+
"min": number;
466+
/**
467+
* The orientation of the slider: "horizontal" or "vertical".
468+
* @default 'horizontal'
469+
*/
470+
"orient": Orientation;
471+
/**
472+
* If true, the slider has two thumbs allowing for range selection.
473+
* @default false
474+
*/
475+
"range": boolean;
476+
/**
477+
* The granularity that the value must adhere to.
478+
* @default 1
479+
*/
480+
"step": number | 'any';
481+
/**
482+
* The number or range initially selected.
483+
*/
484+
"value"?: number | [number, number];
485+
}
453486
interface PostTabHeader {
454487
/**
455488
* The name of the panel controlled by the tab header.
@@ -564,6 +597,10 @@ export interface PostRatingCustomEvent<T> extends CustomEvent<T> {
564597
detail: T;
565598
target: HTMLPostRatingElement;
566599
}
600+
export interface PostSliderCustomEvent<T> extends CustomEvent<T> {
601+
detail: T;
602+
target: HTMLPostSliderElement;
603+
}
567604
export interface PostTabsCustomEvent<T> extends CustomEvent<T> {
568605
detail: T;
569606
target: HTMLPostTabsElement;
@@ -840,6 +877,24 @@ declare global {
840877
prototype: HTMLPostRatingElement;
841878
new (): HTMLPostRatingElement;
842879
};
880+
interface HTMLPostSliderElementEventMap {
881+
"postInput": number | [number, number];
882+
"postChange": number | [number, number];
883+
}
884+
interface HTMLPostSliderElement extends Components.PostSlider, HTMLStencilElement {
885+
addEventListener<K extends keyof HTMLPostSliderElementEventMap>(type: K, listener: (this: HTMLPostSliderElement, ev: PostSliderCustomEvent<HTMLPostSliderElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
886+
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
887+
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
888+
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
889+
removeEventListener<K extends keyof HTMLPostSliderElementEventMap>(type: K, listener: (this: HTMLPostSliderElement, ev: PostSliderCustomEvent<HTMLPostSliderElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
890+
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
891+
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
892+
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
893+
}
894+
var HTMLPostSliderElement: {
895+
prototype: HTMLPostSliderElement;
896+
new (): HTMLPostSliderElement;
897+
};
843898
interface HTMLPostTabHeaderElement extends Components.PostTabHeader, HTMLStencilElement {
844899
}
845900
var HTMLPostTabHeaderElement: {
@@ -917,6 +972,7 @@ declare global {
917972
"post-popover": HTMLPostPopoverElement;
918973
"post-popovercontainer": HTMLPostPopovercontainerElement;
919974
"post-rating": HTMLPostRatingElement;
975+
"post-slider": HTMLPostSliderElement;
920976
"post-tab-header": HTMLPostTabHeaderElement;
921977
"post-tab-panel": HTMLPostTabPanelElement;
922978
"post-tabs": HTMLPostTabsElement;
@@ -1303,6 +1359,45 @@ declare namespace LocalJSX {
13031359
*/
13041360
"stars"?: number;
13051361
}
1362+
interface PostSlider {
1363+
/**
1364+
* The greatest value in the range of permitted values.
1365+
* @default 100
1366+
*/
1367+
"max"?: number;
1368+
/**
1369+
* The lowest value in the range of permitted values.
1370+
* @default 0
1371+
*/
1372+
"min"?: number;
1373+
/**
1374+
* Event dispatched when a thumb is released after sliding, payload is the current value.
1375+
*/
1376+
"onPostChange"?: (event: PostSliderCustomEvent<number | [number, number]>) => void;
1377+
/**
1378+
* Event dispatched while a thumb is sliding, payload is the current value.
1379+
*/
1380+
"onPostInput"?: (event: PostSliderCustomEvent<number | [number, number]>) => void;
1381+
/**
1382+
* The orientation of the slider: "horizontal" or "vertical".
1383+
* @default 'horizontal'
1384+
*/
1385+
"orient"?: Orientation;
1386+
/**
1387+
* If true, the slider has two thumbs allowing for range selection.
1388+
* @default false
1389+
*/
1390+
"range"?: boolean;
1391+
/**
1392+
* The granularity that the value must adhere to.
1393+
* @default 1
1394+
*/
1395+
"step"?: number | 'any';
1396+
/**
1397+
* The number or range initially selected.
1398+
*/
1399+
"value"?: number | [number, number];
1400+
}
13061401
interface PostTabHeader {
13071402
/**
13081403
* The name of the panel controlled by the tab header.
@@ -1399,6 +1494,7 @@ declare namespace LocalJSX {
13991494
"post-popover": PostPopover;
14001495
"post-popovercontainer": PostPopovercontainer;
14011496
"post-rating": PostRating;
1497+
"post-slider": PostSlider;
14021498
"post-tab-header": PostTabHeader;
14031499
"post-tab-panel": PostTabPanel;
14041500
"post-tabs": PostTabs;
@@ -1446,6 +1542,7 @@ declare module "@stencil/core" {
14461542
"post-popover": LocalJSX.PostPopover & JSXBase.HTMLAttributes<HTMLPostPopoverElement>;
14471543
"post-popovercontainer": LocalJSX.PostPopovercontainer & JSXBase.HTMLAttributes<HTMLPostPopovercontainerElement>;
14481544
"post-rating": LocalJSX.PostRating & JSXBase.HTMLAttributes<HTMLPostRatingElement>;
1545+
"post-slider": LocalJSX.PostSlider & JSXBase.HTMLAttributes<HTMLPostSliderElement>;
14491546
"post-tab-header": LocalJSX.PostTabHeader & JSXBase.HTMLAttributes<HTMLPostTabHeaderElement>;
14501547
"post-tab-panel": LocalJSX.PostTabPanel & JSXBase.HTMLAttributes<HTMLPostTabPanelElement>;
14511548
"post-tabs": LocalJSX.PostTabs & JSXBase.HTMLAttributes<HTMLPostTabsElement>;
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Orientation } from '@root/src';
2+
3+
interface Bounds {
4+
min: number;
5+
max: number;
6+
}
7+
8+
interface Neighbors {
9+
previous: HTMLElement | null;
10+
next: HTMLElement | null;
11+
}
12+
13+
function isThumb(node: Node | EventTarget): node is HTMLElement {
14+
return node instanceof HTMLElement && node.matches('[role="slider"]');
15+
}
16+
17+
function getValueNow(thumb: HTMLElement): number {
18+
return parseFloat(thumb.getAttribute('aria-valuenow'));
19+
}
20+
21+
export class ActiveThumb {
22+
el: HTMLElement;
23+
neighbors: Neighbors;
24+
positionBounds: Bounds;
25+
hostBounds: Bounds;
26+
27+
private host: HTMLElement;
28+
private isAnimationRunning = false;
29+
private animationTarget = 0;
30+
31+
get isOnlyThumb(): boolean {
32+
return !this.neighbors.previous && !this.neighbors.next;
33+
}
34+
35+
get isFirstThumb(): boolean {
36+
return !this.isOnlyThumb && !this.neighbors.previous;
37+
}
38+
39+
get value(): number {
40+
return getValueNow(this.el);
41+
}
42+
43+
get neighborValues(): { previous: number | null; next: number | null } {
44+
const previousValue = this.neighbors.previous ? getValueNow(this.neighbors.previous) : null;
45+
const nextValue = this.neighbors.next ? getValueNow(this.neighbors.next) : null;
46+
return { previous: previousValue, next: nextValue };
47+
}
48+
49+
constructor(node: Node | EventTarget, host: HTMLElement, orientation: Orientation) {
50+
if (!isThumb(node)) throw Error('An active thumb must be an HTML element with a slider role.');
51+
52+
this.el = node;
53+
this.neighbors = {
54+
previous: isThumb(this.el.previousSibling) ? this.el.previousSibling : null,
55+
next: isThumb(this.el.nextSibling) ? this.el.nextSibling : null,
56+
};
57+
58+
this.host = host;
59+
this.hostBounds = this.getHostBounds(host, orientation);
60+
61+
const minBound = this.neighbors.previous
62+
? this.getNeighbourOffset(this.neighbors.previous, orientation)
63+
: this.hostBounds.min;
64+
const maxBound = this.neighbors.next
65+
? this.getNeighbourOffset(this.neighbors.next, orientation)
66+
: this.hostBounds.max;
67+
this.positionBounds = { min: minBound, max: maxBound };
68+
69+
this.runDragAnimation = this.runDragAnimation.bind(this);
70+
}
71+
72+
private getHostBounds(host: HTMLElement, orientation: Orientation): Bounds {
73+
const rect = host.getBoundingClientRect();
74+
return orientation === 'vertical'
75+
? { min: rect.top, max: rect.bottom }
76+
: { min: rect.left, max: rect.right };
77+
}
78+
79+
private getNeighbourOffset(el: HTMLElement, orientation: Orientation): number {
80+
const rect = el.getBoundingClientRect();
81+
return orientation === 'vertical' ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
82+
}
83+
84+
private runDragAnimation() {
85+
this.isAnimationRunning = true;
86+
const cssProperty = this.isFirstThumb ? '--post-slider-fill-start' : '--post-slider-fill-end';
87+
this.host.style.setProperty(cssProperty, this.animationTarget.toString());
88+
requestAnimationFrame(this.runDragAnimation);
89+
}
90+
91+
setValue(value: number, relativePosition: number) {
92+
this.el.setAttribute('aria-valuenow', value.toString());
93+
this.neighbors.previous?.setAttribute('aria-valuemax', value.toString());
94+
this.neighbors.next?.setAttribute('aria-valuemin', value.toString());
95+
96+
// updating position depending on the animation frame rate
97+
this.animationTarget = relativePosition;
98+
if (!this.isAnimationRunning) this.runDragAnimation();
99+
}
100+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export const ORIENTATIONS = ['horizontal', 'vertical'] as const;
2+
3+
export type Orientation = (typeof ORIENTATIONS)[number];
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
@use '@swisspost/design-system-styles/variables/elevation';
2+
3+
:host {
4+
--post-slider-thumb-size: 1.75rem;
5+
--post-slider-track-size: .75rem;
6+
--post-slider-fill-start: 0;
7+
--post-slider-fill-end: 1;
8+
9+
display: block;
10+
position: relative;
11+
height: var(--post-slider-thumb-size);
12+
container-type: inline-size;
13+
touch-action: none;
14+
15+
&::before,
16+
&::after {
17+
content: '';
18+
display: block;
19+
position: absolute;
20+
inset-block: calc(50% - var(--post-slider-track-size) / 2);
21+
inset-inline: 0;
22+
}
23+
24+
// slider track
25+
&::before {
26+
background-color: #f0efed;
27+
}
28+
29+
// slider fill
30+
&::after {
31+
--post-slider-fill-scale: calc(var(--post-slider-fill-end) - var(--post-slider-fill-start));
32+
background-color: #050400;
33+
transform-origin: left;
34+
transform: translateX(calc(var(--post-slider-fill-start) * 100cqw)) scaleX(var(--post-slider-fill-scale));
35+
}
36+
37+
// slider thumbs
38+
[role="slider"] {
39+
--post-slider-thumb-scale: 1;
40+
41+
z-index: 1;
42+
box-sizing: border-box;
43+
height: var(--post-slider-thumb-size);
44+
width: var(--post-slider-thumb-size);
45+
margin-inline-start: calc(var(--post-slider-thumb-size) / -2);
46+
position: absolute;
47+
inset-block-start: 0;
48+
inset-inline-start: 0;
49+
background-color: #050400;
50+
border: 3px solid #fff;
51+
border-radius: 50%;
52+
box-shadow: elevation.$elevation-100;
53+
will-change: transform;
54+
transform: translateX(calc(var(--post-slider-thumb-position) * 100cqw)) scale(var(--post-slider-thumb-scale));
55+
transition: background-color 200ms;
56+
57+
&:not(:last-child) {
58+
--post-slider-thumb-position: var(--post-slider-fill-start);
59+
}
60+
61+
&:last-child {
62+
--post-slider-thumb-position: var(--post-slider-fill-end);
63+
}
64+
65+
&:is(:hover, .active) {
66+
background-color: #504f4b;
67+
}
68+
}
69+
}
70+
71+
:host([orient="vertical"]) {
72+
writing-mode: vertical-lr;
73+
height: unset;
74+
min-height: 15rem;
75+
width: var(--post-slider-thumb-size);
76+
77+
&::after {
78+
transform-origin: top;
79+
transform: translateY(calc(var(--post-slider-fill-start) * 100cqh)) scaleY(var(--post-slider-fill-scale));
80+
}
81+
82+
[role="slider"] {
83+
transform: translateY(calc(var(--post-slider-thumb-position) * 100cqh)) scale(var(--post-slider-thumb-scale));
84+
}
85+
}

0 commit comments

Comments
 (0)