Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions packages/components/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { HeadingLevel } from "./types/index";
import { BannerType } from "./components/post-banner/banner-types";
import { SwitchVariant } from "./components/post-language-switch/switch-variants";
import { Placement } from "@floating-ui/dom";
import { Orientation } from "./components/post-slider/orientation";
export { HeadingLevel } from "./types/index";
export { BannerType } from "./components/post-banner/banner-types";
export { SwitchVariant } from "./components/post-language-switch/switch-variants";
export { Placement } from "@floating-ui/dom";
export { Orientation } from "./components/post-slider/orientation";
export namespace Components {
interface PostAccordion {
/**
Expand Down Expand Up @@ -450,6 +452,37 @@ export namespace Components {
*/
"stars": number;
}
interface PostSlider {
/**
* The greatest value in the range of permitted values.
* @default 100
*/
"max": number;
/**
* The lowest value in the range of permitted values.
* @default 0
*/
"min": number;
/**
* The orientation of the slider: "horizontal" or "vertical".
* @default 'horizontal'
*/
"orient": Orientation;
/**
* If true, the slider has two thumbs allowing for range selection.
* @default false
*/
"range": boolean;
/**
* The granularity that the value must adhere to.
* @default 1
*/
"step": number | 'any';
/**
* The number or range initially selected.
*/
"value"?: number | [number, number];
}
interface PostTabHeader {
/**
* The name of the panel controlled by the tab header.
Expand Down Expand Up @@ -564,6 +597,10 @@ export interface PostRatingCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLPostRatingElement;
}
export interface PostSliderCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLPostSliderElement;
}
export interface PostTabsCustomEvent<T> extends CustomEvent<T> {
detail: T;
target: HTMLPostTabsElement;
Expand Down Expand Up @@ -840,6 +877,24 @@ declare global {
prototype: HTMLPostRatingElement;
new (): HTMLPostRatingElement;
};
interface HTMLPostSliderElementEventMap {
"postInput": number | [number, number];
"postChange": number | [number, number];
}
interface HTMLPostSliderElement extends Components.PostSlider, HTMLStencilElement {
addEventListener<K extends keyof HTMLPostSliderElementEventMap>(type: K, listener: (this: HTMLPostSliderElement, ev: PostSliderCustomEvent<HTMLPostSliderElementEventMap[K]>) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
removeEventListener<K extends keyof HTMLPostSliderElementEventMap>(type: K, listener: (this: HTMLPostSliderElement, ev: PostSliderCustomEvent<HTMLPostSliderElementEventMap[K]>) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
var HTMLPostSliderElement: {
prototype: HTMLPostSliderElement;
new (): HTMLPostSliderElement;
};
interface HTMLPostTabHeaderElement extends Components.PostTabHeader, HTMLStencilElement {
}
var HTMLPostTabHeaderElement: {
Expand Down Expand Up @@ -917,6 +972,7 @@ declare global {
"post-popover": HTMLPostPopoverElement;
"post-popovercontainer": HTMLPostPopovercontainerElement;
"post-rating": HTMLPostRatingElement;
"post-slider": HTMLPostSliderElement;
"post-tab-header": HTMLPostTabHeaderElement;
"post-tab-panel": HTMLPostTabPanelElement;
"post-tabs": HTMLPostTabsElement;
Expand Down Expand Up @@ -1303,6 +1359,45 @@ declare namespace LocalJSX {
*/
"stars"?: number;
}
interface PostSlider {
/**
* The greatest value in the range of permitted values.
* @default 100
*/
"max"?: number;
/**
* The lowest value in the range of permitted values.
* @default 0
*/
"min"?: number;
/**
* Event dispatched when a thumb is released after sliding, payload is the current value.
*/
"onPostChange"?: (event: PostSliderCustomEvent<number | [number, number]>) => void;
/**
* Event dispatched while a thumb is sliding, payload is the current value.
*/
"onPostInput"?: (event: PostSliderCustomEvent<number | [number, number]>) => void;
/**
* The orientation of the slider: "horizontal" or "vertical".
* @default 'horizontal'
*/
"orient"?: Orientation;
/**
* If true, the slider has two thumbs allowing for range selection.
* @default false
*/
"range"?: boolean;
/**
* The granularity that the value must adhere to.
* @default 1
*/
"step"?: number | 'any';
/**
* The number or range initially selected.
*/
"value"?: number | [number, number];
}
interface PostTabHeader {
/**
* The name of the panel controlled by the tab header.
Expand Down Expand Up @@ -1399,6 +1494,7 @@ declare namespace LocalJSX {
"post-popover": PostPopover;
"post-popovercontainer": PostPopovercontainer;
"post-rating": PostRating;
"post-slider": PostSlider;
"post-tab-header": PostTabHeader;
"post-tab-panel": PostTabPanel;
"post-tabs": PostTabs;
Expand Down Expand Up @@ -1446,6 +1542,7 @@ declare module "@stencil/core" {
"post-popover": LocalJSX.PostPopover & JSXBase.HTMLAttributes<HTMLPostPopoverElement>;
"post-popovercontainer": LocalJSX.PostPopovercontainer & JSXBase.HTMLAttributes<HTMLPostPopovercontainerElement>;
"post-rating": LocalJSX.PostRating & JSXBase.HTMLAttributes<HTMLPostRatingElement>;
"post-slider": LocalJSX.PostSlider & JSXBase.HTMLAttributes<HTMLPostSliderElement>;
"post-tab-header": LocalJSX.PostTabHeader & JSXBase.HTMLAttributes<HTMLPostTabHeaderElement>;
"post-tab-panel": LocalJSX.PostTabPanel & JSXBase.HTMLAttributes<HTMLPostTabPanelElement>;
"post-tabs": LocalJSX.PostTabs & JSXBase.HTMLAttributes<HTMLPostTabsElement>;
Expand Down
102 changes: 102 additions & 0 deletions packages/components/src/components/post-slider/active-thumb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Orientation } from '@root/src';

interface Bounds {
min: number;
max: number;
}

interface Neighbors {
previous: HTMLElement | null;
next: HTMLElement | null;
}

function isThumb(node: Node | EventTarget): node is HTMLElement {
return node instanceof HTMLElement && node.matches('[role="slider"]');
}

function getValueNow(thumb: HTMLElement): number {
return parseFloat(thumb.getAttribute('aria-valuenow'));
}

export class ActiveThumb {
el: HTMLElement;
neighbors: Neighbors;
positionBounds: Bounds;
hostBounds: Bounds;

private host: HTMLElement;
private relativePosition = 0;
private isPositionUpdating = false;

get isOnlyThumb(): boolean {
return !this.neighbors.previous && !this.neighbors.next;
}

get isFirstThumb(): boolean {
return !this.isOnlyThumb && !this.neighbors.previous;
}

get value(): number {
return getValueNow(this.el);
}

get neighborValues(): { previous: number | null; next: number | null } {
const previousValue = this.neighbors.previous ? getValueNow(this.neighbors.previous) : null;
const nextValue = this.neighbors.next ? getValueNow(this.neighbors.next) : null;
return { previous: previousValue, next: nextValue };
}

constructor(node: Node | EventTarget, host: HTMLElement, orientation: Orientation) {
if (!isThumb(node)) throw Error('An active thumb must be an HTML element with a slider role.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor is only called onPointerDown of the div with the role="slider", therefore isThumb(node) should always be true so I'm not sure it's worth checking here.


this.el = node;
this.neighbors = {
previous: isThumb(this.el.previousSibling) ? this.el.previousSibling : null,
next: isThumb(this.el.nextSibling) ? this.el.nextSibling : null,
};

this.host = host;
this.hostBounds = this.getHostBounds(host, orientation);

const minBound = this.neighbors.previous
? this.getOffset(this.neighbors.previous, orientation)
: this.hostBounds.min;
const maxBound = this.neighbors.next
? this.getOffset(this.neighbors.next, orientation)
: this.hostBounds.max;
this.positionBounds = { min: minBound, max: maxBound };

this.updatePosition = this.updatePosition.bind(this);
}

private getHostBounds(host: HTMLElement, orientation: Orientation): Bounds {
const rect = host.getBoundingClientRect();
return orientation === 'vertical'
? { min: rect.top, max: rect.bottom }
: { min: rect.left, max: rect.right };
}

private getOffset(el: HTMLElement, orientation: Orientation): number {
const rect = el.getBoundingClientRect();
return orientation === 'vertical' ? rect.top + rect.height / 2 : rect.left + rect.width / 2;
}

private updatePosition() {
this.isPositionUpdating = true;

const cssProperty = this.isFirstThumb ? '--post-slider-fill-start' : '--post-slider-fill-end';
this.host.style.setProperty(cssProperty, this.relativePosition.toString());

requestAnimationFrame(this.updatePosition);
}

setValue(value: number, relativePosition: number) {
this.el.setAttribute('aria-valuenow', value.toString());
this.neighbors.previous?.setAttribute('aria-valuemax', value.toString());
this.neighbors.next?.setAttribute('aria-valuemin', value.toString());

// updating position depending on the animation frame rate
this.relativePosition = relativePosition;
if (!this.isPositionUpdating) this.updatePosition();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ORIENTATIONS = ['horizontal', 'vertical'] as const;

export type Orientation = (typeof ORIENTATIONS)[number];
85 changes: 85 additions & 0 deletions packages/components/src/components/post-slider/post-slider.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
@use '@swisspost/design-system-styles/variables/elevation';

:host {
--post-slider-thumb-size: 1.75rem;
--post-slider-track-size: .75rem;
--post-slider-fill-start: 0;
--post-slider-fill-end: 1;

display: block;
position: relative;
height: var(--post-slider-thumb-size);
container-type: inline-size;
touch-action: none;

&::before,
&::after {
content: '';
display: block;
position: absolute;
inset-block: calc(50% - var(--post-slider-track-size) / 2);
inset-inline: 0;
}

// slider track
&::before {
background-color: #f0efed;
}

// slider fill
&::after {
--post-slider-fill-scale: calc(var(--post-slider-fill-end) - var(--post-slider-fill-start));
background-color: #050400;
transform-origin: left;
transform: translateX(calc(var(--post-slider-fill-start) * 100cqw)) scaleX(var(--post-slider-fill-scale));
}

// slider thumbs
[role="slider"] {
--post-slider-thumb-scale: 1;

z-index: 1;
box-sizing: border-box;
height: var(--post-slider-thumb-size);
width: var(--post-slider-thumb-size);
margin-inline-start: calc(var(--post-slider-thumb-size) / -2);
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
background-color: #050400;
border: 3px solid #fff;
border-radius: 50%;
box-shadow: elevation.$elevation-100;
will-change: transform;
transform: translateX(calc(var(--post-slider-thumb-position) * 100cqw)) scale(var(--post-slider-thumb-scale));
transition: background-color 200ms;

&:not(:last-child) {
--post-slider-thumb-position: var(--post-slider-fill-start);
}

&:last-child {
--post-slider-thumb-position: var(--post-slider-fill-end);
}

&:is(:hover, .active) {
background-color: #504f4b;
}
}
}

:host([orient="vertical"]) {
writing-mode: vertical-lr;
height: unset;
min-height: 15rem;
width: var(--post-slider-thumb-size);

&::after {
transform-origin: top;
transform: translateY(calc(var(--post-slider-fill-start) * 100cqh)) scaleY(var(--post-slider-fill-scale));
}

[role="slider"] {
transform: translateY(calc(var(--post-slider-thumb-position) * 100cqh)) scale(var(--post-slider-thumb-scale));
}
}
Loading
Loading