Skip to content

Commit

Permalink
feat: add suggestions component
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexGarrixen committed Jan 12, 2024
1 parent 8df9052 commit c588c9e
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 125 deletions.
40 changes: 1 addition & 39 deletions src/components/color-inputs/suggestions-button.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,43 +40,5 @@ export default {

dialogBody: css({ px: "6", py: "6", overflowY: "auto" }),

suggestionsGrid: css({ display: "grid", gridTemplateColumns: { sm: "2" }, gap: "5" }),

suggestion: css({
border: "1px solid",
borderColor: "border-secondary",
rounded: "lg",
overflow: "hidden",
}),

suggestionPreviews: css({ display: "flex" }),

suggestionPreview: css({
aspectRatio: "3/2",
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:first-child": {
borderRight: "1px solid",
borderColor: "border-secondary",
},
"& > span": {
textStyle: "body-lg",
},
}),

suggestionContent: css({
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderTop: "1px solid",
borderColor: "border-secondary",
px: "4",
py: "3",
}),

suggestionBtn: css({ fontWeight: "bold", textStyle: "body-base", cursor: "pointer" }),

suggestionContrast: css({ textStyle: "body-base", color: "text-secondary" }),
suggestions: css({ gridTemplateColumns: { sm: "2" } }),
};
122 changes: 36 additions & 86 deletions src/components/color-inputs/suggestions-button.tsx
Original file line number Diff line number Diff line change
@@ -1,106 +1,56 @@
"use client";

import { useAtom, useAtomValue } from "jotai";
import { useSetAtom } from "jotai";
import * as Dialog from "@radix-ui/react-dialog";
import { css } from "@root/styled-system/css";

import { Button } from "@/components/primitives/button";
import { Suggestions } from "@/components/suggestions";
import { LightFill, CloseFill } from "@/components/icons";
import { background, foreground, contrastRelation } from "@/store";
import { createContrastSuggestions } from "@/lib/contrast-suggestions";
import { background, foreground } from "@/store";
import { useToggle } from "@/hooks/use-toggle";

import classes from "./suggestions-button.styled";

export function SuggestionsButton() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button isIconOnly aria-label="swap button" className={css({ fontSize: "icon-20" })}>
<LightFill />
</Button>
</Dialog.Trigger>
<SuggestionsDialog />
</Dialog.Root>
);
}

function SuggestionsDialog() {
const [fg, setFg] = useAtom(foreground);
const [bg, setBg] = useAtom(background);
const score = useAtomValue(contrastRelation);
const suggestions = createContrastSuggestions(bg, fg).filter(
(result) => parseFloat(result.contrast) > score.contrast,
);
const emptySuggestions = suggestions.length === 0;
const { isEnabled: open, onOpen, onClose, setOpen } = useToggle();
const setFg = useSetAtom(foreground);
const setBg = useSetAtom(background);

function onClickApply(bg: string, fg: string) {
setFg(fg);
setBg(bg);
onClose();
}

return (
<Dialog.Portal>
<Dialog.Overlay className={classes.dialogOverlay} />
<Dialog.Content className={classes.dialogRoot}>
<header className={classes.dialogHeader}>
Contrast suggestions
<Dialog.Close asChild>
<Button isIconOnly size="sm">
<CloseFill />
</Button>
</Dialog.Close>
</header>
<div className={classes.dialogBody}>
{emptySuggestions ? (
<p>There is nothing more to suggest</p>
) : (
<ul className={classes.suggestionsGrid}>
{suggestions.map((item) => (
<li key={item.id}>
<SuggestionItem {...item} onApply={onClickApply} />
</li>
))}
</ul>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
);
}

function SuggestionItem({
contrast,
fg,
bg,
onApply,
}: {
contrast: string;
bg: string;
fg: string;
onApply: (bg: string, fg: string) => void;
}) {
function onClickApply() {
onApply(bg, fg);
}

return (
<article className={classes.suggestion}>
<div className={classes.suggestionPreviews}>
<div className={classes.suggestionPreview} style={{ background: bg }}>
<span style={{ color: fg }}>B</span>
</div>
<div className={classes.suggestionPreview} style={{ background: fg }}>
<span style={{ color: bg }}>T</span>
</div>
</div>
<div className={classes.suggestionContent}>
<Dialog.Close asChild>
<button className={classes.suggestionBtn} type="button" onClick={onClickApply}>
Apply
</button>
</Dialog.Close>
<span className={classes.suggestionContrast}>{contrast}</span>
</div>
</article>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<Button
isIconOnly
aria-label="swap button"
className={css({ fontSize: "icon-20" })}
onClick={onOpen}
>
<LightFill />
</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={classes.dialogOverlay} />
<Dialog.Content className={classes.dialogRoot}>
<header className={classes.dialogHeader}>
Contrast suggestions
<Dialog.Close asChild>
<Button isIconOnly size="sm">
<CloseFill />
</Button>
</Dialog.Close>
</header>
<div className={classes.dialogBody}>
<Suggestions className={classes.suggestions} onApply={onClickApply} />
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
35 changes: 35 additions & 0 deletions src/components/suggestions/__tests__/suggestions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it } from "@jest/globals";
import { fireEvent, render } from "@testing-library/react";

import { background, foreground } from "@/store";

import { Suggestions } from "../suggestions";

describe("Suggestions List", () => {
it("Correct rendering and unmount", () => {
const screen = render(<Suggestions />);

expect(() => screen.unmount()).not.toThrow();
});

it("Should call onApply callback", () => {
const mockHandler = jest.fn();
const screen = render(<Suggestions className="my-class" onApply={mockHandler} />);
const [button] = screen.getAllByRole("button");

expect(button).toBeInTheDocument();

fireEvent.click(button);

expect(mockHandler).toHaveBeenCalled();
});

it("Should show text when is suggestions empty", () => {
background.onMount = (set) => set("#fff");
foreground.onMount = (set) => set("#000");

const screen = render(<Suggestions />);

expect(screen.getByText("There is nothing more to suggest")).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/components/suggestions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Suggestions } from "./suggestions";
43 changes: 43 additions & 0 deletions src/components/suggestions/suggestions.styled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { css } from "@root/styled-system/css";

export default {
root: css({ display: "grid", gridTemplateColumns: "1", gap: "5" }),

suggestion: css({
border: "1px solid",
borderColor: "border-secondary",
rounded: "lg",
overflow: "hidden",
}),

suggestionPreviews: css({ display: "flex" }),

suggestionPreview: css({
aspectRatio: "3/2",
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
"&:first-child": {
borderRight: "1px solid",
borderColor: "border-secondary",
},
"& > span": {
textStyle: "body-lg",
},
}),

suggestionContent: css({
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderTop: "1px solid",
borderColor: "border-secondary",
px: "4",
py: "3",
}),

suggestionBtn: css({ fontWeight: "bold", textStyle: "body-base", cursor: "pointer" }),

suggestionContrast: css({ textStyle: "body-base", color: "text-secondary" }),
};
66 changes: 66 additions & 0 deletions src/components/suggestions/suggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import { useAtomValue } from "jotai";

import { background, foreground, contrastRelation } from "@/store";
import { createContrastSuggestions } from "@/lib/contrast-suggestions";

import classes from "./suggestions.styled";

interface SuggestionProps extends Pick<SuggestionItemProps, "onApply"> {
className?: string;
}

export function Suggestions({ onApply, className }: SuggestionProps) {
const fg = useAtomValue(foreground);
const bg = useAtomValue(background);
const score = useAtomValue(contrastRelation);
const suggestions = createContrastSuggestions(bg, fg).filter(
(result) => parseFloat(result.contrast) > score.contrast,
);
const isEmpty = suggestions.length === 0;

return (
<div className={`${classes.root} ${className ?? ""}`}>
{isEmpty ? (
<p>There is nothing more to suggest</p>
) : (
<>
{suggestions.map((item) => (
<SuggestionItem key={item.id} {...item} onApply={onApply} />
))}
</>
)}
</div>
);
}

interface SuggestionItemProps {
contrast: string;
bg: string;
fg: string;
onApply?: (bg: string, fg: string) => void;
}

function SuggestionItem({ contrast, fg, bg, onApply }: SuggestionItemProps) {
const onClickApply = () => onApply?.(bg, fg);

return (
<article className={classes.suggestion}>
<div className={classes.suggestionPreviews}>
<div className={classes.suggestionPreview} style={{ background: bg }}>
<span style={{ color: fg }}>B</span>
</div>
<div className={classes.suggestionPreview} style={{ background: fg }}>
<span style={{ color: bg }}>T</span>
</div>
</div>
<div className={classes.suggestionContent}>
<button className={classes.suggestionBtn} type="button" onClick={onClickApply}>
Apply
</button>
<span className={classes.suggestionContrast}>{contrast}</span>
</div>
</article>
);
}

0 comments on commit c588c9e

Please sign in to comment.