diff --git a/frontend/__tests__/components/Button.spec.tsx b/frontend/__tests__/components/Button.spec.tsx deleted file mode 100644 index 4c0ca6db2746..000000000000 --- a/frontend/__tests__/components/Button.spec.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { render, cleanup } from "@solidjs/testing-library"; -import { describe, it, expect, vi, afterEach } from "vitest"; - -import { Button } from "../../src/ts/components/common/Button"; - -describe("Button component", () => { - afterEach(() => { - cleanup(); - }); - - it("renders a button element when onClick is provided", () => { - const onClick = vi.fn(); - - const { container } = render(() => ( - - )); - - const button = container.querySelector("button"); - expect(button).toBeTruthy(); - expect(button?.textContent).toContain("Click me"); - }); - - it("renders an anchor element when href is provided", () => { - const { container } = render(() => ( - - )); - - const anchor = container.querySelector("a"); - expect(anchor).toBeTruthy(); - expect(anchor?.getAttribute("href")).toBe("https://example.com"); - expect(anchor?.getAttribute("target")).toBe("_blank"); - expect(anchor?.getAttribute("rel")).toContain("noreferrer"); - }); - - it("calls onClick when button is clicked", async () => { - const onClick = vi.fn(); - - const { container } = render(() => ( - - )); - - const button = container.querySelector("button"); - button?.click(); - - expect(onClick).toHaveBeenCalledTimes(1); - }); - - it("renders icon when icon prop is provided", () => { - const { container } = render(() => ( - { - // - }} - fa={{ - icon: "fa-keyboard", - }} - /> - )); - - const icon = container.querySelector("i"); - expect(icon).toBeTruthy(); - expect(icon?.className).toContain("fas"); - expect(icon?.className).toContain("fa-keyboard"); - }); - - it("applies fa-fw class when text is missing", () => { - const { container } = render(() => ( - { - // - }} - fa={{ - icon: "fa-keyboard", - fixedWidth: true, - }} - /> - )); - - const icon = container.querySelector("i"); - expect(icon?.classList.contains("fa-fw")).toBe(true); - }); - - it("applies fa-fw class when fixedWidthIcon is true", () => { - const { container } = render(() => ( - { - // - }} - fa={{ - fixedWidth: true, - icon: "fa-keyboard", - }} - text="Hello" - /> - )); - - const icon = container.querySelector("i"); - expect(icon?.classList.contains("fa-fw")).toBe(true); - }); - - it("does not apply fa-fw when text is present and fixedWidthIcon is false", () => { - const { container } = render(() => ( - { - // - }} - fa={{ - icon: "fa-keyboard", - }} - text="Hello" - /> - )); - - const icon = container.querySelector("i"); - expect(icon?.classList.contains("fa-fw")).toBe(false); - }); - - it("applies default button class", () => { - const { container } = render(() => ( - { - // - }} - text="Hello" - /> - )); - - const button = container.querySelector("button"); - expect(button?.classList.contains("button")).toBe(false); - }); - - it("applies textButton class when type is text", () => { - const { container } = render(() => ( - { - // - }} - text="Hello" - type="text" - /> - )); - - const button = container.querySelector("button"); - expect(button?.classList.contains("textButton")).toBe(true); - }); - - it("applies custom class when class prop is provided", () => { - const { container } = render(() => ( - { - // - }} - text="Hello" - class="custom-class" - /> - )); - - const button = container.querySelector("button"); - expect(button?.classList.contains("custom-class")).toBe(true); - }); - - it("renders children content", () => { - const { container } = render(() => ( - { - // - }} - > - Child - - )); - - const child = container.querySelector('[data-testid="child"]'); - expect(child).toBeTruthy(); - expect(child?.textContent).toBe("Child"); - }); -}); diff --git a/frontend/__tests__/components/AnimatedModal.spec.tsx b/frontend/__tests__/components/common/AnimatedModal.spec.tsx similarity index 96% rename from frontend/__tests__/components/AnimatedModal.spec.tsx rename to frontend/__tests__/components/common/AnimatedModal.spec.tsx index 354cdf0f997c..b57934428ace 100644 --- a/frontend/__tests__/components/AnimatedModal.spec.tsx +++ b/frontend/__tests__/components/common/AnimatedModal.spec.tsx @@ -1,7 +1,7 @@ import { render } from "@solidjs/testing-library"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { AnimatedModal } from "../../src/ts/components/common/AnimatedModal"; +import { AnimatedModal } from "../../../src/ts/components/common/AnimatedModal"; describe("AnimatedModal", () => { beforeEach(() => { diff --git a/frontend/__tests__/components/common/Button.spec.tsx b/frontend/__tests__/components/common/Button.spec.tsx new file mode 100644 index 000000000000..0488c8b81d9a --- /dev/null +++ b/frontend/__tests__/components/common/Button.spec.tsx @@ -0,0 +1,297 @@ +import { cleanup, render } from "@solidjs/testing-library"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { Button } from "../../../src/ts/components/common/Button"; + +describe("Button component", () => { + afterEach(() => { + cleanup(); + }); + + it("renders a button element when onClick is provided", () => { + const onClick = vi.fn(); + + const { container } = render(() => ( + + )); + + const button = container.querySelector("button"); + expect(button).toBeTruthy(); + expect(button).toHaveTextContent("Click me"); + expect(button).not.toBeDisabled(); + }); + + it("renders an anchor element when href is provided", () => { + const { container } = render(() => ( + + )); + + const anchor = container.querySelector("a"); + expect(anchor).toBeTruthy(); + expect(anchor).toHaveAttribute("href", "https://example.com"); + expect(anchor).toHaveAttribute("target", "_blank"); + expect(anchor).toHaveAttribute("rel", "noreferrer noopener"); + expect(anchor).not.toHaveAttribute("router-link"); + expect(anchor).not.toHaveAttribute("aria-label"); + expect(anchor).not.toHaveAttribute("data-balloon-pos"); + }); + + it("calls onClick when button is clicked", async () => { + const onClick = vi.fn(); + + const { container } = render(() => ( + + )); + + const button = container.querySelector("button"); + button?.click(); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("renders icon when icon prop is provided", () => { + const { container } = render(() => ( + { + // + }} + fa={{ + icon: "fa-keyboard", + }} + /> + )); + + const icon = container.querySelector("i"); + expect(icon).toBeTruthy(); + expect(icon).toHaveClass("fas"); + expect(icon).toHaveClass("fa-keyboard"); + }); + + it("applies fa-fw class when fixedWidthIcon is true", () => { + const { container } = render(() => ( + { + // + }} + fa={{ + fixedWidth: true, + icon: "fa-keyboard", + }} + text="Hello" + /> + )); + + const icon = container.querySelector("i"); + expect(icon).toHaveClass("fa-fw"); + }); + + it("does not apply fa-fw when text is present and fixedWidthIcon is false", () => { + const { container } = render(() => ( + { + // + }} + fa={{ + icon: "fa-keyboard", + }} + text="Hello" + /> + )); + + const icon = container.querySelector("i"); + expect(icon).not.toHaveClass("fa-fw"); + }); + + it("applies default button class", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + /> + )); + + const button = container.querySelector("button"); + expect(button).not.toHaveClass("button"); + }); + + it("applies textButton class when type is text", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + type="text" + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveClass("textButton"); + }); + + it("applies custom class when class prop is provided", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + class="custom-class" + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveClass("custom-class"); + }); + + it("renders children content", () => { + const { container } = render(() => ( + { + // + }} + > + Child + + )); + + const child = container.querySelector('[data-testid="child"]'); + expect(child).toBeTruthy(); + expect(child).toHaveTextContent("Child"); + }); + + it("applies custom class list when classList prop is provided", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + classList={{ + customTrue: true, + customFalse: false, + customUndefined: undefined, + }} + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveClass("customTrue"); + expect(button).not.toHaveClass("customFalse"); + expect(button).not.toHaveClass("customUndefined"); + }); + + it("applies active", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + active + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveClass("bg-main"); + expect(button).toHaveClass("text-bg"); + expect(button).toHaveClass("hover:bg-text"); + }); + + it("applies aria-label to button provided as text", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + ariaLabel="test" + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveAttribute("aria-label", "test"); + expect(button).toHaveAttribute("data-balloon-pos", "up"); + }); + + it("applies aria-label to button provided as object", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + ariaLabel={{ text: "test", position: "down" }} + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveAttribute("aria-label", "test"); + expect(button).toHaveAttribute("data-balloon-pos", "down"); + }); + + it("applies router-link to button", () => { + const { container } = render(() => ( + { + // + }} + text="Hello" + router-link + /> + )); + + const button = container.querySelector("button"); + expect(button).toHaveAttribute("router-link", ""); + }); + + it("applies aria-label to anchor provided as text", () => { + const { container } = render(() => ( + + )); + + const anchor = container.querySelector("a"); + expect(anchor).toHaveAttribute("aria-label", "test"); + expect(anchor).toHaveAttribute("data-balloon-pos", "up"); + }); + + it("applies aria-label to anchor provided as object", () => { + const { container } = render(() => ( + + )); + + const anchor = container.querySelector("a"); + expect(anchor).toHaveAttribute("aria-label", "test"); + expect(anchor).toHaveAttribute("data-balloon-pos", "down"); + }); + + it("applies router-link to anchor", () => { + const { container } = render(() => ( + + )); + + const anchor = container.querySelector("a"); + expect(anchor).toHaveAttribute("router-link", ""); + }); + + it("applies disabled to button", () => { + const { container } = render(() => ( + { + /** */ + }} + text="Hello" + disabled={true} + /> + )); + + const button = container.querySelector("button"); + expect(button).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/ScrollToTop.spec.tsx b/frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx similarity index 95% rename from frontend/__tests__/components/ScrollToTop.spec.tsx rename to frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx index 36021bfcc7ae..99b74cb4373d 100644 --- a/frontend/__tests__/components/ScrollToTop.spec.tsx +++ b/frontend/__tests__/components/layout/footer/ScrollToTop.spec.tsx @@ -2,8 +2,8 @@ import { render } from "@solidjs/testing-library"; import { userEvent } from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { ScrollToTop } from "../../src/ts/components/layout/footer/ScrollToTop"; -import * as CoreSignals from "../../src/ts/signals/core"; +import { ScrollToTop } from "../../../../src/ts/components/layout/footer/ScrollToTop"; +import * as CoreSignals from "../../../../src/ts/signals/core"; describe("ScrollToTop", () => { const getActivePageMock = vi.spyOn(CoreSignals, "getActivePage"); diff --git a/frontend/src/ts/components/common/AsyncContent.tsx b/frontend/src/ts/components/common/AsyncContent.tsx index be3e4a37037f..d8d504fec9d0 100644 --- a/frontend/src/ts/components/common/AsyncContent.tsx +++ b/frontend/src/ts/components/common/AsyncContent.tsx @@ -13,7 +13,7 @@ import * as Notifications from "../../elements/notifications"; import { createErrorMessage } from "../../utils/misc"; import { Conditional } from "./Conditional"; -import { Fa } from "./Fa"; +import { LoadingCircle } from "./LoadingCircle"; export default function AsyncContent( props: { @@ -78,11 +78,7 @@ export default function AsyncContent( return message; }; - const loader = ( - - - - ); + const loader = ; const errorText = (err: unknown): JSXElement => ( {handleError(err)} diff --git a/frontend/src/ts/components/common/Button.tsx b/frontend/src/ts/components/common/Button.tsx index acd421c94a66..40ef3c4d34ec 100644 --- a/frontend/src/ts/components/common/Button.tsx +++ b/frontend/src/ts/components/common/Button.tsx @@ -1,4 +1,4 @@ -import { JSXElement, Show } from "solid-js"; +import { JSX, JSXElement, Show } from "solid-js"; import { Conditional } from "./Conditional"; import { Fa, FaProps } from "./Fa"; @@ -7,24 +7,33 @@ type BaseProps = { text?: string; fa?: FaProps; class?: string; + classList?: JSX.HTMLAttributes["classList"]; type?: "text" | "button"; children?: JSXElement; + ariaLabel?: + | string + | { text: string; position: "up" | "down" | "left" | "right" }; + "router-link"?: true; }; type ButtonProps = BaseProps & { onClick: () => void; href?: never; sameTarget?: true; + active?: boolean; + disabled?: boolean; }; type AnchorProps = BaseProps & { href: string; onClick?: never; + disabled?: never; }; export function Button(props: ButtonProps | AnchorProps): JSXElement { const isAnchor = "href" in props; const buttonClass = isAnchor ? "button" : ""; + const isActive = (): boolean => (!isAnchor && props.active) ?? false; const content = ( <> @@ -36,10 +45,25 @@ export function Button(props: ButtonProps | AnchorProps): JSXElement { > ); - const getClassList = (): Record => { + const ariaLabel = (): object => { + if (props.ariaLabel === undefined) return {}; + if (typeof props.ariaLabel === "string") { + return { "aria-label": props.ariaLabel, "data-balloon-pos": "up" }; + } + return { + "aria-label": props.ariaLabel.text, + "data-balloon-pos": props.ariaLabel.position, + }; + }; + + const getClassList = (): Record => { return { [(props.type ?? "button") === "text" ? "textButton" : buttonClass]: true, [props.class ?? ""]: props.class !== undefined, + "bg-main": isActive(), + "text-bg": isActive(), + "hover:bg-text": isActive(), + ...props.classList, }; }; @@ -52,6 +76,8 @@ export function Button(props: ButtonProps | AnchorProps): JSXElement { href={props.href} target={props.href?.startsWith("#") ? undefined : "_blank"} rel={props.href?.startsWith("#") ? undefined : "noreferrer noopener"} + {...ariaLabel()} + {...(props["router-link"] ? { "router-link": "" } : {})} > {content} @@ -61,6 +87,9 @@ export function Button(props: ButtonProps | AnchorProps): JSXElement { type="button" classList={getClassList()} onClick={() => props.onClick?.()} + {...ariaLabel()} + {...(props["router-link"] ? { "router-link": "" } : {})} + disabled={props.disabled ?? false} > {content} diff --git a/frontend/src/ts/components/common/DiscordAvatar.tsx b/frontend/src/ts/components/common/DiscordAvatar.tsx new file mode 100644 index 000000000000..aaf1a96b91c6 --- /dev/null +++ b/frontend/src/ts/components/common/DiscordAvatar.tsx @@ -0,0 +1,48 @@ +import { createSignal, JSXElement, Show } from "solid-js"; +import { createStore } from "solid-js/store"; + +import { FaSolidIcon } from "../../types/font-awesome"; + +import { Fa } from "./Fa"; + +//cache successful and missing avatars +const [avatar, setAvatar] = createStore>(); + +export function DiscordAvatar(props: { + discordId: string | undefined; + discordAvatar: string | undefined; + size?: number; + missingIcon?: FaSolidIcon; +}): JSXElement { + const cacheKey = (): string => `${props.discordId}/${props.discordAvatar}`; + const [showSpinner, setShowSpinner] = createSignal(true); + return ( + + } + > + <> + + + + { + setAvatar(cacheKey(), true); + setShowSpinner(false); + }} + onError={() => { + setAvatar(cacheKey(), false); + }} + /> + > + + + ); +} diff --git a/frontend/src/ts/components/common/Fa.tsx b/frontend/src/ts/components/common/Fa.tsx index 60604d84f930..ae8d20b92543 100644 --- a/frontend/src/ts/components/common/Fa.tsx +++ b/frontend/src/ts/components/common/Fa.tsx @@ -1,18 +1,20 @@ import { JSXElement } from "solid-js"; import { FaObject } from "../../types/font-awesome"; +import { cn } from "../../utils/cn"; export type FaProps = { fixedWidth?: boolean; spin?: boolean; size?: number; + class?: string; } & FaObject; export function Fa(props: FaProps): JSXElement { const variant = (): string => props.variant ?? "solid"; return ( + + + + {props.text} + + ); +} + +export function H2(props: { text: string; fa?: FaProps }): JSXElement { + return ( + + + + + {props.text} + + ); +} + +export function H3(props: { text: string; fa: FaProps }): JSXElement { + return ( + + + {props.text} + + ); +} diff --git a/frontend/src/ts/components/common/LoadingCircle.tsx b/frontend/src/ts/components/common/LoadingCircle.tsx new file mode 100644 index 000000000000..180703e4a841 --- /dev/null +++ b/frontend/src/ts/components/common/LoadingCircle.tsx @@ -0,0 +1,10 @@ +import { JSXElement } from "solid-js"; + +import { Fa } from "./Fa"; +export function LoadingCircle(): JSXElement { + return ( + + + + ); +} diff --git a/frontend/src/ts/components/common/User.tsx b/frontend/src/ts/components/common/User.tsx new file mode 100644 index 000000000000..079ca696a473 --- /dev/null +++ b/frontend/src/ts/components/common/User.tsx @@ -0,0 +1,99 @@ +import { User as UserType } from "@monkeytype/schemas/users"; +import { For, JSXElement, Show } from "solid-js"; + +import { + badges, + UserBadge as UserBadgeType, +} from "../../controllers/badge-controller"; +import { + getMatchingFlags, + SupportsFlags, + UserFlag, + UserFlagOptions, +} from "../../controllers/user-flag-controller"; + +import { Button } from "./Button"; +import { DiscordAvatar } from "./DiscordAvatar"; +import { Fa } from "./Fa"; + +export function User( + props: { + user: SupportsFlags & + Pick & { + badgeId?: number; + }; + showAvatar?: boolean; + } & UserFlagOptions, +): JSXElement { + return ( + + + + + + + + + + + ); +} + +function UserFlags(props: SupportsFlags & UserFlagOptions): JSXElement { + const flags = (): UserFlag[] => getMatchingFlags(props); + + return ( + + {(flag) => ( + }> + + {} + + + )} + + ); +} + +function UserBadge(props: { id?: number }): JSXElement { + const badge = (): UserBadgeType | undefined => + props.id !== undefined ? badges[props.id] : undefined; + return ( + + + + + {badge()?.name} + + + + ); +} diff --git a/frontend/src/ts/components/pages/AboutPage.tsx b/frontend/src/ts/components/pages/AboutPage.tsx index 20335d005d87..6b79cb26dc18 100644 --- a/frontend/src/ts/components/pages/AboutPage.tsx +++ b/frontend/src/ts/components/pages/AboutPage.tsx @@ -15,25 +15,9 @@ import { qsr } from "../../utils/dom"; import AsyncContent from "../common/AsyncContent"; import { Button } from "../common/Button"; import { ChartJs } from "../common/ChartJs"; -import { Fa, FaProps } from "../common/Fa"; - -function H2(props: { text: string; fa: FaProps }): JSXElement { - return ( - - - {props.text} - - ); -} - -function H3(props: { text: string; fa: FaProps }): JSXElement { - return ( - - - {props.text} - - ); -} +import { Fa } from "../common/Fa"; +import { H1, H3 } from "../common/Headers"; +import { User } from "../common/User"; qsr("nav .view-about").on("mouseenter", () => { prefetch(); @@ -64,6 +48,53 @@ export function AboutPage(): JSXElement { return ( + + {/*TODO remove after testing */} + + + + + + + + + + + + + + + Created with love by Miodec. @@ -173,7 +204,7 @@ export function AboutPage(): JSXElement { - + Monkeytype is a minimalistic and customizable typing test. It features many test modes, an account system to save your typing speed history, @@ -281,7 +312,7 @@ export function AboutPage(): JSXElement { - + Thanks to everyone who has supported this project. It would not be possible without you and your continued support. @@ -299,7 +330,7 @@ export function AboutPage(): JSXElement { - + If you encounter a bug, have a feature request or just want to say hi - here are the different ways you can contact me directly. @@ -333,7 +364,7 @@ export function AboutPage(): JSXElement { - + - + - + = +export type DataTableColumnDef = | ColumnDef | AccessorFnColumnDef | AccessorKeyColumnDef; type DataTableProps = { id: string; - columns: AnyColumnDef[]; + columns: DataTableColumnDef[]; data: TData[]; fallback?: JSXElement; + hideHeader?: true; }; export function DataTable( @@ -71,13 +72,18 @@ export function DataTable( const current = bp(); const result = Object.fromEntries( props.columns.map((col, index) => { - const id = + col.id = col.id ?? ("accessorKey" in col && col.accessorKey !== null ? String(col.accessorKey) : `__col_${index}`); - return [id, current[col.meta?.breakpoint ?? "xxs"]]; + const visible = + current[col.meta?.breakpoint ?? "xxs"] && + (col.meta?.maxBreakpoint === undefined || + !current[col.meta?.maxBreakpoint]); + + return [col.id, visible]; }), ); @@ -107,34 +113,92 @@ export function DataTable( return ( - - - {(headerGroup) => ( - - - {(header) => ( - - { - header.column.getToggleSortingHandler()?.(e); + + + + {(headerGroup) => ( + + + {(header) => ( + + { + header.column.getToggleSortingHandler()?.(e); + }} + class="m-0 box-border flex h-full w-full cursor-pointer items-start justify-start rounded-none border-0 bg-transparent p-2 font-normal whitespace-nowrap text-sub hover:bg-sub-alt" + classList={{ + "text-left": + (header.column.columnDef.meta?.align ?? + "left") === "left", + "text-center": + header.column.columnDef.meta?.align === + "center", + "text-right": + header.column.columnDef.meta?.align === + "right", + }} + {...(header.column.columnDef.meta?.headerMeta ?? + {})} + > + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + }> + + + + + + + + + + } + else={ + {flexRender( @@ -142,47 +206,16 @@ export function DataTable( header.getContext(), )} - - }> - - - - - - - - - - } - else={ - - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - - } - /> - )} - - - )} - - + + } + /> + )} + + + )} + + + {(row) => ( @@ -197,7 +230,18 @@ export function DataTable( }) : (cell.column.columnDef.meta?.cellMeta ?? {}); return ( - + {flexRender( cell.column.columnDef.cell, cell.getContext(), diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx index 88f1abd7390f..e625bb19d541 100644 --- a/frontend/src/ts/components/ui/table/Table.tsx +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -57,7 +57,7 @@ const TableHead: Component> = (props) => { = { +export const badges: Record = { 1: { id: 1, name: "Developer", description: "I made this", icon: "fa-laptop", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%)", + }, }, 2: { id: 2, @@ -24,8 +30,11 @@ const badges: Record = { description: "I helped make this", icon: "fa-code", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%)", + }, }, 3: { id: 3, @@ -33,8 +42,11 @@ const badges: Record = { description: "Discord server moderator", icon: "fa-hammer", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, 4: { id: 4, @@ -114,8 +126,11 @@ const badges: Record = { description: "Yes, I'm actually this fast", icon: "fa-rocket", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, 14: { id: 14, @@ -132,8 +147,11 @@ const badges: Record = { icon: "fa-bomb", color: "white", background: "#093d79", - customStyle: - "animation: gold-shimmer 10s cubic-bezier(0.5, 0, 0.5, 1) infinite; background: linear-gradient(90deg, rgb(8 31 84) 0%, rgb(18 134 158) 100%); background-size: 200% 200%;", + customStyle: { + animation: "gold-shimmer 10s cubic-bezier(0.5, 0, 0.5, 1) infinite", + background: + "linear-gradient(90deg, rgb(8 31 84) 0%, rgb(18 134 158) 100%); background-size: 200% 200%;", + }, }, 16: { id: 16, @@ -141,8 +159,12 @@ const badges: Record = { description: "Longest test with zero mistakes - 4 hours and 1 minute", icon: "fa-bullseye", color: "white", - customStyle: - "animation: gold-shimmer 10s cubic-bezier(0.5, -0.15, 0.5, 1.15) infinite; background: linear-gradient(45deg, #b8860b 0%, #daa520 25%, #ffd700 50%, #daa520 75%, #b8860b 100%); background-size: 200% 200%;", + customStyle: { + animation: + "gold-shimmer 10s cubic-bezier(0.5, -0.15, 0.5, 1.15) infinite", + background: + "linear-gradient(45deg, #b8860b 0%, #daa520 25%, #ffd700 50%, #daa520 75%, #b8860b 100%); background-size: 200% 200%;", + }, }, 17: { id: 17, @@ -150,8 +172,11 @@ const badges: Record = { description: "Ferb, I know what we're gonna do today...", icon: "fa-sun", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, }; @@ -175,7 +200,9 @@ export function getHTMLById( style += `color: ${badge.color};`; } if (badge?.customStyle !== undefined) { - style += badge.customStyle; + style += Object.entries(badge.customStyle) + .map(([key, value]) => `${key}: ${value};`) + .join(";"); } const badgeName = badge?.name ?? "Badge Name Missing"; diff --git a/frontend/src/ts/controllers/user-flag-controller.ts b/frontend/src/ts/controllers/user-flag-controller.ts index 790f83dcd8bc..58d588eb2c64 100644 --- a/frontend/src/ts/controllers/user-flag-controller.ts +++ b/frontend/src/ts/controllers/user-flag-controller.ts @@ -1,3 +1,5 @@ +import { FaSolidIcon } from "../types/font-awesome"; + const flags: UserFlag[] = [ { name: "Prime Ape", @@ -34,17 +36,16 @@ export type SupportsFlags = { isFriend?: boolean; }; -type UserFlag = { +export type UserFlag = { readonly name: string; readonly description: string; - readonly icon: string; + readonly icon: FaSolidIcon; readonly color?: string; readonly background?: string; - readonly customStyle?: string; test(source: SupportsFlags): boolean; }; -type UserFlagOptions = { +export type UserFlagOptions = { iconsOnly?: boolean; isFriend?: boolean; }; @@ -53,7 +54,7 @@ const USER_FLAG_OPTIONS_DEFAULT: UserFlagOptions = { iconsOnly: false, }; -function getMatchingFlags(source: SupportsFlags): UserFlag[] { +export function getMatchingFlags(source: SupportsFlags): UserFlag[] { const result = flags.filter((it) => it.test(source)); return result; } @@ -71,9 +72,6 @@ function toHtml(flag: UserFlag, formatOptions: UserFlagOptions): string { if (flag?.color !== undefined) { style.push(`color: ${flag.color};`); } - if (flag?.customStyle !== undefined) { - style.push(flag.customStyle); - } const balloon = `aria-label="${flag.description}" data-balloon-pos="right"`; diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts index d69863e3b835..b0719fca4858 100644 --- a/frontend/src/ts/types/tanstack-table.d.ts +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -12,6 +12,17 @@ declare module "@tanstack/solid-table" { */ breakpoint?: BreakpointKey; + /** + * define maximal breakpoint for the column to be visible. + * If not set, the column is always visible + */ + maxBreakpoint?: BreakpointKey; + + /** + * align header and cells, default: `left` + */ + align?: "left" | "right" | "center"; + /** * additional attributes to be set on the table cell. * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` @@ -24,9 +35,9 @@ declare module "@tanstack/solid-table" { }) => JSX.HTMLAttributes); /** - * additional attributes to be set on the header if it is sortable + * additional attributes to be set on the header * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` */ - sortableHeaderMeta?: JSX.HTMLAttributes; + headerMeta?: JSX.HTMLAttributes; } }
Monkeytype is a minimalistic and customizable typing test. It features many test modes, an account system to save your typing speed history, @@ -281,7 +312,7 @@ export function AboutPage(): JSXElement {
Thanks to everyone who has supported this project. It would not be possible without you and your continued support. @@ -299,7 +330,7 @@ export function AboutPage(): JSXElement {
If you encounter a bug, have a feature request or just want to say hi - here are the different ways you can contact me directly. @@ -333,7 +364,7 @@ export function AboutPage(): JSXElement {
- + - + = +export type DataTableColumnDef = | ColumnDef | AccessorFnColumnDef | AccessorKeyColumnDef; type DataTableProps = { id: string; - columns: AnyColumnDef[]; + columns: DataTableColumnDef[]; data: TData[]; fallback?: JSXElement; + hideHeader?: true; }; export function DataTable( @@ -71,13 +72,18 @@ export function DataTable( const current = bp(); const result = Object.fromEntries( props.columns.map((col, index) => { - const id = + col.id = col.id ?? ("accessorKey" in col && col.accessorKey !== null ? String(col.accessorKey) : `__col_${index}`); - return [id, current[col.meta?.breakpoint ?? "xxs"]]; + const visible = + current[col.meta?.breakpoint ?? "xxs"] && + (col.meta?.maxBreakpoint === undefined || + !current[col.meta?.maxBreakpoint]); + + return [col.id, visible]; }), ); @@ -107,34 +113,92 @@ export function DataTable( return ( - - - {(headerGroup) => ( - - - {(header) => ( - - { - header.column.getToggleSortingHandler()?.(e); + + + + {(headerGroup) => ( + + + {(header) => ( + + { + header.column.getToggleSortingHandler()?.(e); + }} + class="m-0 box-border flex h-full w-full cursor-pointer items-start justify-start rounded-none border-0 bg-transparent p-2 font-normal whitespace-nowrap text-sub hover:bg-sub-alt" + classList={{ + "text-left": + (header.column.columnDef.meta?.align ?? + "left") === "left", + "text-center": + header.column.columnDef.meta?.align === + "center", + "text-right": + header.column.columnDef.meta?.align === + "right", + }} + {...(header.column.columnDef.meta?.headerMeta ?? + {})} + > + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + + }> + + + + + + + + + + } + else={ + {flexRender( @@ -142,47 +206,16 @@ export function DataTable( header.getContext(), )} - - }> - - - - - - - - - - } - else={ - - - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - - } - /> - )} - - - )} - - + + } + /> + )} + + + )} + + + {(row) => ( @@ -197,7 +230,18 @@ export function DataTable( }) : (cell.column.columnDef.meta?.cellMeta ?? {}); return ( - + {flexRender( cell.column.columnDef.cell, cell.getContext(), diff --git a/frontend/src/ts/components/ui/table/Table.tsx b/frontend/src/ts/components/ui/table/Table.tsx index 88f1abd7390f..e625bb19d541 100644 --- a/frontend/src/ts/components/ui/table/Table.tsx +++ b/frontend/src/ts/components/ui/table/Table.tsx @@ -57,7 +57,7 @@ const TableHead: Component> = (props) => { = { +export const badges: Record = { 1: { id: 1, name: "Developer", description: "I made this", icon: "fa-laptop", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%)", + }, }, 2: { id: 2, @@ -24,8 +30,11 @@ const badges: Record = { description: "I helped make this", icon: "fa-code", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%)", + }, }, 3: { id: 3, @@ -33,8 +42,11 @@ const badges: Record = { description: "Discord server moderator", icon: "fa-hammer", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, 4: { id: 4, @@ -114,8 +126,11 @@ const badges: Record = { description: "Yes, I'm actually this fast", icon: "fa-rocket", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, 14: { id: 14, @@ -132,8 +147,11 @@ const badges: Record = { icon: "fa-bomb", color: "white", background: "#093d79", - customStyle: - "animation: gold-shimmer 10s cubic-bezier(0.5, 0, 0.5, 1) infinite; background: linear-gradient(90deg, rgb(8 31 84) 0%, rgb(18 134 158) 100%); background-size: 200% 200%;", + customStyle: { + animation: "gold-shimmer 10s cubic-bezier(0.5, 0, 0.5, 1) infinite", + background: + "linear-gradient(90deg, rgb(8 31 84) 0%, rgb(18 134 158) 100%); background-size: 200% 200%;", + }, }, 16: { id: 16, @@ -141,8 +159,12 @@ const badges: Record = { description: "Longest test with zero mistakes - 4 hours and 1 minute", icon: "fa-bullseye", color: "white", - customStyle: - "animation: gold-shimmer 10s cubic-bezier(0.5, -0.15, 0.5, 1.15) infinite; background: linear-gradient(45deg, #b8860b 0%, #daa520 25%, #ffd700 50%, #daa520 75%, #b8860b 100%); background-size: 200% 200%;", + customStyle: { + animation: + "gold-shimmer 10s cubic-bezier(0.5, -0.15, 0.5, 1.15) infinite", + background: + "linear-gradient(45deg, #b8860b 0%, #daa520 25%, #ffd700 50%, #daa520 75%, #b8860b 100%); background-size: 200% 200%;", + }, }, 17: { id: 17, @@ -150,8 +172,11 @@ const badges: Record = { description: "Ferb, I know what we're gonna do today...", icon: "fa-sun", color: "white", - customStyle: - "animation: rgb-bg 10s linear infinite; background: linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + customStyle: { + animation: "rgb-bg 10s linear infinite", + background: + "linear-gradient(45deg in hsl longer hue, hsl(330, 90%, 30%) 0%, hsl(250, 90%, 30%) 100%);", + }, }, }; @@ -175,7 +200,9 @@ export function getHTMLById( style += `color: ${badge.color};`; } if (badge?.customStyle !== undefined) { - style += badge.customStyle; + style += Object.entries(badge.customStyle) + .map(([key, value]) => `${key}: ${value};`) + .join(";"); } const badgeName = badge?.name ?? "Badge Name Missing"; diff --git a/frontend/src/ts/controllers/user-flag-controller.ts b/frontend/src/ts/controllers/user-flag-controller.ts index 790f83dcd8bc..58d588eb2c64 100644 --- a/frontend/src/ts/controllers/user-flag-controller.ts +++ b/frontend/src/ts/controllers/user-flag-controller.ts @@ -1,3 +1,5 @@ +import { FaSolidIcon } from "../types/font-awesome"; + const flags: UserFlag[] = [ { name: "Prime Ape", @@ -34,17 +36,16 @@ export type SupportsFlags = { isFriend?: boolean; }; -type UserFlag = { +export type UserFlag = { readonly name: string; readonly description: string; - readonly icon: string; + readonly icon: FaSolidIcon; readonly color?: string; readonly background?: string; - readonly customStyle?: string; test(source: SupportsFlags): boolean; }; -type UserFlagOptions = { +export type UserFlagOptions = { iconsOnly?: boolean; isFriend?: boolean; }; @@ -53,7 +54,7 @@ const USER_FLAG_OPTIONS_DEFAULT: UserFlagOptions = { iconsOnly: false, }; -function getMatchingFlags(source: SupportsFlags): UserFlag[] { +export function getMatchingFlags(source: SupportsFlags): UserFlag[] { const result = flags.filter((it) => it.test(source)); return result; } @@ -71,9 +72,6 @@ function toHtml(flag: UserFlag, formatOptions: UserFlagOptions): string { if (flag?.color !== undefined) { style.push(`color: ${flag.color};`); } - if (flag?.customStyle !== undefined) { - style.push(flag.customStyle); - } const balloon = `aria-label="${flag.description}" data-balloon-pos="right"`; diff --git a/frontend/src/ts/types/tanstack-table.d.ts b/frontend/src/ts/types/tanstack-table.d.ts index d69863e3b835..b0719fca4858 100644 --- a/frontend/src/ts/types/tanstack-table.d.ts +++ b/frontend/src/ts/types/tanstack-table.d.ts @@ -12,6 +12,17 @@ declare module "@tanstack/solid-table" { */ breakpoint?: BreakpointKey; + /** + * define maximal breakpoint for the column to be visible. + * If not set, the column is always visible + */ + maxBreakpoint?: BreakpointKey; + + /** + * align header and cells, default: `left` + */ + align?: "left" | "right" | "center"; + /** * additional attributes to be set on the table cell. * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` @@ -24,9 +35,9 @@ declare module "@tanstack/solid-table" { }) => JSX.HTMLAttributes); /** - * additional attributes to be set on the header if it is sortable + * additional attributes to be set on the header * Can be used to define mouse-overs with `aria-label` and `data-balloon-pos` */ - sortableHeaderMeta?: JSX.HTMLAttributes; + headerMeta?: JSX.HTMLAttributes; } }