├── .husky
└── pre-commit
├── src
├── components
│ ├── icon
│ │ ├── index.tsx
│ │ └── icon.tsx
│ ├── badge
│ │ ├── index.tsx
│ │ ├── badge.stories.tsx
│ │ └── badge.tsx
│ ├── button
│ │ ├── index.tsx
│ │ ├── button.tsx
│ │ └── button.module.css
│ ├── callout
│ │ ├── index.tsx
│ │ └── callout.tsx
│ ├── input
│ │ ├── index.tsx
│ │ ├── input.stories.tsx
│ │ └── input.tsx
│ ├── toggle
│ │ ├── index.tsx
│ │ ├── toggle.stories.tsx
│ │ └── toggle.tsx
│ ├── checkbox
│ │ ├── index.tsx
│ │ └── checkbox.tsx
│ ├── text-area
│ │ ├── index.tsx
│ │ ├── get-length.ts
│ │ ├── text-area.stories.tsx
│ │ └── text-area.tsx
│ └── task-list
│ │ ├── index.tsx
│ │ ├── task-list.stories.tsx
│ │ ├── task-list.tsx
│ │ └── task-list-context.tsx
├── vite-env.d.ts
├── index.css
└── tokens
│ └── colors.ts
├── postcss.config.js
├── vite.config.ts
├── .prettierrc
├── tailwind.config.ts
├── tsconfig.node.json
├── .storybook
├── preview.ts
└── main.ts
├── public
├── favicon.svg
└── mockServiceWorker.js
├── .gitignore
├── README.md
├── index.html
├── .eslintrc.cjs
├── tsconfig.json
└── package.json
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | pnpm exec lint-staged
2 |
--------------------------------------------------------------------------------
/src/components/icon/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './icon';
2 |
--------------------------------------------------------------------------------
/src/components/badge/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './badge';
2 |
--------------------------------------------------------------------------------
/src/components/button/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './button';
2 |
--------------------------------------------------------------------------------
/src/components/callout/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './callout';
2 |
--------------------------------------------------------------------------------
/src/components/input/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './input';
2 |
--------------------------------------------------------------------------------
/src/components/toggle/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './toggle';
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/components/checkbox/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './checkbox';
2 |
--------------------------------------------------------------------------------
/src/components/text-area/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './text-area';
2 |
--------------------------------------------------------------------------------
/src/components/task-list/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './task-list';
2 | export * from './task-list-context';
3 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/src/components/button/button.tsx:
--------------------------------------------------------------------------------
1 | export const Button = (props: any) => {
2 | return ;
3 | };
4 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | @apply dark:bg-slate-950 dark:text-white;
7 | }
8 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | });
7 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "semi": true,
6 | "printWidth": 100,
7 | "plugins": ["prettier-plugin-tailwindcss"]
8 | }
9 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 |
3 | export default {
4 | content: ['./src/**/*.tsx', './src/**/*.ts', './src/**/*.mdx'],
5 | plugins: [],
6 | } satisfies Config;
7 |
--------------------------------------------------------------------------------
/src/components/callout/callout.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | export const Callout = () => {
5 | return
Callout
;
6 | };
7 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react';
2 |
3 | const preview: Preview = {
4 | parameters: {
5 | controls: {
6 | matchers: {
7 | color: /(background|color)$/i,
8 | date: /Date$/i,
9 | },
10 | },
11 | },
12 | };
13 |
14 | export default preview;
15 |
--------------------------------------------------------------------------------
/src/components/task-list/task-list.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { TaskList } from './task-list';
3 |
4 | const meta = {
5 | title: 'Components/TaskList',
6 | component: TaskList,
7 | } as Meta;
8 |
9 | export default meta;
10 | type Story = StoryObj;
11 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/components/toggle/toggle.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Toggle } from './toggle';
3 |
4 | const meta = {
5 | title: 'Components/Toggle',
6 | component: Toggle,
7 | args: {
8 | label: 'Toggle',
9 | },
10 | } satisfies Meta;
11 |
12 | export default meta;
13 | type Story = StoryObj;
14 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Anthology
2 |
3 | An example repository for Steve's [Building Design Systems with Storybook](https://stevekinney.net/courses/storybook) course for [Frontend Masters](https://frontendmasters.com).
4 |
5 | The Figma designs that inspire the components in this repository can be found [here](https://www.figma.com/file/Qhb4PJucNK8bgvf4N65Jrm/Anthology?type=design&node-id=0%3A1&mode=design&t=Dr1OUnsNFnelFSUN-1).
6 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Anthology
8 |
9 |
10 |
11 |
12 | Please start this project by running npm run storybook from the command line.
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/text-area/get-length.ts:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react';
2 |
3 | export const getLength = (value: ComponentProps<'textarea'>['value']): number => {
4 | if (typeof value !== 'string') return 0;
5 | return value.length;
6 | };
7 |
8 | export const isTooLong = (
9 | value: ComponentProps<'textarea'>['value'],
10 | maxLength: ComponentProps<'textarea'>['maxLength'],
11 | ): boolean => {
12 | if (typeof value !== 'string') return false;
13 | if (!maxLength) return false;
14 | return value.length > maxLength;
15 | };
16 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | 'plugin:storybook/recommended',
9 | 'prettier',
10 | ],
11 | ignorePatterns: ['dist', '.eslintrc.cjs'],
12 | parser: '@typescript-eslint/parser',
13 | plugins: ['react-refresh'],
14 | rules: {
15 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/react-vite';
2 |
3 | const config: StorybookConfig = {
4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
5 | addons: [
6 | '@storybook/addon-onboarding',
7 | '@storybook/addon-links',
8 | '@storybook/addon-essentials',
9 | '@chromatic-com/storybook',
10 | '@storybook/addon-interactions',
11 | '@storybook/addon-themes',
12 | '@storybook/addon-a11y',
13 | ],
14 | framework: {
15 | name: '@storybook/react-vite',
16 | options: {},
17 | },
18 | docs: {
19 | autodocs: 'tag',
20 | },
21 | core: {
22 | disableTelemetry: true, // 👈 Used to ignore update notifications.
23 | },
24 | };
25 | export default config;
26 |
--------------------------------------------------------------------------------
/src/components/badge/badge.stories.tsx:
--------------------------------------------------------------------------------
1 | import { Meta, StoryObj } from '@storybook/react';
2 | import { Badge } from './badge';
3 |
4 | const meta = {
5 | title: 'Components/Badge',
6 | component: Badge,
7 | args: {
8 | children: 'Badge',
9 | variant: 'default',
10 | },
11 | argTypes: {
12 | children: {
13 | name: 'Label',
14 | control: 'text',
15 | description: 'Text to display on the badge',
16 | table: {
17 | disable: true,
18 | },
19 | },
20 | variant: {
21 | name: 'Variant',
22 | description: 'Variant of the badge',
23 | control: 'select',
24 | options: ['default', 'primary', 'information', 'success', 'warning', 'danger'],
25 | table: {
26 | defaultValue: {
27 | summary: 'default',
28 | },
29 | },
30 | },
31 | },
32 | } as Meta;
33 |
34 | export default meta;
35 | type Story = StoryObj;
36 |
--------------------------------------------------------------------------------
/src/components/toggle/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { useState, type ComponentProps } from 'react';
2 | import clsx from 'clsx';
3 |
4 | type ToggleProps = ComponentProps<'input'> & {
5 | label: string;
6 | };
7 |
8 | export const Toggle = ({ label, ...props }: ToggleProps) => {
9 | return (
10 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/checkbox/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentProps } from 'react';
2 | import clsx from 'clsx';
3 |
4 | export type CheckboxProps = Omit, 'type'> & {
5 | label: string;
6 | };
7 |
8 | export const Checkbox = ({ label, className, ...props }: CheckboxProps) => {
9 | return (
10 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/src/components/input/input.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { Input } from './input';
3 |
4 | const meta = {
5 | title: 'Components/Input',
6 | component: Input,
7 | args: {
8 | label: 'Input',
9 | placeholder: 'Enter text',
10 | disabled: false,
11 | required: false,
12 | },
13 | argTypes: {
14 | label: {
15 | name: 'Label',
16 | control: 'text',
17 | description: 'Label of the input',
18 | },
19 | placeholder: {
20 | name: 'Placeholder',
21 | control: 'text',
22 | description: 'Placeholder text of the input',
23 | },
24 | disabled: {
25 | name: 'Disabled',
26 | control: 'boolean',
27 | description: 'Disables the input',
28 | table: {
29 | defaultValue: {
30 | summary: false,
31 | },
32 | },
33 | },
34 | required: {
35 | name: 'Required',
36 | control: 'boolean',
37 | description: 'Marks the input as required',
38 | table: {
39 | defaultValue: {
40 | summary: false,
41 | },
42 | },
43 | },
44 | },
45 | } as Meta;
46 |
47 | export default meta;
48 | type Story = StoryObj;
49 |
--------------------------------------------------------------------------------
/src/components/text-area/text-area.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import { TextArea } from './text-area';
3 |
4 | import { userEvent, within, expect } from '@storybook/test';
5 |
6 | const meta = {
7 | title: 'Components/TextArea',
8 | component: TextArea,
9 | args: {
10 | label: 'Text Area Label',
11 | placeholder: 'Enter some text here…',
12 | disabled: false,
13 | required: false,
14 | },
15 | argTypes: {
16 | label: {
17 | name: 'Label',
18 | control: 'text',
19 | description: 'Label of the text area',
20 | },
21 | placeholder: {
22 | name: 'Placeholder',
23 | control: 'text',
24 | description: 'Placeholder text of the text area',
25 | },
26 | disabled: {
27 | name: 'Disabled',
28 | control: 'boolean',
29 | description: 'Disables the text area',
30 | table: {
31 | defaultValue: {
32 | summary: false,
33 | },
34 | },
35 | },
36 | required: {
37 | name: 'Required',
38 | control: 'boolean',
39 | description: 'Marks the text area as required',
40 | table: {
41 | defaultValue: {
42 | summary: false,
43 | },
44 | },
45 | },
46 | },
47 | } as Meta;
48 |
49 | export default meta;
50 | type Story = StoryObj;
51 |
--------------------------------------------------------------------------------
/src/components/input/input.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps } from 'react';
2 | import clsx from 'clsx';
3 |
4 | type InputProps = ComponentProps<'input'> & {
5 | label: string;
6 | details?: string;
7 | required?: boolean;
8 | unlabeled?: boolean;
9 | disabled?: boolean;
10 | };
11 |
12 | export const Input = ({
13 | label,
14 | value,
15 | details,
16 | placeholder,
17 | required = false,
18 | unlabeled = false,
19 | disabled = false,
20 | ...props
21 | }: InputProps) => {
22 | return (
23 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/components/icon/icon.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type LucideIcon,
3 | AlertTriangle,
4 | Bug,
5 | Check,
6 | CheckCircle2,
7 | ChevronDown,
8 | Clipboard,
9 | ExternalLink,
10 | Flame,
11 | Hash,
12 | Heart,
13 | HelpCircle,
14 | Info,
15 | Link,
16 | List,
17 | Pencil,
18 | Plus,
19 | Quote,
20 | Skull,
21 | Star,
22 | X,
23 | Zap,
24 | } from 'lucide-react';
25 | import { ComponentProps } from 'react';
26 |
27 | export type IconProps = ComponentProps & {
28 | type:
29 | | 'bug'
30 | | 'check'
31 | | 'chevron'
32 | | 'clipboard'
33 | | 'external'
34 | | 'flame'
35 | | 'hash'
36 | | 'heart'
37 | | 'help'
38 | | 'info'
39 | | 'link'
40 | | 'list'
41 | | 'pencil'
42 | | 'plus'
43 | | 'quote'
44 | | 'skull'
45 | | 'star'
46 | | 'success'
47 | | 'warning'
48 | | 'x'
49 | | 'zap';
50 | };
51 |
52 | const iconComponents: Record = {
53 | bug: Bug,
54 | check: Check,
55 | chevron: ChevronDown,
56 | clipboard: Clipboard,
57 | external: ExternalLink,
58 | flame: Flame,
59 | hash: Hash,
60 | heart: Heart,
61 | help: HelpCircle,
62 | info: Info,
63 | link: Link,
64 | list: List,
65 | pencil: Pencil,
66 | plus: Plus,
67 | quote: Quote,
68 | skull: Skull,
69 | star: Star,
70 | success: CheckCircle2,
71 | warning: AlertTriangle,
72 | x: X,
73 | zap: Zap,
74 | };
75 |
76 | export const Icon = ({ type, ...props }: IconProps) => {
77 | const IconComponent = iconComponents[type];
78 | return ;
79 | };
80 |
81 | export const icons = Object.keys(iconComponents) as IconProps['type'][];
82 |
--------------------------------------------------------------------------------
/src/components/button/button.module.css:
--------------------------------------------------------------------------------
1 | .button {
2 | @apply bg-primary-600 inline-flex cursor-pointer items-center gap-1.5 rounded border border-transparent px-2.5 py-1.5 text-white shadow-sm transition-colors;
3 | }
4 |
5 | /* Focus visible styles */
6 | .button:focus-visible {
7 | outline: 2px solid;
8 | outline-offset: 2px;
9 | }
10 |
11 | /* Disabled styles */
12 | .button:disabled {
13 | opacity: 0.5;
14 | cursor: not-allowed;
15 | }
16 |
17 | .button:hover {
18 | background-color: #9967d5;
19 | }
20 |
21 | .button:active {
22 | background-color: #bc9be5;
23 | }
24 |
25 | /* Variant: secondary */
26 | .secondary {
27 | background-color: white;
28 | color: #343a46;
29 | border-color: #b1bbc8;
30 | }
31 |
32 | .secondary:hover {
33 | background-color: #f6f7f9;
34 | }
35 |
36 | .secondary:active {
37 | background-color: #eceef2;
38 | }
39 |
40 | /* Variant: destructive */
41 | .destructive {
42 | background-color: #d9253e;
43 | color: white;
44 | border-color: transparent;
45 | }
46 |
47 | .destructive:hover {
48 | background-color: #ed4656;
49 | }
50 |
51 | .destructive:active {
52 | background-color: #f6767f;
53 | }
54 |
55 | /* Variant: ghost */
56 | .ghost {
57 | background-color: transparent;
58 | color: #343a46;
59 | border-color: transparent;
60 | box-shadow: none;
61 | }
62 |
63 | .ghost:hover {
64 | background-color: #f6f7f9;
65 | }
66 |
67 | .ghost:active {
68 | background-color: #eceef2;
69 | }
70 |
71 | /* Sizes */
72 | .small {
73 | font-size: 0.875rem;
74 | padding: 0.25rem 0.5rem;
75 | }
76 |
77 | .medium {
78 | font-size: 0.875rem;
79 | padding: 0.375rem 0.625rem;
80 | }
81 |
82 | .large {
83 | font-size: 0.875rem;
84 | padding: 0.5rem 0.75rem;
85 | }
86 |
87 | /* Icon positions */
88 | .icon-left {
89 | flex-direction: row;
90 | }
91 |
92 | .icon-right {
93 | flex-direction: row-reverse;
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/text-area/text-area.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import { useMemo, useState, type ComponentProps } from 'react';
3 | import { isTooLong, getLength } from './get-length';
4 |
5 | type TextAreaProps = ComponentProps<'textarea'> & { label: string };
6 |
7 | export const TextArea = ({ label, required, maxLength, ...props }: TextAreaProps) => {
8 | const [value, setValue] = useState(props.value ?? '');
9 | const tooLong = useMemo(() => isTooLong(value, maxLength), [value, maxLength]);
10 | const length = useMemo(() => getLength(value), [value]);
11 |
12 | console.log({ label, required, maxLength, value, ...props });
13 |
14 | return (
15 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/src/components/badge/badge.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 |
4 | const variants = cva(
5 | [
6 | 'inline-flex',
7 | 'items-center',
8 | 'gap-1',
9 | 'rounded-md',
10 | 'border',
11 | 'border-opacity-50',
12 | 'px-2',
13 | 'py-1',
14 | 'font-medium',
15 | 'text-xs',
16 | ],
17 | {
18 | variants: {
19 | variant: {
20 | default: [
21 | 'bg-slate-50',
22 | 'text-slate-600',
23 | 'border-slate-400',
24 | 'dark:bg-slate-800',
25 | 'dark:border-slate-700',
26 | 'dark:text-slate-300',
27 | ],
28 | primary: [
29 | 'bg-primary-50',
30 | 'text-primary-600',
31 | 'border-primary-400',
32 | 'dark:bg-primary-800',
33 | 'dark:border-primary-700',
34 | 'dark:text-primary-300',
35 | ],
36 | success: [
37 | 'bg-success-50',
38 | 'text-success-700',
39 | 'border-success-600',
40 | 'dark:bg-success-800',
41 | 'dark:text-success-300',
42 | ],
43 | danger: [
44 | 'bg-danger-50',
45 | 'text-danger-700',
46 | 'border-danger-400',
47 | 'dark:bg-danger-800',
48 | 'dark:text-danger-300',
49 | ],
50 | warning: [
51 | 'bg-warning-50',
52 | 'text-warning-800',
53 | 'border-warning-600',
54 | 'dark:bg-warning-800',
55 | 'dark:text-warning-300',
56 | ],
57 | information: [
58 | 'bg-information-50',
59 | 'text-information-600',
60 | 'border-information-400',
61 | 'dark:bg-information-800',
62 | 'dark:text-information-300',
63 | ],
64 | },
65 | },
66 | defaultVariants: {
67 | variant: 'default',
68 | },
69 | },
70 | );
71 |
72 | type BadgeProps = PropsWithChildren>;
73 |
74 | export const Badge = ({ variant, ...props }: BadgeProps) => {
75 | return ;
76 | };
77 |
--------------------------------------------------------------------------------
/src/components/task-list/task-list.tsx:
--------------------------------------------------------------------------------
1 | import { useContext, useState } from 'react';
2 | import { Button } from '../button';
3 | import { Input } from '../input';
4 | import { Checkbox } from '../checkbox';
5 | import { TaskListContext } from './task-list-context';
6 | import { Icon } from '../icon';
7 |
8 | export const TaskList = () => {
9 | const [newTask, setNewTask] = useState('');
10 | const { tasks, addTask, toggleTask, removeTask, total, incomplete } = useContext(TaskListContext);
11 |
12 | return (
13 |
14 |
34 |
35 |
36 | {tasks.map((task) => (
37 |
38 | toggleTask(task.id)}
43 | className="w-full rounded-md px-2 py-2 hover:bg-slate-100 dark:hover:bg-slate-700"
44 | />
45 |
48 |
49 | ))}
50 |
51 |
52 |
53 | {incomplete}/{total}
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "anthology",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "npm storybook",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "format": "prettier --write --ignore-unknown .",
11 | "preview": "vite preview",
12 | "storybook": "storybook dev -p 6006",
13 | "build-storybook": "storybook build",
14 | "prepare": "husky"
15 | },
16 | "lint-staged": {
17 | "**/*": "prettier --write --ignore-unknown"
18 | },
19 | "msw": {
20 | "workerDirectory": [
21 | "public"
22 | ]
23 | },
24 | "dependencies": {
25 | "@fontsource-variable/inter": "5.0.18",
26 | "lucide-react": "0.368.0",
27 | "react": "18.3.1",
28 | "react-dom": "18.3.1"
29 | },
30 | "devDependencies": {
31 | "@chromatic-com/storybook": "1.5.0",
32 | "@storybook/addon-a11y": "8.1.6",
33 | "@storybook/addon-essentials": "8.1.6",
34 | "@storybook/addon-interactions": "8.1.6",
35 | "@storybook/addon-links": "8.1.6",
36 | "@storybook/addon-onboarding": "8.1.6",
37 | "@storybook/addon-themes": "8.1.6",
38 | "@storybook/blocks": "8.1.6",
39 | "@storybook/react": "8.1.6",
40 | "@storybook/react-vite": "8.1.6",
41 | "@storybook/test": "8.1.6",
42 | "@storybook/test-runner": "0.17.0",
43 | "@types/react": "18.3.3",
44 | "@types/react-dom": "18.3.0",
45 | "@typescript-eslint/eslint-plugin": "7.12.0",
46 | "@typescript-eslint/parser": "7.12.0",
47 | "@vitejs/plugin-react": "4.3.0",
48 | "autoprefixer": "10.4.19",
49 | "axe-playwright": "2.0.1",
50 | "class-variance-authority": "0.7.0",
51 | "clsx": "2.1.1",
52 | "eslint": "8.57.0",
53 | "eslint-config-prettier": "8.10.0",
54 | "eslint-plugin-react-hooks": "4.6.2",
55 | "eslint-plugin-react-refresh": "0.4.7",
56 | "eslint-plugin-storybook": "0.8.0",
57 | "husky": "9.0.11",
58 | "lint-staged": "15.2.5",
59 | "msw": "2.3.1",
60 | "msw-storybook-addon": "2.0.2",
61 | "postcss": "8.4.38",
62 | "prettier": "3.3.1",
63 | "prettier-plugin-tailwindcss": "0.5.14",
64 | "storybook": "8.1.6",
65 | "tailwindcss": "3.4.4",
66 | "typescript": "5.4.5",
67 | "vite": "5.2.12",
68 | "vitest": "1.6.0"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/components/task-list/task-list-context.tsx:
--------------------------------------------------------------------------------
1 | import { PropsWithChildren, createContext, useMemo, useReducer } from 'react';
2 |
3 | let id = 0;
4 |
5 | type Task = {
6 | id: string;
7 | title: string;
8 | completed: boolean;
9 | };
10 |
11 | type TaskListContextType = {
12 | tasks: Task[];
13 | incomplete: number;
14 | total: number;
15 | addTask: (title: string) => void;
16 | removeTask: (id: string) => void;
17 | toggleTask: (id: string) => void;
18 | };
19 |
20 | export const createTask = (title: string): Task => ({
21 | id: String(id++),
22 | title,
23 | completed: false,
24 | });
25 |
26 | const updateTask = (tasks: Task[], id: string, update: Partial): Task[] =>
27 | tasks.map((task) => (task.id === id ? { ...task, ...update } : task));
28 |
29 | const removeTask = (tasks: Task[], id: string): Task[] => tasks.filter((task) => task.id !== id);
30 |
31 | const addTask = (tasks: Task[], title: string): Task[] => [...tasks, createTask(title)];
32 |
33 | const toggleTask = (tasks: Task[], id: string): Task[] =>
34 | updateTask(tasks, id, { completed: !tasks.find((task) => task.id === id)?.completed });
35 |
36 | export const taskListReducer = (tasks: Task[], action: { type: string; payload: any }): Task[] => {
37 | switch (action.type) {
38 | case 'addTask':
39 | return addTask(tasks, action.payload);
40 | case 'removeTask':
41 | return removeTask(tasks, action.payload);
42 | case 'toggleTask':
43 | return toggleTask(tasks, action.payload);
44 | default:
45 | return tasks;
46 | }
47 | };
48 |
49 | export const TaskListContext = createContext(
50 | undefined as unknown as TaskListContextType,
51 | );
52 |
53 | export const TaskListProvider = ({
54 | children,
55 | tasks: initialTasks = [],
56 | }: PropsWithChildren<{ tasks?: Task[] }>) => {
57 | const [tasks, dispatch] = useReducer(taskListReducer, initialTasks);
58 |
59 | const addTask = (title: string) => dispatch({ type: 'addTask', payload: title });
60 | const removeTask = (id: string) => dispatch({ type: 'removeTask', payload: id });
61 | const toggleTask = (id: string) => dispatch({ type: 'toggleTask', payload: id });
62 |
63 | const incomplete = useMemo(() => tasks.filter((task) => !task.completed).length, [tasks]);
64 | const total = useMemo(() => tasks.length, [tasks]);
65 |
66 | return (
67 |
68 | {children}
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/src/tokens/colors.ts:
--------------------------------------------------------------------------------
1 | export const colors = {
2 | primary: {
3 | '50': '#faf7fd',
4 | '100': '#f2ecfb',
5 | '200': '#e7ddf7',
6 | '300': '#d5c2f0',
7 | '400': '#bc9be5',
8 | '500': '#9967d5',
9 | '600': '#8a55c8',
10 | '700': '#7642ae',
11 | '800': '#643a8f',
12 | '900': '#523073',
13 | '950': '#361952',
14 | },
15 | secondary: {
16 | '50': '#f0fdfa',
17 | '100': '#ccfbf0',
18 | '200': '#98f7e2',
19 | '300': '#5debd2',
20 | '400': '#2cd5bc',
21 | '500': '#13b9a3',
22 | '600': '#0c9586',
23 | '700': '#0e776d',
24 | '800': '#115e57',
25 | '900': '#134e49',
26 | '950': '#042f2d',
27 | },
28 | accent: {
29 | '50': '#fff7ed',
30 | '100': '#ffecd5',
31 | '200': '#fed6aa',
32 | '300': '#feb873',
33 | '400': '#fc903b',
34 | '500': '#fa7015',
35 | '600': '#eb550b',
36 | '700': '#c33f0b',
37 | '800': '#9b3211',
38 | '900': '#7c2c12',
39 | '950': '#431307',
40 | },
41 | information: {
42 | '50': '#f3f6fc',
43 | '100': '#e6eef8',
44 | '200': '#c7daf0',
45 | '300': '#95bbe4',
46 | '400': '#5d99d3',
47 | '500': '#387cbf',
48 | '600': '#2862a1',
49 | '700': '#214e83',
50 | '800': '#1f446d',
51 | '900': '#1f3a5b',
52 | '950': '#14253d',
53 | },
54 | success: {
55 | '50': '#f1fcf2',
56 | '100': '#defae4',
57 | '200': '#bff3ca',
58 | '300': '#8de8a1',
59 | '400': '#54d471',
60 | '500': '#2cb84c',
61 | '600': '#1f9a3b',
62 | '700': '#1c7932',
63 | '800': '#1b602b',
64 | '900': '#184f26',
65 | '950': '#082b11',
66 | },
67 | warning: {
68 | '50': '#fffde7',
69 | '100': '#fffbc1',
70 | '200': '#fff286',
71 | '300': '#ffe341',
72 | '400': '#ffcf0d',
73 | '500': '#ecb100',
74 | '600': '#d18b00',
75 | '700': '#a66202',
76 | '800': '#894c0a',
77 | '900': '#743e0f',
78 | '950': '#442004',
79 | },
80 | danger: {
81 | '50': '#fef3f2',
82 | '100': '#fee5e5',
83 | '200': '#fccfd1',
84 | '300': '#faa7ac',
85 | '400': '#f6767f',
86 | '500': '#ed4656',
87 | '600': '#d9253e',
88 | '700': '#b71933',
89 | '800': '#9a1731',
90 | '900': '#831831',
91 | '950': '#490815',
92 | },
93 | slate: {
94 | '50': '#f6f7f9',
95 | '100': '#eceef2',
96 | '200': '#d5d9e2',
97 | '300': '#b1bbc8',
98 | '400': '#8695aa',
99 | '500': '#64748b',
100 | '600': '#526077',
101 | '700': '#434e61',
102 | '800': '#3a4252',
103 | '900': '#343a46',
104 | '950': '#23272e',
105 | },
106 | };
107 |
108 | export const white = '#ffffff';
109 | export const black = '#010209';
110 | export const transparent = 'transparent';
111 | export const currentColor = 'currentColor';
112 |
--------------------------------------------------------------------------------
/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* tslint:disable */
3 |
4 | /**
5 | * Mock Service Worker.
6 | * @see https://github.com/mswjs/msw
7 | * - Please do NOT modify this file.
8 | * - Please do NOT serve this file on production.
9 | */
10 |
11 | const PACKAGE_VERSION = '2.3.1';
12 | const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423';
13 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
14 | const activeClientIds = new Set();
15 |
16 | self.addEventListener('install', function () {
17 | self.skipWaiting();
18 | });
19 |
20 | self.addEventListener('activate', function (event) {
21 | event.waitUntil(self.clients.claim());
22 | });
23 |
24 | self.addEventListener('message', async function (event) {
25 | const clientId = event.source.id;
26 |
27 | if (!clientId || !self.clients) {
28 | return;
29 | }
30 |
31 | const client = await self.clients.get(clientId);
32 |
33 | if (!client) {
34 | return;
35 | }
36 |
37 | const allClients = await self.clients.matchAll({
38 | type: 'window',
39 | });
40 |
41 | switch (event.data) {
42 | case 'KEEPALIVE_REQUEST': {
43 | sendToClient(client, {
44 | type: 'KEEPALIVE_RESPONSE',
45 | });
46 | break;
47 | }
48 |
49 | case 'INTEGRITY_CHECK_REQUEST': {
50 | sendToClient(client, {
51 | type: 'INTEGRITY_CHECK_RESPONSE',
52 | payload: {
53 | packageVersion: PACKAGE_VERSION,
54 | checksum: INTEGRITY_CHECKSUM,
55 | },
56 | });
57 | break;
58 | }
59 |
60 | case 'MOCK_ACTIVATE': {
61 | activeClientIds.add(clientId);
62 |
63 | sendToClient(client, {
64 | type: 'MOCKING_ENABLED',
65 | payload: true,
66 | });
67 | break;
68 | }
69 |
70 | case 'MOCK_DEACTIVATE': {
71 | activeClientIds.delete(clientId);
72 | break;
73 | }
74 |
75 | case 'CLIENT_CLOSED': {
76 | activeClientIds.delete(clientId);
77 |
78 | const remainingClients = allClients.filter((client) => {
79 | return client.id !== clientId;
80 | });
81 |
82 | // Unregister itself when there are no more clients
83 | if (remainingClients.length === 0) {
84 | self.registration.unregister();
85 | }
86 |
87 | break;
88 | }
89 | }
90 | });
91 |
92 | self.addEventListener('fetch', function (event) {
93 | const { request } = event;
94 |
95 | // Bypass navigation requests.
96 | if (request.mode === 'navigate') {
97 | return;
98 | }
99 |
100 | // Opening the DevTools triggers the "only-if-cached" request
101 | // that cannot be handled by the worker. Bypass such requests.
102 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
103 | return;
104 | }
105 |
106 | // Bypass all requests when there are no active clients.
107 | // Prevents the self-unregistered worked from handling requests
108 | // after it's been deleted (still remains active until the next reload).
109 | if (activeClientIds.size === 0) {
110 | return;
111 | }
112 |
113 | // Generate unique request ID.
114 | const requestId = crypto.randomUUID();
115 | event.respondWith(handleRequest(event, requestId));
116 | });
117 |
118 | async function handleRequest(event, requestId) {
119 | const client = await resolveMainClient(event);
120 | const response = await getResponse(event, client, requestId);
121 |
122 | // Send back the response clone for the "response:*" life-cycle events.
123 | // Ensure MSW is active and ready to handle the message, otherwise
124 | // this message will pend indefinitely.
125 | if (client && activeClientIds.has(client.id)) {
126 | (async function () {
127 | const responseClone = response.clone();
128 |
129 | sendToClient(
130 | client,
131 | {
132 | type: 'RESPONSE',
133 | payload: {
134 | requestId,
135 | isMockedResponse: IS_MOCKED_RESPONSE in response,
136 | type: responseClone.type,
137 | status: responseClone.status,
138 | statusText: responseClone.statusText,
139 | body: responseClone.body,
140 | headers: Object.fromEntries(responseClone.headers.entries()),
141 | },
142 | },
143 | [responseClone.body],
144 | );
145 | })();
146 | }
147 |
148 | return response;
149 | }
150 |
151 | // Resolve the main client for the given event.
152 | // Client that issues a request doesn't necessarily equal the client
153 | // that registered the worker. It's with the latter the worker should
154 | // communicate with during the response resolving phase.
155 | async function resolveMainClient(event) {
156 | const client = await self.clients.get(event.clientId);
157 |
158 | if (client?.frameType === 'top-level') {
159 | return client;
160 | }
161 |
162 | const allClients = await self.clients.matchAll({
163 | type: 'window',
164 | });
165 |
166 | return allClients
167 | .filter((client) => {
168 | // Get only those clients that are currently visible.
169 | return client.visibilityState === 'visible';
170 | })
171 | .find((client) => {
172 | // Find the client ID that's recorded in the
173 | // set of clients that have registered the worker.
174 | return activeClientIds.has(client.id);
175 | });
176 | }
177 |
178 | async function getResponse(event, client, requestId) {
179 | const { request } = event;
180 |
181 | // Clone the request because it might've been already used
182 | // (i.e. its body has been read and sent to the client).
183 | const requestClone = request.clone();
184 |
185 | function passthrough() {
186 | const headers = Object.fromEntries(requestClone.headers.entries());
187 |
188 | // Remove internal MSW request header so the passthrough request
189 | // complies with any potential CORS preflight checks on the server.
190 | // Some servers forbid unknown request headers.
191 | delete headers['x-msw-intention'];
192 |
193 | return fetch(requestClone, { headers });
194 | }
195 |
196 | // Bypass mocking when the client is not active.
197 | if (!client) {
198 | return passthrough();
199 | }
200 |
201 | // Bypass initial page load requests (i.e. static assets).
202 | // The absence of the immediate/parent client in the map of the active clients
203 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
204 | // and is not ready to handle requests.
205 | if (!activeClientIds.has(client.id)) {
206 | return passthrough();
207 | }
208 |
209 | // Notify the client that a request has been intercepted.
210 | const requestBuffer = await request.arrayBuffer();
211 | const clientMessage = await sendToClient(
212 | client,
213 | {
214 | type: 'REQUEST',
215 | payload: {
216 | id: requestId,
217 | url: request.url,
218 | mode: request.mode,
219 | method: request.method,
220 | headers: Object.fromEntries(request.headers.entries()),
221 | cache: request.cache,
222 | credentials: request.credentials,
223 | destination: request.destination,
224 | integrity: request.integrity,
225 | redirect: request.redirect,
226 | referrer: request.referrer,
227 | referrerPolicy: request.referrerPolicy,
228 | body: requestBuffer,
229 | keepalive: request.keepalive,
230 | },
231 | },
232 | [requestBuffer],
233 | );
234 |
235 | switch (clientMessage.type) {
236 | case 'MOCK_RESPONSE': {
237 | return respondWithMock(clientMessage.data);
238 | }
239 |
240 | case 'PASSTHROUGH': {
241 | return passthrough();
242 | }
243 | }
244 |
245 | return passthrough();
246 | }
247 |
248 | function sendToClient(client, message, transferrables = []) {
249 | return new Promise((resolve, reject) => {
250 | const channel = new MessageChannel();
251 |
252 | channel.port1.onmessage = (event) => {
253 | if (event.data && event.data.error) {
254 | return reject(event.data.error);
255 | }
256 |
257 | resolve(event.data);
258 | };
259 |
260 | client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
261 | });
262 | }
263 |
264 | async function respondWithMock(response) {
265 | // Setting response status code to 0 is a no-op.
266 | // However, when responding with a "Response.error()", the produced Response
267 | // instance will have status code set to 0. Since it's not possible to create
268 | // a Response instance with status code 0, handle that use-case separately.
269 | if (response.status === 0) {
270 | return Response.error();
271 | }
272 |
273 | const mockedResponse = new Response(response.body, response);
274 |
275 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
276 | value: true,
277 | enumerable: true,
278 | });
279 |
280 | return mockedResponse;
281 | }
282 |
--------------------------------------------------------------------------------