@scripso-homepad/ui 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/index.cjs +245 -127
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +4 -2
  4. package/dist/index.d.ts +4 -2
  5. package/dist/index.js +247 -129
  6. package/dist/index.js.map +1 -1
  7. package/dist/web/index.cjs +451 -48
  8. package/dist/web/index.cjs.map +1 -1
  9. package/dist/web/index.css +161 -0
  10. package/dist/web/index.css.map +1 -1
  11. package/dist/web/index.d.cts +50 -2
  12. package/dist/web/index.d.ts +50 -2
  13. package/dist/web/index.js +444 -49
  14. package/dist/web/index.js.map +1 -1
  15. package/package.json +10 -3
  16. package/src/components/Calendar.tsx +10 -2
  17. package/src/components/DatePicker.tsx +40 -6
  18. package/src/components/Select.tsx +133 -38
  19. package/src/theme/select.ts +1 -1
  20. package/src/utils/calendarDays.ts +15 -6
  21. package/src/web/components/Badge.tsx +1 -1
  22. package/src/web/components/ConfirmModal.tsx +2 -2
  23. package/src/web/components/Drawer.stories.tsx +234 -0
  24. package/src/web/components/Drawer.tsx +169 -0
  25. package/src/web/components/HistoryTimeline.tsx +46 -0
  26. package/src/web/components/Modal.stories.tsx +1 -1
  27. package/src/web/components/Modal.tsx +30 -13
  28. package/src/web/components/Pagination.tsx +2 -2
  29. package/src/web/components/TableActionButton.tsx +4 -4
  30. package/src/web/components/TableActionsMenu.tsx +1 -1
  31. package/src/web/components/TableIconText.tsx +1 -1
  32. package/src/web/components/Toaster.stories.tsx +106 -0
  33. package/src/web/components/Toaster.tsx +92 -0
  34. package/src/web/components/ToasterProvider.tsx +6 -0
  35. package/src/web/components/drawer-scroll.css +41 -0
  36. package/src/web/components/sonner-toast.css +141 -0
  37. package/src/web/components/table/constants.ts +2 -2
  38. package/src/web/icons/TimelineCheckIcon.tsx +24 -0
  39. package/src/web/icons/ToastIcons.tsx +119 -0
  40. package/src/web/index.ts +20 -0
  41. package/src/web/layout/AppHeader.tsx +1 -1
  42. package/src/web/layout/BuildingSelect.tsx +2 -2
  43. package/src/web/layout/DashboardLayout.stories.tsx +1 -1
  44. package/src/web/layout/SidebarNavItem.tsx +1 -1
  45. package/src/web/layout/SidebarUserCard.tsx +3 -3
  46. package/src/web/mutation-toast.ts +33 -0
  47. package/src/web/styles/typography.css +99 -0
  48. package/src/web/toast.ts +1 -0
@@ -0,0 +1,169 @@
1
+ import { X } from 'lucide-react';
2
+ import { useEffect, useState, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../utils/cn';
5
+
6
+ import './drawer-scroll.css';
7
+
8
+ const DRAWER_TRANSITION_MS = 300;
9
+
10
+ export type DrawerProps = {
11
+ open: boolean;
12
+ title: string;
13
+ subtitle?: string;
14
+ children: ReactNode;
15
+ footer?: ReactNode;
16
+ onClose: () => void;
17
+ closeAriaLabel?: string;
18
+ widthClassName?: string;
19
+ className?: string;
20
+ headerClassName?: string;
21
+ contentClassName?: string;
22
+ footerClassName?: string;
23
+ };
24
+
25
+ export function Drawer({
26
+ open,
27
+ title,
28
+ subtitle,
29
+ children,
30
+ footer,
31
+ onClose,
32
+ closeAriaLabel = 'Close',
33
+ widthClassName = 'w-full md:w-[464px]',
34
+ className,
35
+ headerClassName,
36
+ contentClassName,
37
+ footerClassName,
38
+ }: DrawerProps) {
39
+ const [isPresent, setIsPresent] = useState(open);
40
+ const [isAnimatedIn, setIsAnimatedIn] = useState(false);
41
+
42
+ useEffect(() => {
43
+ if (open) {
44
+ setIsPresent(true);
45
+
46
+ const frame = window.requestAnimationFrame(() => {
47
+ window.requestAnimationFrame(() => {
48
+ setIsAnimatedIn(true);
49
+ });
50
+ });
51
+
52
+ return () => {
53
+ window.cancelAnimationFrame(frame);
54
+ };
55
+ }
56
+
57
+ setIsAnimatedIn(false);
58
+
59
+ const timeout = window.setTimeout(() => {
60
+ setIsPresent(false);
61
+ }, DRAWER_TRANSITION_MS);
62
+
63
+ return () => {
64
+ window.clearTimeout(timeout);
65
+ };
66
+ }, [open]);
67
+
68
+ useEffect(() => {
69
+ if (!isPresent) {
70
+ return;
71
+ }
72
+
73
+ const previousOverflow = document.body.style.overflow;
74
+ document.body.style.overflow = 'hidden';
75
+
76
+ const handleKeyDown = (event: KeyboardEvent) => {
77
+ if (event.key === 'Escape') {
78
+ onClose();
79
+ }
80
+ };
81
+
82
+ window.addEventListener('keydown', handleKeyDown);
83
+
84
+ return () => {
85
+ document.body.style.overflow = previousOverflow;
86
+ window.removeEventListener('keydown', handleKeyDown);
87
+ };
88
+ }, [isPresent, onClose]);
89
+
90
+ if (!isPresent) {
91
+ return null;
92
+ }
93
+
94
+ return (
95
+ <div
96
+ className="fixed inset-0 z-50 flex justify-end p-2 max-md:p-0"
97
+ role="presentation"
98
+ >
99
+ <button
100
+ type="button"
101
+ aria-label={closeAriaLabel}
102
+ className={cn(
103
+ 'absolute inset-0 bg-storm-gray-850/40 backdrop-blur-[12px] transition-opacity duration-300 ease-in-out',
104
+ isAnimatedIn ? 'opacity-100' : 'opacity-0',
105
+ )}
106
+ onClick={onClose}
107
+ />
108
+
109
+ <aside
110
+ role="dialog"
111
+ aria-modal="true"
112
+ aria-labelledby="homepad-drawer-title"
113
+ {...(subtitle ? { 'aria-describedby': 'homepad-drawer-subtitle' } : {})}
114
+ className={cn(
115
+ 'relative z-10 flex h-[calc(100dvh-16px)] min-h-0 flex-col overflow-hidden rounded-[16px] bg-white shadow-2xl transition-transform duration-300 ease-in-out will-change-transform max-md:h-dvh max-md:rounded-none',
116
+ isAnimatedIn ? 'translate-x-0' : 'translate-x-full',
117
+ widthClassName,
118
+ className,
119
+ )}
120
+ >
121
+ <header
122
+ className={cn(
123
+ 'flex h-[87px] shrink-0 items-start justify-between border-b border-storm-gray-50 pt-6 pr-6 pb-4 pl-6',
124
+ headerClassName,
125
+ )}
126
+ >
127
+ <div className="min-w-0 flex flex-col gap-1">
128
+ <h2
129
+ id="homepad-drawer-title"
130
+ className="typography-title-sm tracking-normal text-black"
131
+ >
132
+ {title}
133
+ </h2>
134
+ {subtitle ? (
135
+ <p
136
+ id="homepad-drawer-subtitle"
137
+ className="typography-body tracking-normal text-storm-gray-400"
138
+ >
139
+ {subtitle}
140
+ </p>
141
+ ) : null}
142
+ </div>
143
+
144
+ <button
145
+ type="button"
146
+ aria-label={closeAriaLabel}
147
+ className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-storm-gray-400 transition-colors hover:bg-storm-gray-50 hover:text-storm-gray-600"
148
+ onClick={onClose}
149
+ >
150
+ <X className="size-5" strokeWidth={1.75} />
151
+ </button>
152
+ </header>
153
+
154
+ <div
155
+ className={cn(
156
+ 'drawer-scroll-area min-h-0 flex-1 overflow-y-auto px-6 pt-6 pb-0',
157
+ contentClassName,
158
+ )}
159
+ >
160
+ {children}
161
+ </div>
162
+
163
+ {footer ? (
164
+ <footer className={cn('shrink-0 px-6 py-5', footerClassName)}>{footer}</footer>
165
+ ) : null}
166
+ </aside>
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,46 @@
1
+ import { TimelineCheckIcon } from '../icons/TimelineCheckIcon';
2
+
3
+ export type HistoryTimelineItem = {
4
+ id: string;
5
+ title: string;
6
+ timestamp: string;
7
+ };
8
+
9
+ export type HistoryTimelineProps = {
10
+ events: HistoryTimelineItem[];
11
+ };
12
+
13
+ export function HistoryTimeline({ events }: HistoryTimelineProps) {
14
+ return (
15
+ <ol className="m-0 flex list-none flex-col gap-1 p-0">
16
+ {events.map((event, index) => {
17
+ const isLast = index === events.length - 1;
18
+
19
+ return (
20
+ <li key={event.id} className="flex items-start gap-3">
21
+ <div className="flex w-8 shrink-0 flex-col items-center">
22
+ <div className="flex size-8 shrink-0 items-center justify-center rounded-[28px] border border-storm-gray-50 bg-white">
23
+ <TimelineCheckIcon className="size-4 shrink-0" />
24
+ </div>
25
+ {!isLast ? (
26
+ <div
27
+ aria-hidden
28
+ className="mt-1 h-[31px] w-px shrink-0 border-l border-dashed border-storm-gray-100"
29
+ />
30
+ ) : null}
31
+ </div>
32
+
33
+ <div className="flex min-w-0 flex-1 flex-col gap-2 pt-1.5">
34
+ <p className="typography-14 font-medium tracking-normal text-slate-blue">
35
+ {event.title}
36
+ </p>
37
+ <p className="typography-caption tracking-normal text-storm-gray-300">
38
+ {event.timestamp}
39
+ </p>
40
+ </div>
41
+ </li>
42
+ );
43
+ })}
44
+ </ol>
45
+ );
46
+ }
@@ -106,7 +106,7 @@ function FooterAlignmentsStory() {
106
106
  <button
107
107
  key={value}
108
108
  type="button"
109
- className="rounded-lg border border-storm-gray-50 px-3 py-1 text-sm"
109
+ className="rounded-lg border border-storm-gray-50 px-3 py-1 typography-14"
110
110
  onClick={() => setAlign(value)}
111
111
  >
112
112
  {value}
@@ -1,4 +1,5 @@
1
1
  import { useEffect, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
 
3
4
  import { Button } from '../../components/Button';
4
5
  import { cn } from '../utils/cn';
@@ -22,6 +23,7 @@ export type ModalProps = {
22
23
  cancelDisabled?: boolean;
23
24
  confirmDisabled?: boolean;
24
25
  closeOnBackdrop?: boolean;
26
+ closeOnEscape?: boolean;
25
27
  containerClassName?: string;
26
28
  contentClassName?: string;
27
29
  buttonClassName?: string;
@@ -49,6 +51,7 @@ export function Modal({
49
51
  cancelDisabled = false,
50
52
  confirmDisabled = false,
51
53
  closeOnBackdrop = true,
54
+ closeOnEscape = true,
52
55
  containerClassName,
53
56
  contentClassName,
54
57
  buttonClassName,
@@ -58,8 +61,11 @@ export function Modal({
58
61
  return;
59
62
  }
60
63
 
64
+ const previousOverflow = document.body.style.overflow;
65
+ document.body.style.overflow = 'hidden';
66
+
61
67
  const handleKeyDown = (event: KeyboardEvent) => {
62
- if (event.key === 'Escape') {
68
+ if (event.key === 'Escape' && closeOnEscape) {
63
69
  onCancel();
64
70
  }
65
71
  };
@@ -67,9 +73,10 @@ export function Modal({
67
73
  window.addEventListener('keydown', handleKeyDown);
68
74
 
69
75
  return () => {
76
+ document.body.style.overflow = previousOverflow;
70
77
  window.removeEventListener('keydown', handleKeyDown);
71
78
  };
72
- }, [open, onCancel]);
79
+ }, [open, onCancel, closeOnEscape]);
73
80
 
74
81
  if (!open) {
75
82
  return null;
@@ -77,14 +84,22 @@ export function Modal({
77
84
 
78
85
  const sharedButtonClassName = cn(
79
86
  'btn h-13 justify-center!',
80
- footerLayout === 'split' && 'min-w-0 flex-1',
81
- buttonClassName,
87
+ footerLayout === 'split' && 'min-w-0 flex-1 basis-0',
88
+ footerLayout !== 'split' && buttonClassName,
82
89
  );
83
90
 
84
- return (
91
+ const modal = (
85
92
  <div
86
- className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-[12px] bg-storm-gray-850/40"
87
- onClick={closeOnBackdrop ? onCancel : undefined}
93
+ className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-[12px] bg-storm-gray-850/40"
94
+ onClick={
95
+ closeOnBackdrop
96
+ ? (event) => {
97
+ if (event.target === event.currentTarget) {
98
+ onCancel();
99
+ }
100
+ }
101
+ : undefined
102
+ }
88
103
  role="presentation"
89
104
  >
90
105
  <div
@@ -93,14 +108,14 @@ export function Modal({
93
108
  aria-labelledby="homepad-modal-title"
94
109
  {...(subtitle ? { 'aria-describedby': 'homepad-modal-subtitle' } : {})}
95
110
  className={cn(
96
- 'flex w-full max-w-2xl flex-col gap-6 rounded-2xl bg-white p-4 opacity-100 md:p-6',
111
+ 'flex max-h-[min(90dvh,calc(100dvh-2rem))] w-full max-w-2xl flex-col gap-6 rounded-2xl bg-white p-4 opacity-100 md:p-6',
97
112
  containerClassName,
98
113
  )}
99
114
  onClick={(event) => {
100
115
  event.stopPropagation();
101
116
  }}
102
117
  >
103
- <header className={cn('flex', icon ? 'items-center gap-2.5' : undefined)}>
118
+ <header className={cn('flex shrink-0', icon ? 'items-center gap-2.5' : undefined)}>
104
119
  {icon ? (
105
120
  <div
106
121
  className={cn(
@@ -116,14 +131,14 @@ export function Modal({
116
131
  <div className="flex min-w-0 flex-col gap-2">
117
132
  <h2
118
133
  id="homepad-modal-title"
119
- className="font-sans text-lg font-bold leading-none tracking-normal text-black"
134
+ className="typography-title-sm tracking-normal text-black"
120
135
  >
121
136
  {title}
122
137
  </h2>
123
138
  {subtitle ? (
124
139
  <p
125
140
  id="homepad-modal-subtitle"
126
- className="font-sans text-xs font-normal leading-none tracking-normal text-storm-gray-400"
141
+ className="typography-caption tracking-normal text-storm-gray-400"
127
142
  >
128
143
  {subtitle}
129
144
  </p>
@@ -131,11 +146,11 @@ export function Modal({
131
146
  </div>
132
147
  </header>
133
148
 
134
- <div className={cn('min-w-0', contentClassName)}>{children}</div>
149
+ <div className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto', contentClassName)}>{children}</div>
135
150
 
136
151
  <footer
137
152
  className={cn(
138
- 'flex gap-4',
153
+ 'flex shrink-0 gap-4',
139
154
  footerLayout === 'split' ? 'w-full' : footerAlignClasses[footerAlign],
140
155
  )}
141
156
  >
@@ -159,4 +174,6 @@ export function Modal({
159
174
  </div>
160
175
  </div>
161
176
  );
177
+
178
+ return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
162
179
  }
@@ -23,7 +23,7 @@ const defaultLabels: PaginationLabels = {
23
23
  };
24
24
 
25
25
  const paginationControlClassName =
26
- 'inline-flex size-7 items-center justify-center rounded-lg border border-storm-gray-50 p-1 text-xs font-medium leading-none text-storm-gray-500 transition-colors';
26
+ 'inline-flex size-7 items-center justify-center rounded-lg border border-storm-gray-50 p-1 typography-12 font-medium text-storm-gray-500 transition-colors';
27
27
 
28
28
  const paginationHoverClassName = 'cursor-pointer hover:bg-storm-gray-0';
29
29
 
@@ -78,7 +78,7 @@ export function Pagination({
78
78
  className,
79
79
  )}
80
80
  >
81
- <p className="text-xs font-medium leading-none text-storm-gray-200">
81
+ <p className="typography-12 font-medium text-storm-gray-200">
82
82
  {mergedLabels.showing({ start, end, total })}
83
83
  </p>
84
84
 
@@ -16,12 +16,12 @@ export type TableActionButtonProps = {
16
16
  'onClick' | 'title' | 'aria-expanded' | 'aria-haspopup' | 'aria-controls'
17
17
  >;
18
18
 
19
- const actionButtonClassName =
20
- 'inline-flex cursor-pointer items-center gap-1 rounded-lg border border-storm-gray-50 bg-white px-3 py-2 font-sans text-xs font-medium leading-none text-storm-gray-900 transition-colors hover:bg-storm-gray-0';
19
+ const inlineActionClassName =
20
+ 'inline-flex cursor-pointer items-center gap-1 rounded-lg border border-storm-gray-50 bg-white px-3 py-2 typography-12 font-medium text-storm-gray-900 transition-colors hover:bg-storm-gray-0';
21
21
 
22
22
  const variantClasses: Record<TableActionButtonVariant, string> = {
23
- approve: actionButtonClassName,
24
- reject: actionButtonClassName,
23
+ approve: inlineActionClassName,
24
+ reject: inlineActionClassName,
25
25
  menu: 'inline-flex size-7 cursor-pointer items-center justify-center rounded-lg text-storm-gray-500 transition-colors hover:bg-storm-gray-0 hover:text-storm-gray-900',
26
26
  };
27
27
 
@@ -172,7 +172,7 @@ export function TableActionsMenu({
172
172
  item.onSelect();
173
173
  }}
174
174
  className={cn(
175
- 'flex h-[51px] w-full cursor-pointer items-center gap-2.5 px-3 text-left font-sans text-sm font-medium leading-none text-storm-gray-900 transition-colors hover:bg-storm-gray-0',
175
+ 'flex h-[51px] w-full cursor-pointer items-center gap-2.5 px-3 text-left typography-14 font-medium text-storm-gray-900 transition-colors hover:bg-storm-gray-0',
176
176
  index > 0 && 'border-t border-storm-gray-50',
177
177
  item.disabled && 'cursor-not-allowed opacity-50',
178
178
  )}
@@ -12,7 +12,7 @@ export function TableIconText({ icon, children, className }: TableIconTextProps)
12
12
  return (
13
13
  <span
14
14
  className={cn(
15
- 'inline-flex items-center gap-2 font-sans text-sm font-medium leading-none tracking-normal text-storm-gray-900',
15
+ 'inline-flex items-center gap-2 typography-14 font-medium tracking-normal text-storm-gray-900',
16
16
  className,
17
17
  )}
18
18
  >
@@ -0,0 +1,106 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useEffect } from 'react';
3
+
4
+ import { WebLayoutCanvas } from '../layout/story-helpers';
5
+ import { toast } from '../toast';
6
+
7
+ import { Toaster } from './Toaster';
8
+
9
+ const toastCopy = {
10
+ title: 'Title',
11
+ description: 'Description',
12
+ };
13
+
14
+ const meta = {
15
+ title: 'Web Components/Toaster',
16
+ component: Toaster,
17
+ parameters: {
18
+ layout: 'fullscreen',
19
+ webLayout: true,
20
+ },
21
+ } satisfies Meta<typeof Toaster>;
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof meta>;
25
+
26
+ function ToastTriggerPanel() {
27
+ return (
28
+ <WebLayoutCanvas className="min-h-screen bg-storm-gray-100 p-6">
29
+ <div className="mx-auto flex w-full max-w-md flex-col gap-3 rounded-2xl border border-storm-gray-50 bg-white p-6 shadow-sm">
30
+ <h2 className="typography-title-sm text-slate-blue">Toast preview</h2>
31
+ <p className="typography-body text-storm-gray-400">
32
+ Click a button to show a toast in the bottom-right corner.
33
+ </p>
34
+ <div className="flex flex-col gap-2">
35
+ <button
36
+ type="button"
37
+ className="rounded-xl border border-storm-gray-50 px-4 py-2 text-left typography-14 font-medium text-slate-blue transition-colors hover:bg-storm-gray-0"
38
+ onClick={() => toast.success(toastCopy.title, { description: toastCopy.description })}
39
+ >
40
+ Success
41
+ </button>
42
+ <button
43
+ type="button"
44
+ className="rounded-xl border border-storm-gray-50 px-4 py-2 text-left typography-14 font-medium text-slate-blue transition-colors hover:bg-storm-gray-0"
45
+ onClick={() => toast.error(toastCopy.title, { description: toastCopy.description })}
46
+ >
47
+ Error
48
+ </button>
49
+ <button
50
+ type="button"
51
+ className="rounded-xl border border-storm-gray-50 px-4 py-2 text-left typography-14 font-medium text-slate-blue transition-colors hover:bg-storm-gray-0"
52
+ onClick={() => toast.warning(toastCopy.title, { description: toastCopy.description })}
53
+ >
54
+ Warning
55
+ </button>
56
+ <button
57
+ type="button"
58
+ className="rounded-xl border border-storm-gray-50 px-4 py-2 text-left typography-14 font-medium text-slate-blue transition-colors hover:bg-storm-gray-0"
59
+ onClick={() => toast.info(toastCopy.title, { description: toastCopy.description })}
60
+ >
61
+ Info
62
+ </button>
63
+ </div>
64
+ </div>
65
+ <Toaster />
66
+ </WebLayoutCanvas>
67
+ );
68
+ }
69
+
70
+ function AllTypesStory() {
71
+ useEffect(() => {
72
+ toast.success(toastCopy.title, { description: toastCopy.description });
73
+ toast.error(toastCopy.title, { description: toastCopy.description });
74
+ toast.warning(toastCopy.title, { description: toastCopy.description });
75
+ toast.info(toastCopy.title, { description: toastCopy.description });
76
+ }, []);
77
+
78
+ return (
79
+ <WebLayoutCanvas className="min-h-screen bg-storm-gray-100">
80
+ <Toaster />
81
+ </WebLayoutCanvas>
82
+ );
83
+ }
84
+
85
+ export const Interactive: Story = {
86
+ render: () => <ToastTriggerPanel />,
87
+ };
88
+
89
+ export const AllTypes: Story = {
90
+ render: () => <AllTypesStory />,
91
+ };
92
+
93
+ export const TitleOnly: Story = {
94
+ render: () => (
95
+ <WebLayoutCanvas className="min-h-screen bg-storm-gray-100 p-6">
96
+ <button
97
+ type="button"
98
+ className="rounded-xl border border-storm-gray-50 bg-white px-4 py-2 typography-14 font-medium text-slate-blue"
99
+ onClick={() => toast.success('Request approved.')}
100
+ >
101
+ Show title-only toast
102
+ </button>
103
+ <Toaster />
104
+ </WebLayoutCanvas>
105
+ ),
106
+ };
@@ -0,0 +1,92 @@
1
+ import type { ReactNode } from 'react';
2
+ import { Toaster as SonnerToaster, type ToasterProps } from 'sonner';
3
+
4
+ import {
5
+ ToastCloseIcon,
6
+ ToastErrorIcon,
7
+ ToastInfoIcon,
8
+ ToastSuccessIcon,
9
+ ToastWarningIcon,
10
+ } from '../icons/ToastIcons';
11
+
12
+ import 'sonner/dist/styles.css';
13
+ import './sonner-toast.css';
14
+
15
+ function ToastIconBadge({
16
+ tone,
17
+ children,
18
+ }: {
19
+ tone: 'success' | 'error' | 'warning' | 'info' | 'default';
20
+ children: ReactNode;
21
+ }) {
22
+ return (
23
+ <div className={`homepad-toast__icon-badge homepad-toast__icon-badge--${tone}`}>
24
+ {children}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ const homepadToastIcons = {
30
+ success: (
31
+ <ToastIconBadge tone="success">
32
+ <ToastSuccessIcon />
33
+ </ToastIconBadge>
34
+ ),
35
+ error: (
36
+ <ToastIconBadge tone="error">
37
+ <ToastErrorIcon />
38
+ </ToastIconBadge>
39
+ ),
40
+ warning: (
41
+ <ToastIconBadge tone="warning">
42
+ <ToastWarningIcon />
43
+ </ToastIconBadge>
44
+ ),
45
+ info: (
46
+ <ToastIconBadge tone="info">
47
+ <ToastInfoIcon />
48
+ </ToastIconBadge>
49
+ ),
50
+ close: <ToastCloseIcon />,
51
+ };
52
+
53
+ export type HomepadToasterProps = Omit<ToasterProps, 'richColors'>;
54
+
55
+ export function Toaster({
56
+ className,
57
+ position = 'bottom-right',
58
+ theme = 'light',
59
+ closeButton = true,
60
+ gap = 12,
61
+ offset = 20,
62
+ toastOptions,
63
+ icons,
64
+ ...props
65
+ }: HomepadToasterProps) {
66
+ return (
67
+ <SonnerToaster
68
+ className={['homepad-toaster', className].filter(Boolean).join(' ')}
69
+ theme={theme}
70
+ position={position}
71
+ closeButton={closeButton}
72
+ gap={gap}
73
+ offset={offset}
74
+ mobileOffset={8}
75
+ icons={{ ...homepadToastIcons, ...icons }}
76
+ toastOptions={{
77
+ duration: 4000,
78
+ classNames: {
79
+ toast: 'homepad-toast',
80
+ title: 'homepad-toast__title',
81
+ description: 'homepad-toast__description',
82
+ content: 'homepad-toast__content',
83
+ icon: 'homepad-toast__icon',
84
+ closeButton: 'homepad-toast__close',
85
+ ...toastOptions?.classNames,
86
+ },
87
+ ...toastOptions,
88
+ }}
89
+ {...props}
90
+ />
91
+ );
92
+ }
@@ -0,0 +1,6 @@
1
+ import { Toaster } from './Toaster';
2
+
3
+ /** Global toast outlet styled to match the HomePad design system. */
4
+ export function ToasterProvider() {
5
+ return <Toaster />;
6
+ }
@@ -0,0 +1,41 @@
1
+ .drawer-scroll-area {
2
+ scrollbar-color: var(--color-storm-gray-200, #d5d9dd) transparent;
3
+ scrollbar-width: thin;
4
+ }
5
+
6
+ .drawer-scroll-area::-webkit-scrollbar {
7
+ width: 8px;
8
+ -webkit-appearance: none;
9
+ appearance: none;
10
+ }
11
+
12
+ .drawer-scroll-area::-webkit-scrollbar-button,
13
+ .drawer-scroll-area::-webkit-scrollbar-button:single-button,
14
+ .drawer-scroll-area::-webkit-scrollbar-button:double-button,
15
+ .drawer-scroll-area::-webkit-scrollbar-button:vertical:decrement,
16
+ .drawer-scroll-area::-webkit-scrollbar-button:vertical:increment,
17
+ .drawer-scroll-area::-webkit-scrollbar-button:vertical:start:decrement,
18
+ .drawer-scroll-area::-webkit-scrollbar-button:vertical:end:increment,
19
+ .drawer-scroll-area::-webkit-scrollbar-button:start:decrement,
20
+ .drawer-scroll-area::-webkit-scrollbar-button:end:increment,
21
+ .drawer-scroll-area::-webkit-scrollbar-button:decrement,
22
+ .drawer-scroll-area::-webkit-scrollbar-button:increment {
23
+ display: none;
24
+ width: 0;
25
+ height: 0;
26
+ }
27
+
28
+ .drawer-scroll-area::-webkit-scrollbar-track {
29
+ background: transparent;
30
+ }
31
+
32
+ .drawer-scroll-area::-webkit-scrollbar-thumb {
33
+ background-color: var(--color-storm-gray-200, #d5d9dd);
34
+ border-radius: 9999px;
35
+ }
36
+
37
+ @media (max-width: 767px) {
38
+ .drawer-scroll-area::-webkit-scrollbar {
39
+ width: 4px;
40
+ }
41
+ }