From b57151f28287904d08c8471a2450ed84325f798c Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Mon, 20 Jul 2020 06:17:20 -0400 Subject: [PATCH] Initial commit for rating (unfinished) --- docs/_sidebar.md | 1 + docs/components/rating.md | 11 ++ src/components.d.ts | 57 ++++++++++ src/components/rating/rating.scss | 49 +++++++++ src/components/rating/rating.tsx | 166 ++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 docs/components/rating.md create mode 100644 src/components/rating/rating.scss create mode 100644 src/components/rating/rating.tsx diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 697072180..d55eecb47 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -29,6 +29,7 @@ - [Progress Ring](/components/progress-ring.md) - [Radio](/components/radio.md) - [Range](/components/range.md) + - [Rating](/components/rating.md) - [Select](/components/select.md) - [Spinner](/components/spinner.md) - [Switch](/components/switch.md) diff --git a/docs/components/rating.md b/docs/components/rating.md new file mode 100644 index 000000000..5989f9dad --- /dev/null +++ b/docs/components/rating.md @@ -0,0 +1,11 @@ +# Rating + +[component-header:sl-rating] + +Ratings show feedback, typically in the form of stars, and let users provide their own feedback. + +```html preview + +``` + +[component-metadata:sl-rating] diff --git a/src/components.d.ts b/src/components.d.ts index 2c0336138..80cc27125 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -567,6 +567,28 @@ export namespace Components { */ "value": number; } + interface SlRating { + /** + * Disables the rating. + */ + "disabled": boolean; + /** + * The highest rating to show. + */ + "max": number; + /** + * The minimum increment value allowed by the control. + */ + "precision": number; + /** + * Makes the rating readonly. + */ + "readonly": boolean; + /** + * The current rating. + */ + "value": number; + } interface SlSelect { /** * Set to true to disable the select control. @@ -972,6 +994,12 @@ declare global { prototype: HTMLSlRangeElement; new (): HTMLSlRangeElement; }; + interface HTMLSlRatingElement extends Components.SlRating, HTMLStencilElement { + } + var HTMLSlRatingElement: { + prototype: HTMLSlRatingElement; + new (): HTMLSlRatingElement; + }; interface HTMLSlSelectElement extends Components.SlSelect, HTMLStencilElement { } var HTMLSlSelectElement: { @@ -1048,6 +1076,7 @@ declare global { "sl-progress-ring": HTMLSlProgressRingElement; "sl-radio": HTMLSlRadioElement; "sl-range": HTMLSlRangeElement; + "sl-rating": HTMLSlRatingElement; "sl-select": HTMLSlSelectElement; "sl-spinner": HTMLSlSpinnerElement; "sl-switch": HTMLSlSwitchElement; @@ -1701,6 +1730,32 @@ declare namespace LocalJSX { */ "value"?: number; } + interface SlRating { + /** + * Disables the rating. + */ + "disabled"?: boolean; + /** + * The highest rating to show. + */ + "max"?: number; + /** + * Emitted when the rating's value changes. + */ + "onSlChange"?: (event: CustomEvent) => void; + /** + * The minimum increment value allowed by the control. + */ + "precision"?: number; + /** + * Makes the rating readonly. + */ + "readonly"?: boolean; + /** + * The current rating. + */ + "value"?: number; + } interface SlSelect { /** * Set to true to disable the select control. @@ -2020,6 +2075,7 @@ declare namespace LocalJSX { "sl-progress-ring": SlProgressRing; "sl-radio": SlRadio; "sl-range": SlRange; + "sl-rating": SlRating; "sl-select": SlSelect; "sl-spinner": SlSpinner; "sl-switch": SlSwitch; @@ -2056,6 +2112,7 @@ declare module "@stencil/core" { "sl-progress-ring": LocalJSX.SlProgressRing & JSXBase.HTMLAttributes; "sl-radio": LocalJSX.SlRadio & JSXBase.HTMLAttributes; "sl-range": LocalJSX.SlRange & JSXBase.HTMLAttributes; + "sl-rating": LocalJSX.SlRating & JSXBase.HTMLAttributes; "sl-select": LocalJSX.SlSelect & JSXBase.HTMLAttributes; "sl-spinner": LocalJSX.SlSpinner & JSXBase.HTMLAttributes; "sl-switch": LocalJSX.SlSwitch & JSXBase.HTMLAttributes; diff --git a/src/components/rating/rating.scss b/src/components/rating/rating.scss new file mode 100644 index 000000000..7cc5f6c5c --- /dev/null +++ b/src/components/rating/rating.scss @@ -0,0 +1,49 @@ +@import 'component'; + +:host { + display: inline-flex; + + --inactive-color: var(--sl-color-gray-90); + --active-color: #f8e71c; +} + +.rating { + position: relative; + display: inline-flex; + border-radius: var(--sl-border-radius-medium); + + &:focus { + outline: none; + } + + &.focus-visible:focus { + box-shadow: var(--sl-focus-ring-box-shadow); + } +} + +.rating__symbols { + display: inline-block; + position: relative; + font-size: 1.4rem; + line-height: 0; + color: var(--inactive-color); + cursor: pointer; + white-space: nowrap; + + > :not(:last-of-type) { + margin-right: var(--sl-spacing-xx-small); + } +} + +.rating__indicator { + position: absolute; + top: 0; + left: 0; + color: var(--active-color); + overflow: hidden; +} + +.rating__symbol { + display: inline-block; + transition: var(--sl-transition-medium) transform; +} diff --git a/src/components/rating/rating.tsx b/src/components/rating/rating.tsx new file mode 100644 index 000000000..f3362e10e --- /dev/null +++ b/src/components/rating/rating.tsx @@ -0,0 +1,166 @@ +import { Component, Event, EventEmitter, Prop, State, Watch, h } from '@stencil/core'; +import { focusVisible } from '../../utilities/focus-visible'; +import { clamp } from '../../utilities/math'; + +/** + * @since 2.0 + * @status stable + * + * @part base - The component's base wrapper. + */ + +// +// TODO: +// +// - sizing +// - labels +// - disabled +// - readonly +// - custom icons +// - icon should grow on hover +// + +@Component({ + tag: 'sl-rating', + styleUrl: 'rating.scss', + shadow: true +}) +export class Rating { + constructor() { + this.handleClick = this.handleClick.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleMouseOver = this.handleMouseOver.bind(this); + this.handleMouseOut = this.handleMouseOut.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + } + + rating: HTMLElement; + + @State() hoverValue = 0; + @State() isHovering = false; + + /** The current rating. */ + @Prop({ mutable: true, reflect: true }) value = 2.5; + + /** The highest rating to show. */ + @Prop() max = 5; + + /** The minimum increment value allowed by the control. */ + @Prop() precision = 0.5; + + /** Makes the rating readonly. */ + @Prop() readonly = false; + + /** Disables the rating. */ + @Prop() disabled = false; + + @Watch('value') + handleValueChange() { + this.slChange.emit(); + } + + /** Emitted when the rating's value changes. */ + @Event() slChange: EventEmitter; + + componentDidLoad() { + focusVisible.observe(this.rating); + } + + componentDidUnload() { + focusVisible.unobserve(this.rating); + } + + getValueFromMousePosition(event: MouseEvent) { + const containerLeft = this.rating.getBoundingClientRect().left; + const containerWidth = this.rating.getBoundingClientRect().width; + return clamp(this.roundToPrecision(((event.clientX - containerLeft) / containerWidth) * this.max), 0, this.max); + } + + handleClick(event: MouseEvent) { + this.value = this.getValueFromMousePosition(event); + } + + handleKeyDown(event: KeyboardEvent) { + if (event.key === 'ArrowLeft') { + const decrement = event.shiftKey ? 1 : this.precision; + this.value = Math.max(0, this.value - decrement); + event.preventDefault(); + } + + if (event.key === 'ArrowRight') { + const increment = event.shiftKey ? 1 : this.precision; + this.value = Math.min(this.max, this.value + increment); + event.preventDefault(); + } + + if (event.key === 'Home') { + this.value = 0; + event.preventDefault(); + } + + if (event.key === 'End') { + this.value = this.max; + event.preventDefault(); + } + } + + handleMouseOver() { + this.isHovering = true; + } + + handleMouseOut() { + this.isHovering = false; + } + + handleMouseMove(event: MouseEvent) { + this.hoverValue = this.getValueFromMousePosition(event); + } + + roundToPrecision(numberToRound: number, precision = 0.5) { + const multiplier = 1 / precision; + return Math.ceil(numberToRound * multiplier) / multiplier; + } + + render() { + const counter = Array.from(Array(this.max)); + const displayValue = this.isHovering ? this.hoverValue : this.value; + + return ( +
(this.rating = el)} + part="base" + class="rating" + aria-value={this.value} + aria-valuemin={0} + aria-valuemax={this.max} + tabIndex={0} + onClick={this.handleClick} + onKeyDown={this.handleKeyDown} + onMouseEnter={this.handleMouseOver} + onMouseLeave={this.handleMouseOut} + onMouseMove={this.handleMouseMove} + > + + {counter.map(() => ( + + + + ))} + + + + {counter.map(() => ( + + + + ))} + +
+ ); + } +}