adding monkeytype
Some checks failed
Mark Stale PRs / stale (push) Has been cancelled

This commit is contained in:
Benjamin Falch
2026-04-23 13:53:44 +02:00
parent e214a2fd35
commit 2bc741fb78
1930 changed files with 7590652 additions and 0 deletions

5
frontend/storybook/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
storybook-static
bun.lock
package-lock.json

View File

@@ -0,0 +1,51 @@
import type { ThemeName } from "@monkeytype/schemas/configs";
import type { JSXElement } from "solid-js";
import { ThemesList, ThemeWithName } from "../../src/ts/constants/themes";
type StoryContext = {
globals: { theme?: string };
};
const themeMap = new Map(ThemesList.map((t) => [t.name, t]));
let currentThemeLink: HTMLLinkElement | null = null;
export function ThemeDecorator(
Story: () => JSXElement,
context: StoryContext,
): JSXElement {
const themeName = (context.globals.theme ?? "serika_dark") as ThemeName;
const theme =
themeMap.get(themeName) ?? (themeMap.get("serika_dark") as ThemeWithName);
const root = document.documentElement;
root.style.setProperty("--bg-color", theme.bg);
root.style.setProperty("--main-color", theme.main);
root.style.setProperty("--caret-color", theme.caret);
root.style.setProperty("--sub-color", theme.sub);
root.style.setProperty("--sub-alt-color", theme.subAlt);
root.style.setProperty("--text-color", theme.text);
root.style.setProperty("--error-color", theme.error);
root.style.setProperty("--error-extra-color", theme.errorExtra);
root.style.setProperty("--colorful-error-color", theme.colorfulError);
root.style.setProperty(
"--colorful-error-extra-color",
theme.colorfulErrorExtra,
);
// Load/unload theme CSS file
if (currentThemeLink) {
currentThemeLink.remove();
currentThemeLink = null;
}
if (theme.hasCss) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `/themes/${themeName}.css`;
document.head.appendChild(link);
currentThemeLink = link;
}
return Story();
}

View File

@@ -0,0 +1,153 @@
import { defineMain } from "storybook-solidjs-vite";
import tailwindcss from "@tailwindcss/vite";
import type { Plugin } from "vite";
function stubVirtualEnvConfig(): Plugin {
const id = "virtual:env-config";
const resolved = "\0" + id;
return {
name: "stub-virtual-env-config",
resolveId(source) {
if (source === id) return resolved;
},
load(loadId) {
if (loadId === resolved) {
return `export const envConfig = ${JSON.stringify({
isDevelopment: true,
backendUrl: "http://localhost:5005",
clientVersion: "storybook",
recaptchaSiteKey: "",
quickLoginEmail: undefined,
quickLoginPassword: undefined,
})};`;
}
},
};
}
function patchQsrToNotThrow(): Plugin {
return {
name: "patch-qsr-not-throw",
enforce: "pre",
transform(code, id) {
if (!id.includes("utils/dom")) return;
// Replace the throw in qsr with creating a dummy element
return code.replaceAll(
`throw new Error(\`Required element not found: \${selector}\`);`,
`console.warn(\`[storybook] qsr: element not found: \${selector}, returning dummy\`);
return new ElementWithUtils(document.createElement("div") as T);`,
);
},
};
}
function patchAnimatedModalToNotThrow(): Plugin {
return {
name: "patch-animated-modal-not-throw",
enforce: "pre",
transform(code, id) {
if (!id.includes("utils/animated-modal")) return;
return code
.replaceAll(
`throw new Error(
\`Dialog element with id \${constructorParams.dialogId} not found\`,
);`,
`console.warn(\`[storybook] AnimatedModal: dialog #\${constructorParams.dialogId} not found\`); return;`,
)
.replace(
`throw new Error("Animated dialog must be an HTMLDialogElement");`,
`console.warn("[storybook] AnimatedModal: element is not a dialog"); return;`,
);
},
};
}
function stubChartController(): Plugin {
const stubId = "\0stub-chart-controller";
const stubCode = `
const noop = () => {};
const fakeScale = new Proxy({}, { get: () => "" , set: () => true });
const fakeDataset = new Proxy({}, { get: () => [], set: () => true });
const fakeChart = {
data: { labels: [] },
options: { plugins: {} },
getDataset: () => fakeDataset,
getScale: () => fakeScale,
update: noop,
resize: noop,
};
export class ChartWithUpdateColors {}
export const result = fakeChart;
export const accountHistory = fakeChart;
export const accountActivity = fakeChart;
export const accountHistogram = fakeChart;
export const miniResult = fakeChart;
export let accountHistoryActiveIndex = 0;
export function updateAccountChartButtons() {}
`;
return {
name: "stub-chart-controller",
enforce: "pre",
resolveId(source, _importer) {
if (
source.endsWith("controllers/chart-controller") ||
source.endsWith("controllers/chart-controller.ts")
) {
return stubId;
}
},
load(id) {
if (id === stubId) return stubCode;
if (id.includes("controllers/chart-controller")) return stubCode;
},
};
}
function stubVirtualLanguageHashes(): Plugin {
const id = "virtual:language-hashes";
const resolved = "\0" + id;
return {
name: "stub-virtual-language-hashes",
resolveId(source) {
if (source === id) return resolved;
},
load(loadId) {
if (loadId === resolved) {
return `export const languageHashes = {};`;
}
},
};
}
export default defineMain({
staticDirs: ["../../static"],
framework: {
name: "storybook-solidjs-vite",
options: {
// docgen: {
// Enabled by default, but you can configure or disable it:
// see https://github.com/styleguidist/react-docgen-typescript#options
// },
},
},
addons: [
"@storybook/addon-docs",
"@storybook/addon-a11y",
"@storybook/addon-links",
"@storybook/addon-vitest",
],
stories: [
"../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
viteFinal(config) {
config.plugins ??= [];
config.plugins.push(tailwindcss());
config.plugins.push(stubVirtualEnvConfig());
config.plugins.push(stubVirtualLanguageHashes());
config.plugins.push(patchQsrToNotThrow());
config.plugins.push(patchAnimatedModalToNotThrow());
config.plugins.push(stubChartController());
return config;
},
});

View File

@@ -0,0 +1,6 @@
<!-- Stub elements needed by components with module-level DOM query calls -->
<body>
<div style="display: none">
<input id="wordsInput" />
</div>
</body>

View File

@@ -0,0 +1,74 @@
import addonA11y from "@storybook/addon-a11y";
import addonDocs from "@storybook/addon-docs";
import { definePreview } from "storybook-solidjs-vite";
import "../stories/tailwind.css";
import "../stories/storybook-theme.css";
import "@fortawesome/fontawesome-free/css/all.min.css";
import "balloon-css/balloon.min.css";
//@ts-expect-error this works i think
import "slim-select/styles";
import { ThemesList } from "../../src/ts/constants/themes";
import { ThemeDecorator } from "./ThemeDecorator";
const tailwindViewports = {
xxs: { name: "xxs (331px)", styles: { width: "331px", height: "900px" } },
xs: { name: "xs (426px)", styles: { width: "426px", height: "900px" } },
sm: { name: "sm (640px)", styles: { width: "640px", height: "900px" } },
md: { name: "md (768px)", styles: { width: "768px", height: "900px" } },
lg: { name: "lg (1024px)", styles: { width: "1024px", height: "900px" } },
xl: { name: "xl (1280px)", styles: { width: "1280px", height: "900px" } },
"2xl": {
name: "2xl (1536px)",
styles: { width: "1536px", height: "900px" },
},
};
export default definePreview({
addons: [addonDocs(), addonA11y()],
globalTypes: {
theme: {
description: "Global theme for components",
toolbar: {
title: "Theme",
icon: "paintbrush",
items: ThemesList.sort((a, b) => a.name.localeCompare(b.name)).map(
(t) => ({
value: t.name,
title: t.name.replace(/_/g, " "),
}),
),
dynamicTitle: true,
},
},
},
initialGlobals: {
theme: "serika_dark",
},
decorators: [ThemeDecorator],
parameters: {
layout: "centered",
// automatically create action args for all props that start with 'on'
actions: {
argTypesRegex: "^on.*",
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
viewport: {
options: tailwindViewports,
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: "todo",
},
},
// All components will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
// tags: ['autodocs'],
});

View File

@@ -0,0 +1,38 @@
{
"name": "@monkeytype/frontend-storybook",
"version": "0.0.0",
"private": true,
"imports": {
"#*": [
"./*",
"./*.ts",
"./*.tsx"
]
},
"scripts": {
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"slim-select": "2.9.2"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "^10.2.14",
"@storybook/addon-docs": "^10.2.14",
"@storybook/addon-links": "^10.2.14",
"@storybook/addon-onboarding": "^10.2.14",
"@storybook/addon-vitest": "^10.2.14",
"@storybook/builder-vite": "^10.2.14",
"@tailwindcss/vite": "^4.2.1",
"@vitest/browser": "^4.0.18",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"playwright": "^1.58.2",
"solid-js": "^1.9.11",
"storybook": "^10.2.14",
"storybook-solidjs-vite": "^10.0.9",
"vite": "^7.3.2",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,37 @@
import preview from "#.storybook/preview";
import { AccountMenu } from "../../src/ts/components/layout/header/AccountMenu";
const meta = preview.meta({
title: "Layout/Header/AccountMenu",
component: AccountMenu,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
showFriendsNotificationBubble: { control: "boolean" },
},
});
export const Default = meta.story({
render: () => (
<div
style={{ position: "relative", "pointer-events": "auto", opacity: 1 }}
class="**:data-[ui-element='accountMenu']:pointer-events-auto **:data-[ui-element='accountMenu']:opacity-100"
>
<AccountMenu showFriendsNotificationBubble={false} />
</div>
),
});
export const WithNotification = meta.story({
render: () => (
<div
style={{ position: "relative", "pointer-events": "auto", opacity: 1 }}
class="**:data-[ui-element='accountMenu']:pointer-events-auto **:data-[ui-element='accountMenu']:opacity-100"
>
<AccountMenu showFriendsNotificationBubble />
</div>
),
});

View File

@@ -0,0 +1,62 @@
import type { XpBreakdown } from "@monkeytype/schemas/results";
import preview from "#.storybook/preview";
import { onMount } from "solid-js";
import { AccountXpBar } from "../../src/ts/components/layout/header/AccountXpBar";
import { setXpBarData, setAnimatedLevel } from "../../src/ts/states/header";
const meta = preview.meta({
title: "Layout/Header/AccountXpBar",
component: AccountXpBar,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
function XpBarStory(props: {
level: number;
addedXp: number;
resultingXp: number;
breakdown?: XpBreakdown;
}) {
onMount(() => {
setXpBarData(null);
setAnimatedLevel(props.level);
// Delay so the effect picks up the change
setTimeout(() => {
setXpBarData({
addedXp: props.addedXp,
resultingXp: props.resultingXp,
breakdown: props.breakdown,
});
}, 1000);
});
return (
<div class="relative">
<div style="width: 150px;"></div>
<AccountXpBar />
</div>
);
}
export const WithBreakdown = meta.story(() => (
<XpBarStory
level={1}
addedXp={150}
resultingXp={150}
breakdown={{
base: 80,
fullAccuracy: 20,
punctuation: 10,
streak: 25,
daily: 15,
}}
/>
));
export const NoBreakdown = meta.story(() => (
<XpBarStory level={1} addedXp={200} resultingXp={200} />
));

View File

@@ -0,0 +1,93 @@
import preview from "#.storybook/preview";
import { AnimatedModal } from "../../src/ts/components/common/AnimatedModal";
import { ModalId, showModal } from "../../src/ts/states/modals";
function ModalTrigger(props: { modalId: ModalId; label: string }) {
return (
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
}}
onClick={() => showModal(props.modalId)}
>
{props.label}
</button>
);
}
const meta = preview.meta({
title: "Common/AnimatedModal",
component: AnimatedModal,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
argTypes: {
mode: { control: "select", options: ["modal", "dialog"] },
animationMode: {
control: "select",
options: ["none", "both", "modalOnly"],
},
title: { control: "text" },
modalClass: { control: "text" },
wrapperClass: { control: "text" },
},
decorators: [
(Story, context) => {
// oxlint-disable-next-line typescript/no-unsafe-member-access -- storybook decorator context is untyped
const modalId = context.args.id as ModalId;
// oxlint-disable-next-line typescript/no-unsafe-member-access -- storybook decorator context is untyped
const title = (context.args.title as string) ?? "Modal";
return (
<div>
<ModalTrigger modalId={modalId} label={`Open ${title}`} />
<Story />
</div>
);
},
],
});
export const Default = meta.story({
args: {
id: "Contact",
title: "Example Modal",
children: (
<div>
<p>This is modal content.</p>
</div>
),
},
});
export const NoAnimation = meta.story({
args: {
id: "Support",
title: "No Animation Modal",
animationMode: "none",
children: (
<div>
<p>This modal has no animation.</p>
</div>
),
},
});
export const DialogMode = meta.story({
args: {
id: "DevOptions",
mode: "dialog",
title: "Dialog Mode",
children: (
<div>
<p>This uses dialog mode instead of modal.</p>
</div>
),
},
});

View File

@@ -0,0 +1,98 @@
import preview from "#.storybook/preview";
import { AnimationParams } from "animejs";
import { Component, createSignal } from "solid-js";
import { Anime } from "../../src/ts/components/common/anime/Anime";
const meta = preview.meta({
title: "Common/Anime/Anime",
component: Anime as Component,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
const box = {
width: "100px",
height: "100px",
"background-color": "var(--main-color)",
"border-radius": "8px",
display: "flex",
"align-items": "center",
"justify-content": "center",
color: "var(--bg-color)",
"font-weight": "bold",
};
export const FadeIn = meta.story({
render: () => (
<Anime
initial={{ opacity: 0 } as Partial<AnimationParams>}
animate={{ opacity: 1, duration: 600 } as AnimationParams}
>
<div style={box}>Fade In</div>
</Anime>
),
});
export const SlideIn = meta.story({
render: () => (
<Anime
initial={{ opacity: 0, translateY: -30 } as Partial<AnimationParams>}
animate={{ opacity: 1, translateY: 0, duration: 500 } as AnimationParams}
>
<div style={box}>Slide In</div>
</Anime>
),
});
export const ReactiveAnimation = meta.story({
render: () => {
const [expanded, setExpanded] = createSignal(false);
return (
<div>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
"margin-bottom": "16px",
}}
onClick={() => setExpanded((prev) => !prev)}
>
{expanded() ? "Shrink" : "Expand"}
</button>
<Anime
animation={
{
scale: expanded() ? 1.5 : 1,
rotate: expanded() ? 45 : 0,
duration: 400,
} as AnimationParams
}
>
<div style={box}>Click</div>
</Anime>
</div>
);
},
});
export const CustomElement = meta.story({
render: () => (
<Anime
as="span"
initial={{ opacity: 0, translateX: -20 } as Partial<AnimationParams>}
animate={{ opacity: 1, translateX: 0, duration: 400 } as AnimationParams}
style={{ display: "inline-block" }}
>
<span style={{ color: "var(--text-color)" }}>
Rendered as a {"<span>"}
</span>
</Anime>
),
});

View File

@@ -0,0 +1,79 @@
import preview from "#.storybook/preview";
import { Accessor, Component, createSignal, JSXElement } from "solid-js";
import { AnimeConditional } from "../../src/ts/components/common/anime/AnimeConditional";
type AnimeConditionalProps = {
if: boolean;
then: JSXElement | ((value: Accessor<NonNullable<boolean>>) => JSXElement);
else?: JSXElement;
exitBeforeEnter?: boolean;
};
const meta = preview.meta({
title: "Common/Anime/AnimeConditional",
component: AnimeConditional as Component<AnimeConditionalProps>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
if: { control: "boolean" },
exitBeforeEnter: { control: "boolean" },
},
});
export const Default = meta.story({
args: {
if: true,
exitBeforeEnter: true,
then: (
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Condition is true
</div>
),
else: (
<div style={{ color: "var(--error-color)", padding: "16px" }}>
Condition is false
</div>
),
},
});
export const InteractiveToggle = meta.story({
render: () => {
const [show, setShow] = createSignal(true);
return (
<div>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
"margin-bottom": "16px",
}}
onClick={() => setShow((prev) => !prev)}
>
Toggle ({show() ? "showing true" : "showing false"})
</button>
<AnimeConditional
if={show()}
exitBeforeEnter
then={
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Condition is true
</div>
}
else={
<div style={{ color: "var(--error-color)", padding: "16px" }}>
Condition is false
</div>
}
/>
</div>
);
},
});

View File

@@ -0,0 +1,178 @@
import preview from "#.storybook/preview";
import { AnimationParams } from "animejs";
import { Component, createSignal, For } from "solid-js";
import { AnimeGroup } from "../../src/ts/components/common/anime/AnimeGroup";
const meta = preview.meta({
title: "Common/Anime/AnimeGroup",
component: AnimeGroup as Component,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
const itemStyle = {
padding: "12px 24px",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
"border-radius": "8px",
"margin-bottom": "4px",
};
export const StaggeredFadeIn = meta.story({
render: () => (
<AnimeGroup
initial={{ opacity: 0, translateY: -10 } as Partial<AnimationParams>}
animation={
{ opacity: 1, translateY: 0, duration: 400 } as AnimationParams
}
stagger={80}
>
<div style={itemStyle}>First</div>
<div style={itemStyle}>Second</div>
<div style={itemStyle}>Third</div>
<div style={itemStyle}>Fourth</div>
<div style={itemStyle}>Fifth</div>
</AnimeGroup>
),
});
export const ReverseDirection = meta.story({
render: () => (
<AnimeGroup
initial={{ opacity: 0, translateX: -20 } as Partial<AnimationParams>}
animation={
{ opacity: 1, translateX: 0, duration: 300 } as AnimationParams
}
stagger={100}
direction="reverse"
>
<div style={itemStyle}>Item 1</div>
<div style={itemStyle}>Item 2</div>
<div style={itemStyle}>Item 3</div>
<div style={itemStyle}>Item 4</div>
</AnimeGroup>
),
});
export const CenterDirection = meta.story({
render: () => (
<AnimeGroup
initial={{ opacity: 0, scale: 0.5 } as Partial<AnimationParams>}
animation={{ opacity: 1, scale: 1, duration: 400 } as AnimationParams}
stagger={80}
direction="center"
class="flex gap-2"
>
<div
style={{
...itemStyle,
width: "60px",
"text-align": "center",
"margin-bottom": "0",
}}
>
1
</div>
<div
style={{
...itemStyle,
width: "60px",
"text-align": "center",
"margin-bottom": "0",
}}
>
2
</div>
<div
style={{
...itemStyle,
width: "60px",
"text-align": "center",
"margin-bottom": "0",
}}
>
3
</div>
<div
style={{
...itemStyle,
width: "60px",
"text-align": "center",
"margin-bottom": "0",
}}
>
4
</div>
<div
style={{
...itemStyle,
width: "60px",
"text-align": "center",
"margin-bottom": "0",
}}
>
5
</div>
</AnimeGroup>
),
});
export const WithExitAnimation = meta.story({
render: () => {
const [items, setItems] = createSignal(["A", "B", "C", "D"]);
return (
<div>
<div style={{ display: "flex", gap: "8px", "margin-bottom": "16px" }}>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
}}
onClick={() =>
setItems((prev) => [
...prev,
String.fromCodePoint(65 + prev.length),
])
}
>
Add
</button>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
}}
onClick={() => setItems((prev) => prev.slice(0, -1))}
>
Remove
</button>
</div>
<AnimeGroup
initial={{ opacity: 0, translateX: -15 } as Partial<AnimationParams>}
animation={
{ opacity: 1, translateX: 0, duration: 300 } as AnimationParams
}
exit={
{ opacity: 0, translateX: 15, duration: 200 } as AnimationParams
}
stagger={60}
>
<For each={items()}>
{(item) => <div style={itemStyle}>Item {item}</div>}
</For>
</AnimeGroup>
</div>
);
},
});

View File

@@ -0,0 +1,194 @@
import preview from "#.storybook/preview";
import { AnimationParams } from "animejs";
import { Component, createSignal, For, Show } from "solid-js";
import { Anime } from "../../src/ts/components/common/anime/Anime";
import { AnimePresence } from "../../src/ts/components/common/anime/AnimePresence";
const meta = preview.meta({
title: "Common/Anime/AnimePresence",
component: AnimePresence as Component,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
const box = {
padding: "16px 24px",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
"border-radius": "8px",
"margin-bottom": "8px",
};
export const SingleToggle = meta.story({
render: () => {
const [show, setShow] = createSignal(true);
return (
<div>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
"margin-bottom": "16px",
}}
onClick={() => setShow((prev) => !prev)}
>
Toggle ({show() ? "visible" : "hidden"})
</button>
<AnimePresence>
<Show when={show()}>
<Anime
initial={{ opacity: 0, scale: 0.8 } as Partial<AnimationParams>}
animate={
{ opacity: 1, scale: 1, duration: 300 } as AnimationParams
}
exit={
{ opacity: 0, scale: 0.8, duration: 300 } as AnimationParams
}
>
<div style={box}>Content with enter and exit animations</div>
</Anime>
</Show>
</AnimePresence>
</div>
);
},
});
export const ExitBeforeEnter = meta.story({
render: () => {
const [view, setView] = createSignal<"a" | "b">("a");
const buttonStyle = (active: boolean) => ({
padding: "8px 16px",
cursor: "pointer",
"background-color": active ? "var(--main-color)" : "var(--sub-alt-color)",
color: active ? "var(--bg-color)" : "var(--text-color)",
border: "none",
"border-radius": "8px",
});
return (
<div>
<div style={{ display: "flex", gap: "8px", "margin-bottom": "16px" }}>
<button
style={buttonStyle(view() === "a")}
onClick={() => setView("a")}
>
View A
</button>
<button
style={buttonStyle(view() === "b")}
onClick={() => setView("b")}
>
View B
</button>
</div>
<AnimePresence exitBeforeEnter>
<Show when={view() === "a"}>
<Anime
initial={
{ opacity: 0, translateX: -20 } as Partial<AnimationParams>
}
animate={
{ opacity: 1, translateX: 0, duration: 300 } as AnimationParams
}
exit={
{ opacity: 0, translateX: 20, duration: 300 } as AnimationParams
}
>
<div style={box}>View A - exits before B enters</div>
</Anime>
</Show>
<Show when={view() === "b"}>
<Anime
initial={
{ opacity: 0, translateX: -20 } as Partial<AnimationParams>
}
animate={
{ opacity: 1, translateX: 0, duration: 300 } as AnimationParams
}
exit={
{ opacity: 0, translateX: 20, duration: 300 } as AnimationParams
}
>
<div style={box}>View B - exits before A enters</div>
</Anime>
</Show>
</AnimePresence>
</div>
);
},
});
export const ListMode = meta.story({
render: () => {
const [items, setItems] = createSignal([1, 2, 3]);
let nextId = 4;
return (
<div>
<div style={{ display: "flex", gap: "8px", "margin-bottom": "16px" }}>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
}}
onClick={() => setItems((prev) => [...prev, nextId++])}
>
Add item
</button>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
}}
onClick={() => setItems((prev) => prev.slice(0, -1))}
>
Remove last
</button>
</div>
<AnimePresence mode="list">
<For each={items()}>
{(item) => (
<Anime
initial={
{ opacity: 0, translateX: -10 } as Partial<AnimationParams>
}
animate={
{
opacity: 1,
translateX: 0,
duration: 200,
} as AnimationParams
}
exit={
{
opacity: 0,
translateX: 10,
duration: 200,
} as AnimationParams
}
>
<div style={box}>Item {item}</div>
</Anime>
)}
</For>
</AnimePresence>
</div>
);
},
});

View File

@@ -0,0 +1,93 @@
import preview from "#.storybook/preview";
import { Component, createSignal } from "solid-js";
import { AnimeShow } from "../../src/ts/components/common/anime/AnimeShow";
const meta = preview.meta({
title: "Common/Anime/AnimeShow",
component: AnimeShow as Component<{
when: boolean;
slide?: true;
duration?: number;
class?: string;
}>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
when: { control: "boolean" },
slide: { control: "boolean" },
duration: { control: "number" },
},
});
export const FadeToggle = meta.story({
args: {
when: true,
children: (
<div style={{ color: "var(--text-color)", padding: "16px" }}>
This content fades in and out
</div>
),
},
});
export const SlideToggle = meta.story({
args: {
when: true,
slide: true,
duration: 250,
children: (
<div style={{ color: "var(--text-color)", padding: "16px" }}>
This content slides in and out
</div>
),
},
});
export const InteractiveDemo = meta.story({
render: () => {
const [show, setShow] = createSignal(true);
return (
<div>
<button
style={{
padding: "8px 16px",
cursor: "pointer",
"background-color": "var(--sub-alt-color)",
color: "var(--text-color)",
border: "none",
"border-radius": "8px",
"margin-bottom": "16px",
}}
onClick={() => setShow((prev) => !prev)}
>
Toggle ({show() ? "visible" : "hidden"})
</button>
<div style={{ display: "flex", gap: "24px" }}>
<div>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Fade
</div>
<AnimeShow when={show()}>
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Fade content
</div>
</AnimeShow>
</div>
<div>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Slide
</div>
<AnimeShow when={show()} slide duration={250}>
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Slide content
</div>
</AnimeShow>
</div>
</div>
</div>
);
},
});

View File

@@ -0,0 +1,77 @@
import preview from "#.storybook/preview";
import { Component, createSignal } from "solid-js";
import { AnimeMatch } from "../../src/ts/components/common/anime/AnimeMatch";
import { AnimeSwitch } from "../../src/ts/components/common/anime/AnimeSwitch";
const meta = preview.meta({
title: "Common/Anime/AnimeSwitch",
component: AnimeSwitch as Component,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
export const Default = meta.story({
render: () => {
const [tab, setTab] = createSignal<"a" | "b" | "c">("a");
const buttonStyle = (active: boolean) => ({
padding: "8px 16px",
cursor: "pointer",
"background-color": active ? "var(--main-color)" : "var(--sub-alt-color)",
color: active ? "var(--bg-color)" : "var(--text-color)",
border: "none",
"border-radius": "8px",
});
return (
<div>
<div style={{ display: "flex", gap: "8px", "margin-bottom": "16px" }}>
<button
style={buttonStyle(tab() === "a")}
onClick={() => setTab("a")}
>
Tab A
</button>
<button
style={buttonStyle(tab() === "b")}
onClick={() => setTab("b")}
>
Tab B
</button>
<button
style={buttonStyle(tab() === "c")}
onClick={() => setTab("c")}
>
Tab C
</button>
</div>
<AnimeSwitch
exitBeforeEnter
animeProps={{
initial: { opacity: 0 },
animate: { opacity: 1, duration: 200 },
exit: { opacity: 0, duration: 200 },
}}
>
<AnimeMatch when={tab() === "a"}>
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Content for Tab A
</div>
</AnimeMatch>
<AnimeMatch when={tab() === "b"}>
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Content for Tab B
</div>
</AnimeMatch>
<AnimeMatch when={tab() === "c"}>
<div style={{ color: "var(--text-color)", padding: "16px" }}>
Content for Tab C
</div>
</AnimeMatch>
</AnimeSwitch>
</div>
);
},
});

View File

@@ -0,0 +1,80 @@
import preview from "#.storybook/preview";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/solid-query";
import AsyncContent from "../../src/ts/components/common/AsyncContent";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const meta = preview.meta({
title: "Common/AsyncContent",
// oxlint-disable-next-line typescript/no-unsafe-assignment -- generic component
component: AsyncContent as unknown as () => ReturnType<typeof AsyncContent>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
),
],
});
function LoadingExample(): ReturnType<typeof AsyncContent> {
const query = useQuery(() => ({
queryKey: ["storybook-loading"],
queryFn: async () => new Promise<string>(() => undefined),
}));
return (
<AsyncContent query={query}>{(data) => <div>{data}</div>}</AsyncContent>
);
}
function SuccessExample(): ReturnType<typeof AsyncContent> {
const query = useQuery(() => ({
queryKey: ["storybook-success"],
queryFn: async () => "Hello World",
}));
return (
<AsyncContent query={query}>
{(data) => <div style={{ color: "var(--text-color)" }}>{data}</div>}
</AsyncContent>
);
}
function ErrorExample(): ReturnType<typeof AsyncContent> {
const query = useQuery(() => ({
queryKey: ["storybook-error"],
queryFn: async () => {
throw new globalThis.Error("Failed to fetch");
},
}));
return (
<AsyncContent query={query} errorMessage="Could not load data">
{() => <div>This won't render</div>}
</AsyncContent>
);
}
export const Loading = meta.story({
render: () => <LoadingExample />,
});
export const Success = meta.story({
render: () => <SuccessExample />,
});
export const WithError = meta.story({
render: () => <ErrorExample />,
});

View File

@@ -0,0 +1,91 @@
import preview from "#.storybook/preview";
import { AutoShrink } from "../../src/ts/components/common/AutoShrink";
const meta = preview.meta({
title: "Common/AutoShrink",
component: AutoShrink,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
upperLimitRem: { control: "number" },
class: { control: "text" },
},
decorators: [
(Story) => (
<div style={{ display: "flex", "flex-direction": "column", gap: "24px" }}>
<div>
<div style={{ "font-size": "12px", "margin-bottom": "4px" }}>
400px container
</div>
<div
style={{
width: "400px",
border: "1px dashed gray",
padding: "8px",
resize: "horizontal",
overflow: "auto",
}}
>
<Story />
</div>
</div>
<div>
<div style={{ "font-size": "12px", "margin-bottom": "4px" }}>
200px container
</div>
<div
style={{
width: "200px",
border: "1px dashed gray",
padding: "8px",
resize: "horizontal",
overflow: "auto",
}}
>
<Story />
</div>
</div>
<div>
<div style={{ "font-size": "12px", "margin-bottom": "4px" }}>
100px container
</div>
<div
style={{
width: "100px",
border: "1px dashed gray",
padding: "8px",
resize: "horizontal",
overflow: "auto",
}}
>
<Story />
</div>
</div>
</div>
),
],
});
export const Default = meta.story({
args: {
upperLimitRem: 2,
children: "Short",
},
});
export const LongText = meta.story({
args: {
upperLimitRem: 2,
children: "This is a much longer piece of text that should shrink to fit",
},
});
export const LargeUpperLimit = meta.story({
args: {
upperLimitRem: 4,
children: "Big text",
},
});

View File

@@ -0,0 +1,123 @@
import preview from "#.storybook/preview";
import { Component } from "solid-js";
import { Balloon } from "../../src/ts/components/common/Balloon";
const meta = preview.meta({
title: "Common/Balloon",
component: Balloon as Component,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
text: { control: "text" },
position: {
control: "select",
options: ["up", "down", "left", "right"],
},
break: { control: "boolean" },
length: {
control: "select",
options: ["small", "medium", "large", "xlarge", "fit"],
},
inline: { control: "boolean" },
},
});
export const Default = meta.story(() => (
<Balloon text="Tooltip text">
<span style={{ color: "var(--text-color)", cursor: "pointer" }}>
Hover me
</span>
</Balloon>
));
export const AllPositions = meta.story(() => (
<div
style={{
display: "grid",
"grid-template-columns": "repeat(2, 1fr)",
gap: "48px",
padding: "64px",
color: "var(--text-color)",
}}
>
<Balloon text="Tooltip above" position="up">
<span style={{ cursor: "pointer" }}>Up</span>
</Balloon>
<Balloon text="Tooltip below" position="down">
<span style={{ cursor: "pointer" }}>Down</span>
</Balloon>
<Balloon text="Tooltip left" position="left">
<span style={{ cursor: "pointer" }}>Left</span>
</Balloon>
<Balloon text="Tooltip right" position="right">
<span style={{ cursor: "pointer" }}>Right</span>
</Balloon>
</div>
));
export const Lengths = meta.story(() => (
<div
style={{
display: "flex",
"flex-direction": "column",
gap: "32px",
padding: "64px",
color: "var(--text-color)",
}}
>
<Balloon text="Short" length="small">
<span style={{ cursor: "pointer" }}>Small</span>
</Balloon>
<Balloon text="A medium length tooltip message" length="medium">
<span style={{ cursor: "pointer" }}>Medium</span>
</Balloon>
<Balloon
text="A longer tooltip message that takes up more space"
length="large"
>
<span style={{ cursor: "pointer" }}>Large</span>
</Balloon>
<Balloon
text="An extra large tooltip with a lot of text content inside it"
length="xlarge"
>
<span style={{ cursor: "pointer" }}>XLarge</span>
</Balloon>
<Balloon text="Fits content" length="fit">
<span style={{ cursor: "pointer" }}>Fit</span>
</Balloon>
</div>
));
export const Inline = meta.story(() => (
<p style={{ color: "var(--text-color)" }}>
This is text with a{" "}
<Balloon text="Inline tooltip" inline>
<span
style={{
color: "var(--main-color)",
cursor: "pointer",
"text-decoration": "underline",
}}
>
inline element
</span>
</Balloon>{" "}
inside a sentence.
</p>
));
export const WithBreak = meta.story(() => (
<Balloon
text="This is a very long tooltip message that should break into multiple lines when displayed"
break
length="medium"
>
<span style={{ color: "var(--text-color)", cursor: "pointer" }}>
Hover for multiline tooltip
</span>
</Balloon>
));

View File

@@ -0,0 +1,53 @@
import preview from "#.storybook/preview";
import { Bar } from "../../src/ts/components/common/Bar";
const meta = preview.meta({
title: "Common/Bar",
component: Bar,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
argTypes: {
percent: { control: { type: "range", min: 0, max: 100 } },
fill: { control: "select", options: ["main", "text"] },
bg: { control: "select", options: ["bg", "sub-alt"] },
showPercentageOnHover: { control: "boolean" },
animationDuration: { control: "number" },
},
});
export const Default = meta.story({
args: {
percent: 50,
fill: "main",
bg: "sub-alt",
},
});
export const Full = meta.story({
args: {
percent: 100,
fill: "main",
bg: "sub-alt",
},
});
export const HalfWithHover = meta.story({
args: {
percent: 50,
fill: "main",
bg: "sub-alt",
showPercentageOnHover: true,
},
});
export const TextFill = meta.story({
args: {
percent: 75,
fill: "text",
bg: "sub-alt",
},
});

View File

@@ -0,0 +1,156 @@
import preview from "#.storybook/preview";
import { fn } from "storybook/test";
import { Button } from "../../src/ts/components/common/Button";
const meta = preview.meta({
title: "Common/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
active: {
control: "boolean",
},
disabled: {
control: "boolean",
},
text: {
control: "text",
},
fa: {
control: "object",
},
balloon: {
control: "object",
},
class: {
control: "text",
},
"router-link": {
control: "boolean",
},
href: {
control: "text",
},
sameTarget: {
control: "boolean",
},
},
args: {
onClick: fn(),
},
});
export const Default = meta.story({
args: {
text: "Button",
type: "button",
},
});
export const AllVariants = meta.story({
render: () => (
<div class="grid grid-cols-5 gap-4 text-text">
<div class="self-center">Button</div>
<Button text="Default" onClick={fn()} />
<Button text="Active" active={true} onClick={fn()} />
<Button text="Disabled" disabled={true} onClick={fn()} />
<Button
text="Active + Disabled"
active={true}
disabled={true}
onClick={fn()}
/>
<div class="self-center">Button + Icon</div>
<Button
text="Default"
fa={{ icon: "fa-cog", variant: "solid" }}
onClick={fn()}
/>
<Button
text="Active"
fa={{ icon: "fa-cog", variant: "solid" }}
active={true}
onClick={fn()}
/>
<Button
text="Disabled"
fa={{ icon: "fa-cog", variant: "solid" }}
disabled={true}
onClick={fn()}
/>
<Button
text="Active + Disabled"
fa={{ icon: "fa-cog", variant: "solid" }}
active={true}
disabled={true}
onClick={fn()}
/>
<div class="self-center">Icon Only</div>
<Button fa={{ icon: "fa-cog", variant: "solid" }} onClick={fn()} />
<Button
fa={{ icon: "fa-cog", variant: "solid" }}
active={true}
onClick={fn()}
/>
<Button
fa={{ icon: "fa-cog", variant: "solid" }}
disabled={true}
onClick={fn()}
/>
<Button
fa={{ icon: "fa-cog", variant: "solid" }}
active={true}
disabled={true}
onClick={fn()}
/>
<div class="self-center">Text</div>
<Button variant="text" text="Default" onClick={fn()} />
<Button variant="text" text="Active" active={true} onClick={fn()} />
<Button variant="text" text="Disabled" disabled={true} onClick={fn()} />
<Button
variant="text"
text="Active + Disabled"
active={true}
disabled={true}
onClick={fn()}
/>
<div class="self-center">Text + Icon</div>
<Button
variant="text"
text="Default"
fa={{ icon: "fa-cog", variant: "solid" }}
onClick={fn()}
/>
<Button
variant="text"
text="Active"
fa={{ icon: "fa-cog", variant: "solid" }}
active={true}
onClick={fn()}
/>
<Button
variant="text"
text="Disabled"
fa={{ icon: "fa-cog", variant: "solid" }}
disabled={true}
onClick={fn()}
/>
<Button
variant="text"
text="Active + Disabled"
fa={{ icon: "fa-cog", variant: "solid" }}
active={true}
disabled={true}
onClick={fn()}
/>
</div>
),
});

View File

@@ -0,0 +1,120 @@
import preview from "#.storybook/preview";
import { ChartJs } from "../../src/ts/components/common/ChartJs";
const meta = preview.meta({
title: "Common/ChartJs",
component: ChartJs as any,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
});
export const BarChart = meta.story({
args: {
type: "bar",
data: {
labels: ["10", "20", "30", "40", "50", "60", "70", "80", "90", "100"],
datasets: [
{
label: "Users",
data: [12, 45, 78, 120, 250, 180, 95, 42, 18, 5],
minBarLength: 2,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { display: true, title: { display: true, text: "WPM" } },
y: {
beginAtZero: true,
display: true,
title: { display: true, text: "Users" },
},
},
},
},
decorators: [
(Story) => (
<div style={{ height: "300px", width: "600px" }}>
<Story />
</div>
),
],
});
export const LineChart = meta.story({
args: {
type: "line",
data: {
labels: Array.from({ length: 30 }, (_, i) => `${i + 1}`),
datasets: [
{
label: "WPM",
data: [
65, 68, 72, 70, 75, 78, 80, 82, 79, 85, 88, 90, 87, 92, 95, 93, 98,
100, 97, 102, 105, 103, 108, 110, 107, 112, 115, 113, 118, 120,
],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { display: true, title: { display: true, text: "Test #" } },
y: { display: true, title: { display: true, text: "WPM" } },
},
},
},
decorators: [
(Story) => (
<div style={{ height: "300px", width: "600px" }}>
<Story />
</div>
),
],
});
export const ScatterChart = meta.story({
args: {
type: "scatter",
data: {
datasets: [
{
label: "Results",
data: [
{ x: 90, y: 95 },
{ x: 85, y: 92 },
{ x: 100, y: 98 },
{ x: 75, y: 88 },
{ x: 110, y: 97 },
{ x: 95, y: 94 },
{ x: 80, y: 90 },
{ x: 105, y: 96 },
{ x: 70, y: 85 },
{ x: 115, y: 99 },
],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { display: true, title: { display: true, text: "WPM" } },
y: { display: true, title: { display: true, text: "Accuracy %" } },
},
},
},
decorators: [
(Story) => (
<div style={{ height: "300px", width: "600px" }}>
<Story />
</div>
),
],
});

View File

@@ -0,0 +1,64 @@
import preview from "#.storybook/preview";
import { AnyFieldApi } from "@tanstack/solid-form";
import { Accessor, Component, createSignal } from "solid-js";
import { Checkbox } from "../../src/ts/components/ui/form/Checkbox";
function createFieldMock(options: { name?: string; value?: boolean }) {
const [value, setValue] = createSignal(options.value ?? false);
return {
name: options.name ?? "test",
get state() {
return { value: value() };
},
handleChange(v: boolean) {
setValue(v);
},
handleBlur() {},
} as unknown as AnyFieldApi;
}
const meta = preview.meta({
title: "UI/Form/Checkbox",
component: Checkbox as Component<{
field: Accessor<AnyFieldApi>;
label?: string;
disabled?: boolean;
}>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
label: { control: "text" },
disabled: { control: "boolean" },
},
});
export const Default = meta.story({
render: () => {
const unchecked = createFieldMock({});
const checked = createFieldMock({ value: true });
const checked2xl = createFieldMock({ value: true });
const disabledUnchecked = createFieldMock({});
const disabledChecked = createFieldMock({ value: true });
const withLabel = createFieldMock({ value: true });
return (
<div class="grid grid-cols-[auto_auto] items-center gap-x-4 gap-y-3 text-text">
<div class="text-xs text-sub">Unchecked</div>
<Checkbox field={() => unchecked} />
<div class="text-xs text-sub">Checked</div>
<Checkbox field={() => checked} />
<div class="text-xs text-sub">Checked 2xl</div>
<Checkbox class="text-2xl" field={() => checked2xl} />
<div class="text-xs text-sub">Disabled</div>
<Checkbox field={() => disabledUnchecked} disabled={true} />
<div class="text-xs text-sub">Disabled Checked</div>
<Checkbox field={() => disabledChecked} disabled={true} />
<div class="text-xs text-sub">With Label</div>
<Checkbox field={() => withLabel} label="checkbox" />
</div>
);
},
});

View File

@@ -0,0 +1,52 @@
import preview from "#.storybook/preview";
import { Component, JSXElement } from "solid-js";
import { Conditional } from "../../src/ts/components/common/Conditional";
type ConditionalProps = {
if: boolean;
then: JSXElement;
else?: JSXElement;
};
const meta = preview.meta({
title: "Common/Conditional",
component: Conditional as Component<ConditionalProps>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
if: { control: "boolean" },
},
});
export const Truthy = meta.story({
args: {
if: true,
then: <div style={{ color: "var(--text-color)" }}>Condition is true</div>,
else: <div style={{ color: "var(--error-color)" }}>Condition is false</div>,
},
});
export const Falsy = meta.story({
args: {
if: false,
then: <div style={{ color: "var(--text-color)" }}>Condition is true</div>,
else: <div style={{ color: "var(--error-color)" }}>Condition is false</div>,
},
});
export const NoFallback = meta.story({
args: {
if: true,
then: <div style={{ color: "var(--text-color)" }}>Visible content</div>,
},
});
export const FalsyNoFallback = meta.story({
args: {
if: false,
then: <div style={{ color: "var(--text-color)" }}>Hidden content</div>,
},
});

View File

@@ -0,0 +1,159 @@
import preview from "#.storybook/preview";
import { Component, createSignal } from "solid-js";
import {
DataTable,
DataTableColumnDef,
} from "../../src/ts/components/ui/table/DataTable";
type Person = {
name: string;
age: number;
email: string;
role: string;
};
const sampleData: Person[] = [
{ name: "Alice", age: 28, email: "alice@example.com", role: "Engineer" },
{ name: "Bob", age: 34, email: "bob@example.com", role: "Designer" },
{ name: "Charlie", age: 22, email: "charlie@example.com", role: "Manager" },
{ name: "Diana", age: 31, email: "diana@example.com", role: "Engineer" },
{ name: "Eve", age: 27, email: "eve@example.com", role: "Designer" },
];
const columns: DataTableColumnDef<Person>[] = [
{
accessorKey: "name",
header: "Name",
enableSorting: true,
},
{
accessorKey: "age",
header: "Age",
enableSorting: true,
meta: { align: "right" as const },
},
{
accessorKey: "email",
header: "Email",
enableSorting: false,
},
{
accessorKey: "role",
header: "Role",
enableSorting: true,
},
];
const meta = preview.meta({
title: "UI/DataTable",
component: DataTable as Component,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
});
export const Default = meta.story({
render: () => (
<DataTable id="story-default" columns={columns} data={sampleData} />
),
});
export const WithRowSelection = meta.story({
render: () => {
const [activeRow, setActiveRow] = createSignal<string | null>(null);
return (
<div>
<p style={{ color: "var(--sub-color)", "margin-bottom": "8px" }}>
Click a row to select it. Active: {activeRow() ?? "none"}
</p>
<DataTable
id="story-selection"
columns={columns.map((col) => ({
...col,
meta: {
...col.meta,
cellMeta: () => ({
style: "cursor: pointer",
onClick: () => {
const key = (col as { accessorKey?: string }).accessorKey;
if (key === "name") return;
},
}),
},
}))}
data={sampleData}
rowSelection={{
getRowId: (row) => row.name,
class: "bg-main/10",
activeRow,
}}
/>
<div style={{ display: "flex", gap: "4px", "margin-top": "8px" }}>
{sampleData.map((p) => (
<button
style={{
padding: "4px 8px",
"background-color":
activeRow() === p.name
? "var(--main-color)"
: "var(--sub-alt-color)",
color:
activeRow() === p.name
? "var(--bg-color)"
: "var(--text-color)",
border: "none",
"border-radius": "4px",
cursor: "pointer",
}}
onClick={() =>
setActiveRow((prev) => (prev === p.name ? null : p.name))
}
>
{p.name}
</button>
))}
</div>
</div>
);
},
});
export const Empty = meta.story({
render: () => <DataTable id="story-empty" columns={columns} data={[]} />,
});
export const EmptyWithNoDataRow = meta.story({
render: () => (
<DataTable id="story-nodata" columns={columns} data={[]} noDataRow />
),
});
export const EmptyWithCustomNoData = meta.story({
render: () => (
<DataTable
id="story-custom-nodata"
columns={columns}
data={[]}
noDataRow={{
content: (
<span style={{ color: "var(--error-color)" }}>
No results found. Try a different search.
</span>
),
}}
/>
),
});
export const HiddenHeader = meta.story({
render: () => (
<DataTable
id="story-no-header"
columns={columns}
data={sampleData}
hideHeader
/>
),
});

View File

@@ -0,0 +1,101 @@
import preview from "#.storybook/preview";
import { DiscordAvatar } from "../../src/ts/components/common/DiscordAvatar";
const meta = preview.meta({
title: "Common/DiscordAvatar",
component: DiscordAvatar,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
discordId: { control: "text" },
discordAvatar: { control: "text" },
size: { control: "number" },
class: { control: "text" },
fallbackIcon: {
control: "select",
options: ["user-circle", "user"],
},
},
decorators: [
(Story) => (
<div class="text-2xl">
<Story />
</div>
),
],
});
export const Default = meta.story({
args: {
discordId: "102819690287489024",
discordAvatar: "a_af6c0b8ad26fdd6bcb86ed7bb40ee6e5",
},
});
export const NoAvatar = meta.story({
args: {
discordId: "123456789",
discordAvatar: undefined,
},
});
export const UserFallback = meta.story({
args: {
discordId: undefined,
discordAvatar: undefined,
fallbackIcon: "user",
},
});
export const AllVariants = meta.story({
render: () => (
<div style={{ display: "flex", gap: "32px", "align-items": "center" }}>
<div
style={{
display: "flex",
"flex-direction": "column",
"align-items": "center",
gap: "8px",
}}
>
<DiscordAvatar discordId={undefined} discordAvatar={undefined} />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
user-circle fallback
</div>
</div>
<div
style={{
display: "flex",
"flex-direction": "column",
"align-items": "center",
gap: "8px",
}}
>
<DiscordAvatar
discordId={undefined}
discordAvatar={undefined}
fallbackIcon="user"
/>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
user fallback
</div>
</div>
<div
style={{
display: "flex",
"flex-direction": "column",
"align-items": "center",
gap: "8px",
}}
>
<DiscordAvatar discordId="123456789" discordAvatar="fake" size={64} />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
invalid avatar (fallback)
</div>
</div>
</div>
),
});

View File

@@ -0,0 +1,119 @@
import preview from "#.storybook/preview";
import { Fa } from "../../src/ts/components/common/Fa";
const meta = preview.meta({
title: "Common/Fa",
component: Fa,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
icon: { control: "text" },
variant: {
control: "select",
options: ["solid", "regular", "brand"],
},
fixedWidth: { control: "boolean" },
spin: { control: "boolean" },
size: { control: "number" },
class: { control: "text" },
},
});
export const Default = meta.story({
args: {
icon: "fa-cog",
variant: "solid",
},
});
export const Spinning = meta.story({
args: {
icon: "fa-circle-notch",
variant: "solid",
spin: true,
},
});
export const FixedWidth = meta.story({
args: {
icon: "fa-home",
variant: "solid",
fixedWidth: true,
},
});
export const CustomSize = meta.story({
args: {
icon: "fa-star",
variant: "solid",
size: 3,
},
});
export const Brand = meta.story({
args: {
icon: "fa-discord",
variant: "brand",
size: 2,
},
});
export const AllVariants = meta.story({
render: () => (
<div
style={{
display: "grid",
"grid-template-columns": "auto repeat(3, 1fr)",
gap: "16px",
"align-items": "center",
"font-size": "1.5em",
}}
>
<div style={{ "font-size": "10px", color: "var(--sub-color)" }} />
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Solid
</div>
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Regular
</div>
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Brand
</div>
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Default
</div>
<Fa icon="fa-cog" variant="solid" />
<Fa icon="fa-circle" variant="regular" />
<Fa icon="fa-discord" variant="brand" />
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Fixed Width
</div>
<Fa icon="fa-cog" variant="solid" fixedWidth />
<Fa icon="fa-circle" variant="regular" fixedWidth />
<Fa icon="fa-discord" variant="brand" fixedWidth />
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Spinning
</div>
<Fa icon="fa-circle-notch" variant="solid" spin />
<Fa icon="fa-circle-notch" variant="solid" spin />
<div />
<div style={{ "font-size": "10px", color: "var(--sub-color)" }}>
Sizes
</div>
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
<Fa icon="fa-star" variant="solid" size={1} />
<Fa icon="fa-star" variant="solid" size={2} />
<Fa icon="fa-star" variant="solid" size={3} />
</div>
<div />
<div />
</div>
),
});

View File

@@ -0,0 +1,90 @@
import preview from "#.storybook/preview";
import { AnyFieldApi } from "@tanstack/solid-form";
import { Component } from "solid-js";
import { FieldIndicator } from "../../src/ts/components/ui/form/FieldIndicator";
type MetaState = {
isTouched?: boolean;
isValid?: boolean;
isValidating?: boolean;
errors?: string[];
hasWarning?: boolean;
warnings?: string[];
};
function createFieldMock(meta: MetaState) {
const stateMeta = {
isTouched: true,
isValid: true,
isValidating: false,
errors: [],
...meta,
};
return {
get state() {
return {
meta: stateMeta,
};
},
getMeta() {
return {
hasWarning: meta.hasWarning ?? false,
warnings: meta.warnings ?? [],
};
},
} as unknown as AnyFieldApi;
}
const meta = preview.meta({
title: "UI/Form/FieldIndicator",
component: FieldIndicator as Component<{
field?: AnyFieldApi;
}>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
});
export const Validating = meta.story({
args: {
field: createFieldMock({
isValidating: true,
}),
},
});
export const Warning = meta.story({
args: {
field: createFieldMock({
isValid: true,
hasWarning: true,
warnings: ["are you sure?", "are you really sure?"],
}),
},
});
export const Valid = meta.story({
args: {
field: createFieldMock({
isValid: true,
}),
},
});
export const Error = meta.story({
args: {
field: createFieldMock({
isValid: false,
errors: [
"Failed validation",
"Extra error",
"very very very very very very very very very long error",
],
}),
},
});

View File

@@ -0,0 +1,21 @@
import preview from "#.storybook/preview";
import { Footer } from "../../src/ts/components/layout/footer/Footer";
const meta = preview.meta({
title: "Layout/Footer",
component: Footer,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<div style={{ padding: "16px" }}>
<Story />
</div>
),
],
});
export const Default = meta.story({});

View File

@@ -0,0 +1,108 @@
import preview from "#.storybook/preview";
import { createForm } from "@tanstack/solid-form";
import { createSignal } from "solid-js";
import { z } from "zod";
import { Checkbox } from "../../src/ts/components/ui/form/Checkbox";
import { InputField } from "../../src/ts/components/ui/form/InputField";
import { SubmitButton } from "../../src/ts/components/ui/form/SubmitButton";
import {
fieldMandatory,
fromSchema,
} from "../../src/ts/components/ui/form/utils";
import { showNoticeNotification } from "../../src/ts/states/notifications";
import { sleep } from "../../src/ts/utils/misc";
const meta = preview.meta({
title: "UI/Form",
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
});
export const withValidation = meta.story({
render: () => {
const form = createForm(() => ({
defaultValues: {
username: "",
password: "",
rememberMe: true,
},
onSubmit: async () => {
setEditable(false);
await sleep(1000);
setEditable(true);
},
onSubmitInvalid: () => {
showNoticeNotification("Please fill in all fields");
},
}));
const [isEditable, setEditable] = createSignal(true);
return (
<form
class="grid w-full gap-2"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field
name="username"
validators={{
onChange: fromSchema(z.string().min(3).max(5)),
onChangeAsyncDebounceMs: 250,
onChangeAsync: async ({ value }) => {
await sleep(500);
return value === "kevin" ? undefined : "you must be kevin";
},
}}
children={(field) => (
<InputField
field={field}
showIndicator
autocomplete="current-user"
disabled={!isEditable()}
/>
)}
/>
<form.Field
name="password"
validators={{
onChange: fieldMandatory(),
}}
children={(field) => (
<InputField
field={field}
type="password"
showIndicator
autocomplete="current-password"
disabled={!isEditable()}
/>
)}
/>
<form.Field
name="rememberMe"
children={(field) => (
<Checkbox
field={field}
disabled={!isEditable()}
label="remember me"
/>
)}
/>
<SubmitButton
form={form}
fa={{ icon: "fa-sign-in-alt" }}
text="sign in"
disabled={!isEditable()}
/>
</form>
);
},
});

View File

@@ -0,0 +1,25 @@
import preview from "#.storybook/preview";
import { H2 } from "../../src/ts/components/common/Headers";
const metaH2 = preview.meta({
title: "Common/H2",
component: H2,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
export const Default = metaH2.story({
args: {
text: "Section Header",
},
});
export const WithIcon = metaH2.story({
args: {
text: "Settings",
fa: { icon: "fa-cog", variant: "solid" },
},
});

View File

@@ -0,0 +1,26 @@
import preview from "#.storybook/preview";
import { H3 } from "../../src/ts/components/common/Headers";
const meta = preview.meta({
title: "Common/H3",
component: H3,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
export const Default = meta.story({
args: {
text: "Sub Section",
fa: { icon: "fa-cog", variant: "solid" },
},
});
export const WithDifferentIcon = meta.story({
args: {
text: "Appearance",
fa: { icon: "fa-paint-brush", variant: "solid" },
},
});

View File

@@ -0,0 +1,28 @@
import preview from "#.storybook/preview";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { Header } from "../../src/ts/components/layout/header/Header";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const meta = preview.meta({
title: "Layout/Header",
component: Header,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<div style={{ padding: "16px" }}>
<Story />
</div>
</QueryClientProvider>
),
],
});
export const Default = meta.story({});

View File

@@ -0,0 +1,109 @@
import preview from "#.storybook/preview";
import { AnyFieldApi } from "@tanstack/solid-form";
import { Component, Accessor } from "solid-js";
import { InputField } from "../../src/ts/components/ui/form/InputField";
type MetaState = {
isTouched?: boolean;
isValid?: boolean;
isValidating?: boolean;
errors?: string[];
hasWarning?: boolean;
warnings?: string[];
};
function createFieldMock(options: {
name?: string;
value?: string;
meta?: MetaState;
}) {
const stateMeta = {
isTouched: true,
isValid: true,
isValidating: false,
errors: [],
...(options.meta ?? {}),
};
return {
name: options.name ?? "test",
get state() {
return {
value: options.value ?? "",
meta: stateMeta,
};
},
getMeta() {
return {
hasWarning: options.meta?.hasWarning ?? false,
warnings: options.meta?.warnings ?? [],
};
},
} as unknown as AnyFieldApi;
}
const meta = preview.meta({
title: "UI/Form/InputField",
component: InputField as Component<{
field: Accessor<AnyFieldApi>;
placeholder?: string;
showIndicator?: true;
autocomplete?: string;
type?: string;
disabled?: boolean;
onFocus?: () => void;
}>,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
placeholder: { control: "text" },
showIndicator: { control: "boolean" },
autocomplete: { control: "text" },
type: { control: "text" },
disabled: { control: "boolean" },
},
});
export const Default = meta.story({
args: {
field: () => createFieldMock({}),
},
});
export const withIndicator = meta.story({
args: {
showIndicator: true,
field: () => createFieldMock({}),
},
});
export const withPlaceholder = meta.story({
args: {
placeholder: "placeholder",
field: () => createFieldMock({}),
},
});
export const withAutocomplete = meta.story({
args: {
autocomplete: "autocomplete",
field: () => createFieldMock({}),
},
});
export const withTypePassword = meta.story({
args: {
type: "password",
placeholder: "password",
field: () => createFieldMock({ value: "test" }),
},
});
export const disabled = meta.story({
args: {
disabled: true,
field: () => createFieldMock({ value: "test", meta: { isValid: true } }),
},
});

View File

@@ -0,0 +1,52 @@
import preview from "#.storybook/preview";
import { LoadingCircle } from "../../src/ts/components/common/LoadingCircle";
const meta = preview.meta({
title: "Common/LoadingCircle",
component: LoadingCircle,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
mode: {
control: "select",
options: ["icon", "svg"],
},
class: { control: "text" },
},
});
export const AllVariants = meta.story({
render: () => (
<div style={{ display: "flex", gap: "32px", "align-items": "center" }}>
<div
style={{
display: "flex",
"flex-direction": "column",
"align-items": "center",
gap: "8px",
}}
>
<LoadingCircle mode="icon" />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Icon
</div>
</div>
<div
style={{
display: "flex",
"flex-direction": "column",
"align-items": "center",
gap: "8px",
}}
>
<LoadingCircle mode="svg" class="h-8 w-8" />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
SVG
</div>
</div>
</div>
),
});

View File

@@ -0,0 +1,14 @@
import preview from "#.storybook/preview";
import { Logo } from "../../src/ts/components/layout/header/Logo";
const meta = preview.meta({
title: "Layout/Header/Logo",
component: Logo,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
});
export const Default = meta.story({});

View File

@@ -0,0 +1,28 @@
import preview from "#.storybook/preview";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import { Nav } from "../../src/ts/components/layout/header/Nav";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const meta = preview.meta({
title: "Layout/Header/Nav",
component: Nav,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
decorators: [
(Story) => (
<QueryClientProvider client={queryClient}>
<div style={{ padding: "16px" }}>
<Story />
</div>
</QueryClientProvider>
),
],
});
export const Default = meta.story({});

View File

@@ -0,0 +1,90 @@
import preview from "#.storybook/preview";
import { Component } from "solid-js";
import { NotificationBubble } from "../../src/ts/components/common/NotificationBubble";
const meta = preview.meta({
title: "Common/NotificationBubble",
component: NotificationBubble,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["fromCorner", "atCorner", "center"],
},
show: { control: "boolean" },
},
decorators: [
(Story: Component) => (
<div
style={{
position: "relative",
width: "48px",
height: "48px",
"background-color": "var(--sub-alt-color)",
"border-radius": "8px",
}}
>
<Story />
</div>
),
],
});
export const FromCorner = meta.story({
args: {
variant: "fromCorner",
show: true,
},
});
export const AtCorner = meta.story({
args: {
variant: "atCorner",
show: true,
},
});
export const Center = meta.story({
args: {
variant: "center",
show: true,
},
});
export const AllVariants = meta.story({
decorators: [
() => (
<div style={{ display: "flex", gap: "32px" }}>
{(["fromCorner", "atCorner", "center"] as const).map((variant) => (
<div
style={{
display: "flex",
"flex-direction": "column",
"align-items": "center",
gap: "8px",
}}
>
<div
style={{
position: "relative",
width: "48px",
height: "48px",
"background-color": "var(--sub-alt-color)",
"border-radius": "8px",
}}
>
<NotificationBubble variant={variant} show />
</div>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
{variant}
</div>
</div>
))}
</div>
),
],
});

View File

@@ -0,0 +1,34 @@
import preview from "#.storybook/preview";
import { Separator } from "../../src/ts/components/common/Separator";
const meta = preview.meta({
title: "Common/Separator",
component: Separator,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
vertical: { control: "boolean" },
class: { control: "text" },
},
});
export const Horizontal = meta.story(() => (
<div style={{ width: "300px" }}>
<div style={{ color: "var(--text-color)", "margin-bottom": "8px" }}>
Above
</div>
<Separator />
<div style={{ color: "var(--text-color)", "margin-top": "8px" }}>Below</div>
</div>
));
export const Vertical = meta.story(() => (
<div style={{ display: "flex", gap: "8px", height: "60px" }}>
<span style={{ color: "var(--text-color)" }}>Left</span>
<Separator vertical />
<span style={{ color: "var(--text-color)" }}>Right</span>
</div>
));

View File

@@ -0,0 +1,170 @@
import preview from "#.storybook/preview";
import { Component, createSignal } from "solid-js";
import { fn } from "storybook/test";
import SlimSelect from "../../src/ts/components/ui/SlimSelect";
const meta = preview.meta({
title: "UI/SlimSelect",
component: SlimSelect as Component,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
args: {
onChange: fn(),
},
});
export const Default = meta.story({
render: () => {
const [selected, setSelected] = createSignal<string | undefined>("banana");
return (
<div style={{ width: "200px" }}>
<SlimSelect
options={[
{ value: "apple", text: "Apple" },
{ value: "banana", text: "Banana" },
{ value: "cherry", text: "Cherry" },
{ value: "date", text: "Date" },
]}
selected={selected()}
onChange={(val) => setSelected(val)}
/>
</div>
);
},
});
export const WithValues = meta.story({
render: () => {
const [selected, setSelected] = createSignal<string | undefined>("15");
return (
<div style={{ width: "200px" }}>
<SlimSelect
values={["15", "30", "60", "120"]}
selected={selected()}
onChange={(val) => setSelected(val)}
/>
</div>
);
},
});
export const Multiple = meta.story({
render: () => {
const [selected, setSelected] = createSignal(["red", "blue"]);
return (
<div style={{ width: "300px" }}>
<SlimSelect
multiple
options={[
{ value: "red", text: "Red" },
{ value: "green", text: "Green" },
{ value: "blue", text: "Blue" },
{ value: "yellow", text: "Yellow" },
{ value: "purple", text: "Purple" },
]}
selected={selected()}
onChange={(val) => setSelected(val)}
/>
</div>
);
},
});
export const MultipleWithAll = meta.story({
render: () => {
const [selected, setSelected] = createSignal([
"english",
"spanish",
"french",
]);
return (
<div style={{ width: "300px" }}>
<SlimSelect
multiple
options={[
{ value: "english", text: "English" },
{ value: "spanish", text: "Spanish" },
{ value: "french", text: "French" },
{ value: "german", text: "German" },
]}
selected={selected()}
onChange={(val) => setSelected(val)}
settings={{ addAllOption: true }}
/>
</div>
);
},
});
export const TwoWayBinding = meta.story({
render: () => {
const options = [
{ value: "apple", text: "Apple" },
{ value: "banana", text: "Banana" },
{ value: "cherry", text: "Cherry" },
{ value: "date", text: "Date" },
];
const [selected, setSelected] = createSignal<string | undefined>("banana");
const buttonStyle = (active: boolean) => ({
padding: "4px 12px",
cursor: "pointer",
"background-color": active ? "var(--main-color)" : "var(--sub-alt-color)",
color: active ? "var(--bg-color)" : "var(--text-color)",
border: "none",
"border-radius": "4px",
});
return (
<div style={{ width: "250px" }}>
<SlimSelect
options={options}
selected={selected()}
onChange={(val) => setSelected(val)}
/>
<div
style={{
display: "flex",
gap: "4px",
"margin-top": "12px",
"flex-wrap": "wrap",
}}
>
{options.map((opt) => (
<button
style={buttonStyle(selected() === opt.value)}
onClick={() => setSelected(opt.value)}
>
{opt.text}
</button>
))}
</div>
<p style={{ color: "var(--sub-color)", "margin-top": "8px" }}>
Selected: {selected() ?? "none"}
</p>
</div>
);
},
});
export const Searchable = meta.story({
render: () => {
const [selected, setSelected] = createSignal<string | undefined>(undefined);
return (
<div style={{ width: "250px" }}>
<SlimSelect
options={Array.from({ length: 50 }, (_, i) => ({
value: `option-${i + 1}`,
text: `Option ${i + 1}`,
}))}
selected={selected()}
onChange={(val) => setSelected(val)}
settings={{ placeholderText: "Search options..." }}
/>
</div>
);
},
});

View File

@@ -0,0 +1,78 @@
import preview from "#.storybook/preview";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../src/ts/components/ui/table/Table";
const meta = preview.meta({
title: "UI/Table",
component: Table,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
});
export const Default = meta.story({
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>WPM</TableHead>
<TableHead>Accuracy</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Alice</TableCell>
<TableCell>120</TableCell>
<TableCell>98%</TableCell>
</TableRow>
<TableRow>
<TableCell>Bob</TableCell>
<TableCell>95</TableCell>
<TableCell>96%</TableCell>
</TableRow>
<TableRow>
<TableCell>Charlie</TableCell>
<TableCell>110</TableCell>
<TableCell>97%</TableCell>
</TableRow>
</TableBody>
</Table>
),
});
export const WithCaption = meta.story({
render: () => (
<Table>
<TableCaption>Recent typing test results</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>WPM</TableHead>
<TableHead>Accuracy</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Alice</TableCell>
<TableCell>120</TableCell>
<TableCell>98%</TableCell>
</TableRow>
<TableRow>
<TableCell>Bob</TableCell>
<TableCell>95</TableCell>
<TableCell>96%</TableCell>
</TableRow>
</TableBody>
</Table>
),
});

View File

@@ -0,0 +1,161 @@
import preview from "#.storybook/preview";
import { createSignal, onCleanup } from "solid-js";
import { User } from "../../src/ts/components/common/User";
const meta = preview.meta({
title: "Common/User",
component: User,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
showAvatar: { control: "boolean" },
iconsOnly: { control: "boolean" },
isFriend: { control: "boolean" },
},
});
// oxlint-disable-next-line no-unused-vars
function SpinnerCycleUser(props: {
data: Record<string, unknown>;
}): ReturnType<typeof User> {
const [showSpinner, setShowSpinner] = createSignal(false);
const interval = setInterval(() => {
setShowSpinner((prev) => !prev);
}, 2000);
onCleanup(() => clearInterval(interval));
//@ts-expect-error - just for testing, ignore type issues
return <User user={{ ...props.data }} showSpinner={showSpinner()} />;
}
export const Default = meta.story({
// args: {
// user: {
// uid: "user123",
// name: "monkeytyper",
// discordId: undefined,
// discordAvatar: undefined,
// },
// },
render: () => {
const data = {
uid: "user123",
name: "monkeytyper",
discordId: "102819690287489024",
discordAvatar: "a_af6c0b8ad26fdd6bcb86ed7bb40ee6e5",
isPremium: true,
banned: true,
};
return (
<div class="grid grid-cols-[auto_1fr] place-items-start gap-4 [--themable-button-text:var(--sub-color)]">
<div class="text-sub">With avatar:</div>
<User user={{ ...data }} />
<div class="text-sub">No avatar:</div>
<User user={{ ...data }} showAvatar={false} />
<div class="text-sub">Avatar fallback:</div>
<User user={{ ...data, discordAvatar: "" }} />
<div class="text-sub">Avatar fallback with color:</div>
<User
user={{ ...data, discordAvatar: "" }}
avatarFallback="user-circle"
avatarColor="sub"
/>
<div class="text-sub">Flag color:</div>
<User user={{ ...data }} flagsColor="sub" />
<div class="text-sub">Hide name on small screen:</div>
<User user={{ ...data }} hideNameOnSmallScreens={true} />
<div class="text-sub">Level:</div>
<User user={{ ...data }} level={10} />
<div class="text-sub">Level no flags:</div>
<User
user={{ ...data, isPremium: undefined, banned: undefined }}
level={10}
/>
<div class="text-sub">Show spinner (cycling):</div>
<SpinnerCycleUser data={data} />
<div class="text-sub">Show notification bubble:</div>
<User user={{ ...data }} showNotificationBubble={true} />
</div>
);
},
});
export const WithBadge = meta.story({
args: {
user: {
uid: "user123",
name: "monkeytyper",
discordId: undefined,
discordAvatar: undefined,
badgeId: 1,
},
},
});
export const Premium = meta.story({
args: {
user: {
uid: "user123",
name: "monkeytyper",
discordId: undefined,
discordAvatar: undefined,
badgeId: 6,
isPremium: true,
},
},
});
export const Friend = meta.story({
args: {
user: {
uid: "user123",
name: "monkeytyper",
discordId: undefined,
discordAvatar: undefined,
},
isFriend: true,
},
});
export const Banned = meta.story({
args: {
user: {
uid: "user123",
name: "monkeytyper",
discordId: undefined,
discordAvatar: undefined,
banned: true,
},
},
});
export const NoAvatar = meta.story({
args: {
user: {
uid: "user123",
name: "monkeytyper",
discordId: undefined,
discordAvatar: undefined,
badgeId: 13,
isPremium: true,
},
showAvatar: false,
},
});
export const FullyLoaded = meta.story({
args: {
user: {
uid: "user123",
name: "monkeytyper",
discordId: undefined,
discordAvatar: undefined,
badgeId: 1,
isPremium: true,
},
isFriend: true,
},
});

View File

@@ -0,0 +1,71 @@
import preview from "#.storybook/preview";
import { UserBadge } from "../../src/ts/components/common/UserBadge";
const meta = preview.meta({
title: "Common/UserBadge",
component: UserBadge,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
id: {
control: { type: "select" },
options: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
},
iconOnly: { control: "boolean" },
},
});
export const Developer = meta.story({
args: { id: 1 },
});
export const Collaborator = meta.story({
args: { id: 2 },
});
export const ServerMod = meta.story({
args: { id: 3 },
});
export const OGAccount = meta.story({
args: { id: 4 },
});
export const Supporter = meta.story({
args: { id: 6 },
});
export const SugarDaddy = meta.story({
args: { id: 7 },
});
export const WhiteHat = meta.story({
args: { id: 9 },
});
export const BugHunter = meta.story({
args: { id: 10 },
});
export const Contributor = meta.story({
args: { id: 12 },
});
export const Mythical = meta.story({
args: { id: 13 },
});
export const AllYearLong = meta.story({
args: { id: 14 },
});
export const Perfection = meta.story({
args: { id: 16 },
});
export const IconOnly = meta.story({
args: { id: 1, iconOnly: true },
});

View File

@@ -0,0 +1,109 @@
import preview from "#.storybook/preview";
import { UserFlags } from "../../src/ts/components/common/UserFlags";
const meta = preview.meta({
title: "Common/UserFlags",
component: UserFlags,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
isPremium: { control: "boolean" },
banned: { control: "boolean" },
lbOptOut: { control: "boolean" },
isFriend: { control: "boolean" },
iconsOnly: { control: "boolean" },
},
});
export const PrimeApe = meta.story({
args: {
isPremium: true,
},
});
export const Banned = meta.story({
args: {
banned: true,
},
});
export const LbOptOut = meta.story({
args: {
lbOptOut: true,
},
});
export const Friend = meta.story({
args: {
isFriend: true,
},
});
export const AllFlags = meta.story({
args: {
isPremium: true,
banned: true,
lbOptOut: true,
isFriend: true,
},
});
export const AllFlagsIconsOnly = meta.story({
args: {
isPremium: true,
banned: true,
lbOptOut: true,
isFriend: true,
iconsOnly: true,
},
});
export const AllVariants = meta.story({
render: () => (
<div
style={{
display: "grid",
"grid-template-columns": "auto 1fr 1fr",
gap: "12px",
"align-items": "center",
}}
>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }} />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>Full</div>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Icon Only
</div>
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Premium
</div>
<UserFlags isPremium />
<UserFlags isPremium iconsOnly />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Banned
</div>
<UserFlags banned />
<UserFlags banned iconsOnly />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
LB Opt Out
</div>
<UserFlags lbOptOut />
<UserFlags lbOptOut iconsOnly />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>
Friend
</div>
<UserFlags isFriend />
<UserFlags isFriend iconsOnly />
<div style={{ "font-size": "12px", color: "var(--sub-color)" }}>All</div>
<UserFlags isPremium banned lbOptOut isFriend />
<UserFlags isPremium banned lbOptOut isFriend iconsOnly />
</div>
),
});

View File

@@ -0,0 +1,240 @@
import preview from "#.storybook/preview";
import { UserProfile as UserProfileType } from "@monkeytype/schemas/users";
import { UserProfile } from "../../src/ts/components/pages/profile/UserProfile";
const baseProfile: UserProfileType = {
uid: "user123",
name: "monkeytyper",
addedAt: 1700000000000,
xp: 42000,
streak: 15,
maxStreak: 30,
isPremium: false,
banned: false,
lbOptOut: false,
typingStats: {
completedTests: 1234,
startedTests: 1500,
timeTyping: 360000,
},
personalBests: {
time: {
"15": [
{
acc: 97.5,
consistency: 82.3,
difficulty: "normal",
language: "english",
raw: 145,
wpm: 138,
timestamp: 1700000000000,
},
],
"30": [
{
acc: 96.2,
consistency: 80.1,
difficulty: "normal",
language: "english",
raw: 140,
wpm: 132,
timestamp: 1699000000000,
},
],
"60": [
{
acc: 95.8,
consistency: 78.5,
difficulty: "normal",
language: "english",
raw: 135,
wpm: 125,
timestamp: 1698000000000,
},
],
"120": [],
},
words: {
"10": [
{
acc: 100,
consistency: 90.0,
difficulty: "normal",
language: "english",
raw: 160,
wpm: 155,
timestamp: 1700000000000,
},
],
"25": [
{
acc: 98.0,
consistency: 85.0,
difficulty: "normal",
language: "english",
raw: 150,
wpm: 142,
timestamp: 1699000000000,
},
],
"50": [
{
acc: 96.5,
consistency: 81.0,
difficulty: "normal",
language: "english",
raw: 142,
wpm: 130,
timestamp: 1698000000000,
},
],
"100": [
{
acc: 95.0,
consistency: 79.0,
difficulty: "normal",
language: "english",
raw: 138,
wpm: 122,
timestamp: 1697000000000,
},
],
},
},
details: {
bio: "Just a monkey typing away",
keyboard: "Custom 65%",
socialProfiles: {
twitter: "monkeytyper",
github: "monkeytyper",
website: "https://example.com",
},
},
// this is styled using global styles so it wont show up correctly in storybook
// testActivity: {
// testsByDays: [
// null,
// 2,
// 5,
// null,
// 3,
// 8,
// 12,
// null,
// null,
// 1,
// 4,
// 6,
// null,
// 7,
// 3,
// null,
// null,
// 5,
// 9,
// 2,
// null,
// null,
// 4,
// 6,
// 11,
// 3,
// null,
// 8,
// 2,
// 5,
// ],
// lastDay: 1700000000000,
// },
inventory: {
badges: [{ id: 1, selected: true }],
},
};
const meta = preview.meta({
title: "Pages/UserProfile",
component: UserProfile,
parameters: {
layout: "padded",
},
tags: ["autodocs"],
});
export const Default = meta.story({
args: {
profile: baseProfile,
},
});
export const AccountPage = meta.story({
args: {
profile: baseProfile,
isAccountPage: true,
},
});
export const WithLeaderboard = meta.story({
args: {
profile: {
...baseProfile,
allTimeLbs: {
time: {
"15": {
english: {
rank: 42,
count: 50000,
},
},
"60": {
english: {
rank: 156,
count: 50000,
},
},
},
},
},
},
});
export const Banned = meta.story({
args: {
profile: {
...baseProfile,
banned: true,
details: undefined,
inventory: undefined,
},
},
});
export const LbOptOut = meta.story({
args: {
profile: {
...baseProfile,
lbOptOut: true,
},
},
});
export const NoPbs = meta.story({
args: {
profile: {
...baseProfile,
personalBests: {
time: {},
words: {},
},
},
},
});
export const Premium = meta.story({
args: {
profile: {
...baseProfile,
isPremium: true,
},
},
});

View File

@@ -0,0 +1,32 @@
/* Fallback theme variables for Storybook (serika_dark) */
:root {
--bg-color: #323437;
--main-color: #e2b714;
--caret-color: #e2b714;
--sub-color: #646669;
--sub-alt-color: #2c2e31;
--text-color: #d1d0c5;
--error-color: #ca4754;
--error-extra-color: #7e2a33;
--colorful-error-color: #ca4754;
--colorful-error-extra-color: #7e2a33;
--roundness: 0.5rem;
}
body {
background: var(--bg-color);
color: var(--text-color);
font-family: "Roboto Mono", monospace;
}
.docs-story {
background: var(--bg-color) !important;
}
.devIndicator {
display: none;
}
.docs-story .docblock-code-toggle {
display: none;
}

View File

@@ -0,0 +1,2 @@
@import "../../src/styles/tailwind.css";
@source "../../src/ts/components/**/*.tsx";

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"paths": {
"#.storybook/*": ["./.storybook/*"]
},
"strictNullChecks": true
},
"include": [
".storybook/**/*.ts",
".storybook/**/*.tsx",
"stories/**/*.ts",
"stories/**/*.tsx",
"stories/types/*.d.ts"
]
}

View File

@@ -0,0 +1,48 @@
/// <reference types="vitest/config" />
import path from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import { playwright } from "@vitest/browser-playwright";
const dirname =
typeof __dirname !== "undefined"
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
// https://vite.dev/config/
export default defineConfig({
plugins: [],
define: {
"process.env": {},
},
test: {
projects: [
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, ".storybook"),
}),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
// oxlint-disable-next-line typescript/ban-ts-comment
//@ts-ignore
provider: playwright(),
instances: [
{
browser: "chromium",
},
],
},
},
},
],
},
});