Add rating component

pull/130/head
Cory LaViska 2020-07-20 12:58:05 -04:00
rodzic 12e6102d87
commit aaa9187b84
4 zmienionych plików z 223 dodań i 56 usunięć

Wyświetl plik

@ -2,10 +2,79 @@
[component-header:sl-rating]
Ratings show feedback, typically in the form of stars, and let users provide their own feedback.
Ratings give users a way to quickly view and provide feedback.
```html preview
<sl-rating></sl-rating>
```
## Examples
### Maximum Value
Ratings are 0-5 by default. To change the maximum possible value, use the `max` attribute.
```html preview
<sl-rating max="3"></sl-rating>
```
### Precision
Use the `precision` attribute to let users select fractional ratings.
```html preview
<sl-rating precision=".5" value="2.5"></sl-rating>
```
## Symbol Sizes
Set the `--symbol-size` custom property to adjust the size.
```html preview
<sl-rating style="--symbol-size: 2rem;"></sl-rating>
```
### Readonly
Use the `readonly` attribute to display a rating that users can't change.
```html preview
<sl-rating readonly value="3"></sl-rating>
```
### Disabled
Use the `disable` attribute to disable the rating.
```html preview
<sl-rating disabled value="3"></sl-rating>
```
### Custom Icons
```html preview
<sl-rating class="rating-hearts" style="--symbol-color-active: #ff4136;"></sl-rating>
<script>
const rating = document.querySelector('.rating-hearts');
rating.getSymbol = () => '<sl-icon name="heart-fill"></sl-icon>';
</script>
```
### Value-based Icons
```html preview
<sl-rating class="rating-emojis"></sl-rating>
<script>
const rating = document.querySelector('.rating-emojis');
rating.getSymbol = (value) => {
const icons = ['emoji-angry', 'emoji-frown', 'emoji-expressionless', 'emoji-smile', 'emoji-laughing'];
return `<sl-icon name="${icons[value - 1]}"></sl-icon>`;
};
</script>
```
[component-metadata:sl-rating]

16
src/components.d.ts vendored
Wyświetl plik

@ -572,6 +572,10 @@ export namespace Components {
* Disables the rating.
*/
"disabled": boolean;
/**
* A function that returns the symbols to display. Accepts an option `value` parameter you can use to map a specific symbol to a value.
*/
"getSymbol": (value?: number) => string;
/**
* The highest rating to show.
*/
@ -584,6 +588,14 @@ export namespace Components {
* Makes the rating readonly.
*/
"readonly": boolean;
/**
* Removes focus from the rating.
*/
"removeFocus": () => Promise<void>;
/**
* Sets focus on the rating.
*/
"setFocus": () => Promise<void>;
/**
* The current rating.
*/
@ -1735,6 +1747,10 @@ declare namespace LocalJSX {
* Disables the rating.
*/
"disabled"?: boolean;
/**
* A function that returns the symbols to display. Accepts an option `value` parameter you can use to map a specific symbol to a value.
*/
"getSymbol"?: (value?: number) => string;
/**
* The highest rating to show.
*/

Wyświetl plik

@ -1,16 +1,25 @@
@import 'component';
/**
* @prop --symbol-color: The inactive color for symbols.
* @prop --symbol-color-active: The active color for symbols.
* @prop --symbol-size: The size of symbols.
* @prop --symbol-spacing: The spacing to use around symbols.
*/
:host {
display: inline-flex;
display: inline-block;
--inactive-color: var(--sl-color-gray-90);
--active-color: #f8e71c;
--symbol-color: var(--sl-color-gray-85);
--symbol-color-active: #ffbe00;
--symbol-size: 1.2rem;
--symbol-spacing: var(--sl-spacing-xxx-small);
}
.rating {
position: relative;
display: inline-flex;
border-radius: var(--sl-border-radius-medium);
vertical-align: middle;
&:focus {
outline: none;
@ -22,28 +31,50 @@
}
.rating__symbols {
display: inline-block;
display: inline-flex;
position: relative;
font-size: 1.4rem;
font-size: var(--symbol-size);
line-height: 0;
color: var(--inactive-color);
cursor: pointer;
color: var(--symbol-color);
white-space: nowrap;
cursor: pointer;
> :not(:last-of-type) {
margin-right: var(--sl-spacing-xx-small);
> * {
padding: var(--symbol-spacing);
}
}
.rating__indicator {
.rating__symbols--indicator {
position: absolute;
top: 0;
left: 0;
color: var(--active-color);
overflow: hidden;
color: var(--symbol-color-active);
pointer-events: none;
}
.rating__symbol {
display: inline-block;
transition: var(--sl-transition-medium) transform;
transition: var(--sl-transition-fast) transform;
}
.rating__symbol--hover {
transform: scale(1.2);
}
.rating--disabled,
.rating--readonly {
.rating__symbols {
cursor: default;
}
.rating__symbol--hover {
transform: none;
}
}
.rating--disabled {
opacity: 0.5;
.rating__symbols {
cursor: not-allowed;
}
}

Wyświetl plik

@ -1,4 +1,4 @@
import { Component, Event, EventEmitter, Prop, State, Watch, h } from '@stencil/core';
import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core';
import { focusVisible } from '../../utilities/focus-visible';
import { clamp } from '../../utilities/math';
@ -9,17 +9,6 @@ import { clamp } from '../../utilities/math';
* @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',
@ -29,24 +18,26 @@ 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.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
}
rating: HTMLElement;
@Element() host: HTMLSlRatingElement;
@State() hoverValue = 0;
@State() isHovering = false;
/** The current rating. */
@Prop({ mutable: true, reflect: true }) value = 2.5;
@Prop({ mutable: true, reflect: true }) value = 0;
/** The highest rating to show. */
@Prop() max = 5;
/** The minimum increment value allowed by the control. */
@Prop() precision = 0.5;
@Prop() precision = 1;
/** Makes the rating readonly. */
@Prop() readonly = false;
@ -54,6 +45,11 @@ export class Rating {
/** Disables the rating. */
@Prop() disabled = false;
/** A function that returns the symbols to display. Accepts an option `value` parameter you can use to map a specific
* symbol to a value. */
// @ts-ignore
@Prop() getSymbol = (value?: number) => '<sl-icon name="star-fill"></sl-icon>';
@Watch('value')
handleValueChange() {
this.slChange.emit();
@ -62,6 +58,18 @@ export class Rating {
/** Emitted when the rating's value changes. */
@Event() slChange: EventEmitter;
/** Sets focus on the rating. */
@Method()
async setFocus() {
this.rating.focus();
}
/** Removes focus from the rating. */
@Method()
async removeFocus() {
this.rating.blur();
}
componentDidLoad() {
focusVisible.observe(this.rating);
}
@ -73,14 +81,33 @@ export class 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);
return clamp(
this.roundToPrecision(((event.clientX - containerLeft) / containerWidth) * this.max, this.precision),
0,
this.max
);
}
handleClick(event: MouseEvent) {
this.value = this.getValueFromMousePosition(event);
if (this.disabled || this.readonly) {
return;
}
const newValue = this.getValueFromMousePosition(event);
if (newValue === this.value) {
this.value = 0;
this.isHovering = false;
} else {
this.value = newValue;
}
}
handleKeyDown(event: KeyboardEvent) {
if (this.disabled || this.readonly) {
return;
}
if (event.key === 'ArrowLeft') {
const decrement = event.shiftKey ? 1 : this.precision;
this.value = Math.max(0, this.value - decrement);
@ -104,11 +131,11 @@ export class Rating {
}
}
handleMouseOver() {
handleMouseEnter() {
this.isHovering = true;
}
handleMouseOut() {
handleMouseLeave() {
this.isHovering = false;
}
@ -122,42 +149,66 @@ export class Rating {
}
render() {
const counter = Array.from(Array(this.max));
const displayValue = this.isHovering ? this.hoverValue : this.value;
const counter = Array.from(Array(this.max).keys());
let displayValue = 0;
if (this.disabled || this.readonly) {
displayValue = this.value;
} else {
displayValue = this.isHovering ? this.hoverValue : this.value;
}
return (
<div
ref={el => (this.rating = el)}
part="base"
class="rating"
class={{
rating: true,
'rating--readonly': this.readonly,
'rating--disabled': this.disabled
}}
aria-disabled={this.disabled}
aria-readonly={this.readonly}
aria-value={this.value}
aria-valuemin={0}
aria-valuemax={this.max}
tabIndex={0}
tabIndex={this.disabled ? -1 : 0}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
onMouseEnter={this.handleMouseOver}
onMouseLeave={this.handleMouseOut}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onMouseMove={this.handleMouseMove}
>
<span class="rating__symbols">
{counter.map(() => (
<span class="rating__symbol">
<sl-icon name="star-fill" role="presentation" />
</span>
<span class="rating__symbols rating__symbols--inactive">
{counter.map(index => (
<span
class={{
rating__symbol: true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
}}
role="presentation"
// Users can click the current value to clear the rating. When this happens, we set this.isHovering to
// false to prevent the hover state from confusing them as they move the mouse out of the control. This
// extra mouseenter will reinstate it if they happen to mouse over an adjacent symbol.
onMouseEnter={this.handleMouseEnter}
innerHTML={this.getSymbol(index + 1)}
/>
))}
</span>
<span
class="rating__symbols rating__indicator"
style={{
width: `${(displayValue / this.max) * 100}%`
}}
>
{counter.map(() => (
<span class="rating__symbol">
<sl-icon name="star-fill" role="presentation" />
</span>
<span class="rating__symbols rating__symbols--indicator">
{counter.map(index => (
<span
class={{
rating__symbol: true,
'rating__symbol--hover': this.isHovering && Math.ceil(displayValue) === index + 1
}}
style={{
clipPath: displayValue > index + 1 ? null : `inset(0 ${100 - ((displayValue - index) / 1) * 100}% 0 0)`
}}
role="presentation"
innerHTML={this.getSymbol(index + 1)}
/>
))}
</span>
</div>