kopia lustrzana https://github.com/shoelace-style/shoelace
Add rating component
rodzic
12e6102d87
commit
aaa9187b84
|
@ -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]
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
Ładowanie…
Reference in New Issue