Skip to content

Commit 9af855c

Browse files
committed
rework icons
1 parent 48aed91 commit 9af855c

File tree

11 files changed

+338
-72
lines changed

11 files changed

+338
-72
lines changed

exercises/99.final/01.solution.final/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"dependencies": {
1515
"@tailwindcss/vite": "^4.0.9",
1616
"class-variance-authority": "^0.7.1",
17+
"clsx": "^2.1.1",
1718
"react": "^19.0.0",
1819
"react-dom": "^19.0.0",
1920
"react-router": "^7.2.0",

exercises/99.final/01.solution.final/src/components/button.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ const buttonStyles = cva(
2727
'hover:bg-button/10',
2828
'active:bg-button/20',
2929
],
30+
success: [
31+
'bg-success-background text-success-foreground',
32+
'hover:bg-success-background/90',
33+
'active:bg-success-background/80',
34+
],
35+
danger: [
36+
'bg-danger-background text-danger-foreground',
37+
'hover:bg-danger-background/90',
38+
'active:bg-danger-background/80',
39+
],
3040
},
3141
icon: {
3242
true: 'flex h-10 w-10 items-center justify-center rounded-full p-2',
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { clsx } from 'clsx'
2+
import * as icons from './icons.tsx'
3+
4+
const sizeClassName = {
5+
font: 'w-[1em] h-[1em]',
6+
xs: 'w-3 h-3',
7+
sm: 'w-4 h-4',
8+
md: 'w-5 h-5',
9+
lg: 'w-6 h-6',
10+
xl: 'w-7 h-7',
11+
} as const
12+
13+
type Size = keyof typeof sizeClassName
14+
15+
const childrenSizeClassName = {
16+
font: 'gap-1.5',
17+
xs: 'gap-1.5',
18+
sm: 'gap-1.5',
19+
md: 'gap-2',
20+
lg: 'gap-2',
21+
xl: 'gap-3',
22+
} satisfies Record<Size, string>
23+
24+
export type IconName = keyof typeof icons extends `${infer Name}Icon`
25+
? Name
26+
: never
27+
28+
/**
29+
* Renders an SVG icon. The icon defaults to the size of the font. To make it
30+
* align vertically with neighboring text, you can pass the text as a child of
31+
* the icon and it will be automatically aligned.
32+
* Alternatively, if you're not ok with the icon being to the left of the text,
33+
* you need to wrap the icon and text in a common parent and set the parent to
34+
* display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
35+
*
36+
* Pass `title` prop to the `Icon` component to get `<title>` element rendered
37+
* in the SVG container, providing this way for accessibility.
38+
*/
39+
export function Icon({
40+
name,
41+
size = 'font',
42+
className,
43+
title,
44+
children,
45+
...props
46+
}: icons.IconProps & {
47+
name: IconName
48+
size?: Size
49+
}) {
50+
if (children) {
51+
return (
52+
<span
53+
className={`inline-flex items-center ${childrenSizeClassName[size]}`}
54+
>
55+
<Icon
56+
name={name}
57+
size={size}
58+
className={className}
59+
title={title}
60+
{...props}
61+
/>
62+
{children}
63+
</span>
64+
)
65+
}
66+
67+
const IconComponent = icons[`${name}Icon`]
68+
if (!IconComponent) {
69+
console.error(`Icon "${name}" not found`)
70+
return null
71+
}
72+
73+
return (
74+
<IconComponent
75+
className={clsx(sizeClassName[size], className)}
76+
{...props}
77+
/>
78+
)
79+
}

exercises/99.final/01.solution.final/src/components/icons.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
type IconProps = React.SVGProps<SVGSVGElement>
1+
export type IconProps = React.SVGProps<SVGSVGElement> & {
2+
title?: string
3+
}
24

3-
export function ArrowLeftIcon(props: IconProps) {
5+
export function ArrowLeftIcon({ title, ...props }: IconProps) {
46
return (
57
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
8+
{title ? <title>{title}</title> : null}
69
<path
710
strokeLinecap="round"
811
strokeLinejoin="round"
@@ -13,9 +16,10 @@ export function ArrowLeftIcon(props: IconProps) {
1316
)
1417
}
1518

16-
export function PhoneIcon(props: IconProps) {
19+
export function PhoneIcon({ title, ...props }: IconProps) {
1720
return (
1821
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
22+
{title ? <title>{title}</title> : null}
1923
<path
2024
strokeLinecap="round"
2125
strokeLinejoin="round"
@@ -26,9 +30,10 @@ export function PhoneIcon(props: IconProps) {
2630
)
2731
}
2832

29-
export function ClockIcon(props: IconProps) {
33+
export function ClockIcon({ title, ...props }: IconProps) {
3034
return (
3135
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
36+
{title ? <title>{title}</title> : null}
3237
<path
3338
strokeLinecap="round"
3439
strokeLinejoin="round"
@@ -39,9 +44,10 @@ export function ClockIcon(props: IconProps) {
3944
)
4045
}
4146

42-
export function SettingsIcon(props: IconProps) {
47+
export function SettingsIcon({ title, ...props }: IconProps) {
4348
return (
4449
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
50+
{title ? <title>{title}</title> : null}
4551
<path
4652
strokeLinecap="round"
4753
strokeLinejoin="round"
@@ -58,9 +64,10 @@ export function SettingsIcon(props: IconProps) {
5864
)
5965
}
6066

61-
export function CheckIcon(props: IconProps) {
67+
export function CheckIcon({ title, ...props }: IconProps) {
6268
return (
6369
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
70+
{title ? <title>{title}</title> : null}
6471
<path
6572
strokeLinecap="round"
6673
strokeLinejoin="round"
@@ -71,9 +78,10 @@ export function CheckIcon(props: IconProps) {
7178
)
7279
}
7380

74-
export function DotsVerticalIcon(props: IconProps) {
81+
export function DotsVerticalIcon({ title, ...props }: IconProps) {
7582
return (
7683
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
84+
{title ? <title>{title}</title> : null}
7785
<path
7886
strokeLinecap="round"
7987
strokeLinejoin="round"
@@ -84,9 +92,10 @@ export function DotsVerticalIcon(props: IconProps) {
8492
)
8593
}
8694

87-
export function MessageIcon(props: IconProps) {
95+
export function MessageIcon({ title, ...props }: IconProps) {
8896
return (
8997
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
98+
{title ? <title>{title}</title> : null}
9099
<path
91100
strokeLinecap="round"
92101
strokeLinejoin="round"
@@ -97,9 +106,10 @@ export function MessageIcon(props: IconProps) {
97106
)
98107
}
99108

100-
export function PlusIcon(props: IconProps) {
109+
export function PlusIcon({ title, ...props }: IconProps) {
101110
return (
102111
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
112+
{title ? <title>{title}</title> : null}
103113
<path
104114
strokeLinecap="round"
105115
strokeLinejoin="round"
@@ -109,3 +119,17 @@ export function PlusIcon(props: IconProps) {
109119
</svg>
110120
)
111121
}
122+
123+
export function InfoIcon({ title, ...props }: IconProps) {
124+
return (
125+
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
126+
{title ? <title>{title}</title> : null}
127+
<path
128+
strokeLinecap="round"
129+
strokeLinejoin="round"
130+
strokeWidth={2}
131+
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
132+
/>
133+
</svg>
134+
)
135+
}

exercises/99.final/01.solution.final/src/routes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AppLayout } from './routes/app/layout.tsx'
33
import { AboutRoute } from './routes/app/marketing/about.tsx'
44
import { HomepageRoute } from './routes/app/marketing/homepage.tsx'
55
import { MarketingLayout } from './routes/app/marketing/layout.tsx'
6+
import { NewRecipientRoute } from './routes/app/new-recipient.tsx'
67
import { RecipientRoute } from './routes/app/recipient.tsx'
78
import { RecipientsRoute } from './routes/app/recipients.tsx'
89
import { SignupRoute } from './routes/signup.tsx'
@@ -28,6 +29,10 @@ export const router = createBrowserRouter([
2829
path: 'recipients',
2930
element: <RecipientsRoute />,
3031
},
32+
{
33+
path: 'recipients/new',
34+
element: <NewRecipientRoute />,
35+
},
3136
{
3237
path: 'recipients/:id',
3338
element: <RecipientRoute />,
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Button } from '#src/components/button.tsx'
2+
import { Icon } from '#src/components/icon.tsx'
3+
4+
export function NewRecipientRoute() {
5+
return (
6+
<div className="container mx-auto max-w-2xl p-6">
7+
<h1 className="mb-2 text-center text-4xl font-bold">Add New Recipient</h1>
8+
<p className="mb-8 text-center">Who should receive your messages?</p>
9+
10+
<form
11+
onSubmit={(e) => e.preventDefault()}
12+
className="flex flex-col gap-6 p-8"
13+
>
14+
<div>
15+
<label htmlFor="name" className="mb-2 block">
16+
Name
17+
</label>
18+
<input
19+
type="text"
20+
id="name"
21+
name="name"
22+
placeholder="Recipient's Name"
23+
className="w-full rounded-lg border p-3"
24+
required
25+
/>
26+
</div>
27+
28+
<div className="grid grid-cols-2 gap-4">
29+
<div>
30+
<label htmlFor="countryCode" className="mb-2 block">
31+
Country Code
32+
</label>
33+
<select
34+
id="countryCode"
35+
name="countryCode"
36+
className="w-full rounded-lg border p-3"
37+
required
38+
>
39+
<option value="">Select Country</option>
40+
<option value="+1">United States (+1)</option>
41+
<option value="+44">United Kingdom (+44)</option>
42+
<option value="+81">Japan (+81)</option>
43+
{/* Add more country codes as needed */}
44+
</select>
45+
</div>
46+
47+
<div>
48+
<label htmlFor="phoneNumber" className="mb-2 block">
49+
Phone Number
50+
</label>
51+
<input
52+
type="tel"
53+
id="phoneNumber"
54+
name="phoneNumber"
55+
placeholder="123 456 7890"
56+
className="w-full rounded-lg border p-3"
57+
required
58+
/>
59+
</div>
60+
</div>
61+
62+
<div>
63+
<label htmlFor="timeZone" className="mb-2 block">
64+
Time Zone
65+
</label>
66+
<select
67+
id="timeZone"
68+
name="timeZone"
69+
className="w-full rounded-lg border p-3"
70+
required
71+
>
72+
<option value="">Select Time Zone</option>
73+
<option value="America/New_York">Eastern Time</option>
74+
<option value="America/Chicago">Central Time</option>
75+
<option value="America/Denver">Mountain Time</option>
76+
<option value="America/Los_Angeles">Pacific Time</option>
77+
{/* Add more time zones as needed */}
78+
</select>
79+
</div>
80+
81+
<div>
82+
<label className="mb-2 block">Create a Schedule</label>
83+
<div className="grid grid-cols-2 gap-4">
84+
<select
85+
name="scheduleDay"
86+
className="w-full rounded-lg border p-3"
87+
required
88+
>
89+
<option value="">Select Day</option>
90+
<option value="monday">Monday</option>
91+
<option value="tuesday">Tuesday</option>
92+
<option value="wednesday">Wednesday</option>
93+
<option value="thursday">Thursday</option>
94+
<option value="friday">Friday</option>
95+
<option value="saturday">Saturday</option>
96+
<option value="sunday">Sunday</option>
97+
</select>
98+
99+
<select
100+
name="scheduleTime"
101+
className="w-full rounded-lg border p-3"
102+
required
103+
>
104+
<option value="">Select Time</option>
105+
{Array.from({ length: 24 }, (_, i) => {
106+
const hour = i.toString().padStart(2, '0')
107+
return (
108+
<option key={hour} value={`${hour}:00`}>
109+
{`${hour}:00`}
110+
</option>
111+
)
112+
})}
113+
</select>
114+
</div>
115+
</div>
116+
117+
<div className="mt-2 flex items-center gap-2">
118+
<Icon name="Info" size="xl">
119+
<p>Your messages will arrive every week at this day and time</p>
120+
</Icon>
121+
</div>
122+
123+
<Button type="submit" variant="success">
124+
<Icon name="Plus">Add New Recipient</Icon>
125+
</Button>
126+
</form>
127+
</div>
128+
)
129+
}

0 commit comments

Comments
 (0)