Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next: Add Ratings Component #2599

Merged
merged 30 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c852c52
init svelte types
Apr 8, 2024
f4ff919
implemented value rating
Apr 9, 2024
d9d7775
Merge branch 'next' of https://github.com/Mahmoud-zino/skeleton into …
Apr 29, 2024
f39ca03
drop component icons
Apr 29, 2024
4f3e151
added basic interaction
Apr 29, 2024
fa68dbf
fully implement fractions
May 1, 2024
e282b2e
finish basic implementation
May 1, 2024
86591aa
rating minor fixes / icons update
May 13, 2024
7e4add6
applied part of code review suggestions
May 14, 2024
5763180
applied code review suggestions 2
May 22, 2024
4c4a56c
fixed conflicts
May 22, 2024
eaae878
format
May 22, 2024
2c9981d
Review and refactor
endigo9740 May 23, 2024
4744e02
Added comments
endigo9740 May 23, 2024
d593d17
implemented keyboard interactions
May 25, 2024
e546f81
implemented rtl
May 26, 2024
d672469
added svelte docs
May 26, 2024
706b749
Stub React doc, minor Svelte doc edits
endigo9740 May 29, 2024
2f0703f
started adding React component
Jun 3, 2024
899f9be
fully implement rating react
Jun 5, 2024
d104194
Merge branch 'next' of https://github.com/Mahmoud-zino/skeleton into …
Jun 6, 2024
ad3e6eb
react rating docs (css problem still exists)
Jun 7, 2024
c7d178e
fixed css issue
Jun 9, 2024
fbb676e
rating basic tests
Jun 9, 2024
d7fadc3
finished adding rating tests
Jun 10, 2024
fb2a9b4
Merge branch 'next' of https://github.com/Mahmoud-zino/skeleton into …
Jun 10, 2024
22f697a
applied first part of code review suggestions
Jun 12, 2024
9a797f3
applied code review suggestions 2
Jun 14, 2024
84ef486
updated pnpm-lock
Jun 15, 2024
f39f739
Minor adjustments per PR review
endigo9740 Jun 17, 2024
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
2 changes: 1 addition & 1 deletion packages/skeleton-react/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
to {
transform: translateX(-100%);
}
}
}
3 changes: 3 additions & 0 deletions packages/skeleton-react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ function App() {
<a className="anchor" href="/components/tabs">
Tabs
</a>
<a className="anchor" href="/components/ratings">
Ratings
</a>
</nav>
</div>
</div>
Expand Down
197 changes: 197 additions & 0 deletions packages/skeleton-react/src/lib/components/Rating/Rating.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { render, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Rating } from "./Rating";
import { Star } from "lucide-react";
import userEvent from "@testing-library/user-event";

// *************************
// Integration Tests
// *************************

describe("static Rating", () => {
const ratingComponent = (value: number, max: number) => (
<Rating value={value} max={max} iconEmpty={<Star size={24} />} iconFull={<Star size={24} className="fill-surface-950-50" />} />
)

it("should render with the value initial value set", () => {
const { getAllByTestId } = render(ratingComponent(2.5, 5));

const emptyIcons = getAllByTestId("rating-iconempty");
const fullIcons = getAllByTestId("rating-iconfull");
expect(emptyIcons).toHaveLength(5);
expect(fullIcons).toHaveLength(5);

// css variable representing the clip value.
expect(getClipValue(emptyIcons[0])).toBe(250);

function getClipValue(span: HTMLElement) {
return parseFloat(getComputedStyle(span).getPropertyValue("--clipValue").trim());
}
});

it("should not render any icons with max set to 0", () => {
const { getByTestId } = render(ratingComponent(2.5, 0));

const component = getByTestId("rating");
expect(component).toBeEmptyDOMElement();
});

it("should render a large number of ratings", () => {
const { getByTestId } = render(ratingComponent(2.5, 100));

const component = getByTestId("rating");
const buttons = component.querySelectorAll("button");
expect(buttons).toHaveLength(100);
});

it("should not be interactive in static mode", () => {
const { getByTestId } = render(ratingComponent(2.5, 0));

const component = getByTestId("rating");
const buttons = component.querySelectorAll("button");

buttons.forEach(button => {
expect(button.tabIndex).toBe(-1);
expect(button).toHaveClass("pointer-events-none");
});
});
});

describe("Interactive Rating", () => {
// getBoundingClientRect always returns 0 in @testing-library, so we have to mock it
beforeEach(() => {
Object.defineProperty(Element.prototype, 'getBoundingClientRect', {
value: () => ({
width: 100,
height: 100,
top: 0,
left: 0,
right: 100,
bottom: 100,
x: 0,
y: 0,
}),
});
});

const onValueChange = vi.fn();
const ratingComponent = (value: number, step: number) => (
<Rating value={value} onValueChange={(val) => onValueChange(val)} step={step} max={5} interactive
iconEmpty={<Star size={24} />} iconFull={<Star size={24} className="fill-surface-950-50" />} />
);

it("should click a rating and change the value successfully", async () => {
const { getByTestId } = render(ratingComponent(2.5, 1));

const component = getByTestId("rating");
const buttons = component.querySelectorAll("button");

// click the last star
await userEvent.click(buttons[buttons.length - 1]);
expect(onValueChange).toHaveBeenCalledWith(5);

// click the first star
await userEvent.click(buttons[0]);
expect(onValueChange).toHaveBeenCalledWith(1);
});

it("should click the Steps and change the value successfully", async () => {
const { getByTestId } = render(ratingComponent(2.5, 2));

const component = getByTestId("rating");
const buttons = component.querySelectorAll("button");

// click the first half of the second star
await userEvent.click(buttons[1]);
expect(onValueChange).toHaveBeenCalledWith(1.5);

// click the second half of the second star
fireEvent.mouseDown(buttons[1], { clientX: 50});
fireEvent.mouseUp(buttons[1], { clientX: 50 });
expect(onValueChange).toHaveBeenCalledWith(2);
});

it("should focus on active rating element on focus", async () => {
const { getByTestId } = render(ratingComponent(2, 1));

const component = getByTestId("rating");

// focus on rating
component.focus();
await userEvent.keyboard('{Tab}');

const buttons = component.querySelectorAll("button");
expect(buttons[1]).toHaveFocus();
});

it("should increase and decrease rating using keyboard arrows", async () => {
const { getByTestId } = render(ratingComponent(2, 1));

const component = getByTestId("rating");

// focus on rating
component.focus();
await userEvent.keyboard('{Tab}');

const buttons = component.querySelectorAll("button");
expect(buttons[1]).toHaveFocus();

// increase rating
await userEvent.keyboard('{ArrowRight}');
expect(buttons[2]).toHaveFocus();
expect(onValueChange).toHaveBeenCalledWith(3);

// decrease rating
await userEvent.keyboard('{ArrowLeft}');
expect(buttons[1]).toHaveFocus();
expect(onValueChange).toHaveBeenCalledWith(2);
});

it("should not increase or decrease value over the limit", async () => {
const { getByTestId } = render(ratingComponent(2, 1));

const component = getByTestId("rating");

// focus on rating
component.focus();
await userEvent.keyboard('{Tab}');

const buttons = component.querySelectorAll("button");
expect(buttons[1]).toHaveFocus();

// increase rating
await userEvent.keyboard('{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}');
expect(buttons[4]).toHaveFocus();
expect(onValueChange).toHaveBeenCalledWith(5);

// decrease rating
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}{ArrowLeft}');
expect(buttons[0]).toHaveFocus();
expect(onValueChange).toHaveBeenCalledWith(0);
});
});

// *************************
// Unit Tests
// *************************

// Rating ---

describe("<Rating>", () => {
it("should render the component", () => {
const { getByTestId } = render(<Rating />);
expect(getByTestId("rating")).toBeInTheDocument();
});

it("should allow to set the `base` style prop", () => {
const tailwindClasses = "bg-red-600";
const { getByTestId } = render(<Rating base={tailwindClasses} />);
expect(getByTestId("rating")).toHaveClass(tailwindClasses);
});

it("should allow you to set the `classes` style prop", () => {
const tailwindClasses = "bg-green-600";
const { getByTestId } = render(<Rating classes={tailwindClasses} />);
expect(getByTestId("rating")).toHaveClass(tailwindClasses);
});
});
161 changes: 161 additions & 0 deletions packages/skeleton-react/src/lib/components/Rating/Rating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { RatingProps } from "./types";

// Components ---
export const Rating: React.FC<RatingProps> = ({
value = 0,
max = 5,
interactive = false,
step = 1,
// Root
base = "flex",
width = "w-full",
justify = "justify-center",
spaceX = "space-x-2",
classes = "",
// Item ---
buttonBase = "w-full h-full",
buttonPosition = "relative",
buttonAspect = "aspect-square",
buttonClasses = "",
// Icon Empty
emptyBase = "absolute left-0 top-0 flex items-center justify-center",
emptyClip = "[clip-path:inset(0_0_0_var(--clipValue))] rtl:[clip-path:inset(0_var(--clipValue)_0_0)]",
emptyInteractive = "size-full",
emptyStatic = "w-fit",
emptyClasses = "",
// Icon Full
fullBase = "absolute left-0 top-0 flex items-center justify-center",
fullClip = "[clip-path:inset(0_var(--clipValue)_0_0)] rtl:[clip-path:inset(0_0_0_var(--clipValue))]",
fullInteractive = "size-full",
fullStatic = "w-fit",
fullClasses = "",
// Events
onMouseDown = () => {},
onKeyDown = () => {},
onValueChange = () => {},
// Children
iconEmpty,
iconFull,
}) => {
const figureRef = useRef<HTMLElement>(null);
const [focusedButtonIndex, setFocusedButtonIndex] = useState(0);
const [rxEmptyInteractive, setRxEmptyInteractive] = useState("");
const [rxFullInteractive, setRxFullInteractive] = useState("");

useEffect(() => {
const index = Math.max(0, Math.ceil(value - 1));
setFocusedButtonIndex(index);
}, [value]);
useEffect(() => {
setRxEmptyInteractive(interactive ? emptyInteractive : emptyStatic);
setRxFullInteractive(interactive ? fullInteractive : fullStatic);
}, [interactive]);

const onRatingMouseDown = useCallback(
(event: React.MouseEvent<HTMLButtonElement>, order: number) => {
if (!figureRef.current) return;

const ratingRect = (
event.currentTarget as HTMLElement
).getBoundingClientRect();
const fractionWidth = ratingRect.width / step;
const left = event.clientX - ratingRect.left;
let selectedFraction = Math.floor(left / fractionWidth) + 1;

if (getComputedStyle(figureRef.current).direction === "rtl") {
selectedFraction = step - selectedFraction + 1;
}

value = order + selectedFraction / step;
onValueChange(value);
onMouseDown(event, value);
},
[step]
);

// https://www.w3.org/WAI/ARIA/apg/patterns/radio/examples/radio-rating/#kbd_label
const onRatingKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!figureRef.current) return;
const rtl = getComputedStyle(figureRef.current).direction === "rtl";
if (["ArrowLeft", "ArrowUp"].includes(event.key)) {
event.preventDefault();
rtl ? increaseValue() : decreaseValue();
}
if (["ArrowRight", "ArrowDown"].includes(event.key)) {
event.preventDefault();
rtl ? decreaseValue() : increaseValue();
}
onKeyDown(event);
},
[]
);

function refreshFocus() {
if (!figureRef.current) return;

const buttons = figureRef.current.querySelectorAll("button");
buttons[Math.max(0, Math.ceil(value - 1))].focus();
}

function increaseValue() {
value = Math.min(max, value + 1 / step);
onValueChange(value);
refreshFocus();
}

function decreaseValue() {
value = Math.max(0, value - 1 / step);
onValueChange(value);
refreshFocus();
}

return (
<figure
ref={figureRef}
className={`${base} ${width} ${justify} ${spaceX} ${classes}`}
data-testid="rating"
>
{[...Array(max)].map((_, order) => (
<button
className={`${buttonBase} ${buttonPosition} ${buttonAspect} ${buttonClasses} ${
interactive ? undefined : "pointer-events-none"
}`}
tabIndex={interactive && order === focusedButtonIndex ? 0 : -1}
onMouseDown={(event: React.MouseEvent<HTMLButtonElement>) =>
interactive ? onRatingMouseDown(event, order) : undefined
}
onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) =>
interactive ? onRatingKeyDown(event) : undefined
}
type="button"
key={order}
>
<span
className={`${emptyBase} ${emptyClip} ${rxEmptyInteractive} ${emptyClasses}`}
style={
{
"--clipValue": `${(value - order) * 100}%`,
} as React.CSSProperties
}
data-testid="rating-iconempty"
>
{iconEmpty}
</span>
<span
className={`${fullBase} ${fullClip} ${rxFullInteractive} ${fullClasses}`}
style={
{
"--clipValue": `${100 - (value - order) * 100}%`,
} as React.CSSProperties
}
data-testid="rating-iconfull"
>
{iconFull}
</span>
</button>
))}
</figure>
);
};
Loading