@scripso-homepad/ui 0.4.9 → 0.4.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scripso-homepad/ui",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "type": "module",
5
5
  "description": "Cross-platform UI components for Homepad (React Web + React Native)",
6
6
  "license": "MIT",
@@ -29,7 +29,8 @@
29
29
  "import": "./src/web/index.ts",
30
30
  "require": "./dist/web/index.cjs",
31
31
  "default": "./src/web/index.ts"
32
- }
32
+ },
33
+ "./web/table-scroll.css": "./src/web/components/table-scroll.css"
33
34
  },
34
35
  "files": [
35
36
  "dist",
@@ -51,7 +52,8 @@
51
52
  "react-native": ">=0.74",
52
53
  "react-native-svg": ">=13",
53
54
  "react-native-web": ">=0.19",
54
- "react-router-dom": ">=6"
55
+ "react-router-dom": ">=6",
56
+ "sonner": ">=2.0.0"
55
57
  },
56
58
  "peerDependenciesMeta": {
57
59
  "react-native-svg": {
@@ -65,6 +67,9 @@
65
67
  },
66
68
  "lucide-react": {
67
69
  "optional": true
70
+ },
71
+ "sonner": {
72
+ "optional": true
68
73
  }
69
74
  },
70
75
  "dependencies": {
@@ -90,6 +95,7 @@
90
95
  "react-native-svg": "^15.11.2",
91
96
  "react-native-web": "^0.19.13",
92
97
  "react-router-dom": "^7.9.1",
98
+ "sonner": "^2.0.7",
93
99
  "storybook": "^8.4.7",
94
100
  "tailwindcss": "^4.1.13",
95
101
  "tsup": "^8.3.5",
@@ -51,6 +51,8 @@ type DatePickerFieldProps = {
51
51
  containerStyle?: StyleProp<ViewStyle>;
52
52
  className?: string;
53
53
  fieldClassName?: string;
54
+ fieldStyle?: StyleProp<ViewStyle>;
55
+ fieldOutlineStyle?: StyleProp<ViewStyle>;
54
56
  panelClassName?: string;
55
57
  labelClassName?: string;
56
58
  errorClassName?: string;
@@ -95,6 +97,8 @@ export function DatePicker(props: DatePickerProps) {
95
97
  containerStyle,
96
98
  className,
97
99
  fieldClassName,
100
+ fieldStyle,
101
+ fieldOutlineStyle,
98
102
  panelClassName,
99
103
  labelClassName,
100
104
  errorClassName,
@@ -224,22 +228,39 @@ export function DatePicker(props: DatePickerProps) {
224
228
  }
225
229
  }
226
230
 
231
+ const isFluidField = Boolean(fieldStyle) || Boolean(fieldOutlineStyle);
232
+
227
233
  return (
228
- <View ref={wrapperRef} style={[styles.wrapper, containerStyle]}>
234
+ <View
235
+ ref={wrapperRef}
236
+ style={[styles.wrapper, isFluidField && styles.wrapperFluid, containerStyle]}
237
+ >
229
238
  {label ? (
230
239
  <Label disabled={disabled} style={styles.label} className={labelClassName}>
231
240
  {label}
232
241
  </Label>
233
242
  ) : null}
234
243
 
235
- <View style={[fieldStyles.outline, styles.fieldOutline]}>
244
+ <View
245
+ style={[
246
+ fieldStyles.outline,
247
+ styles.fieldOutline,
248
+ isFluidField && styles.fieldOutlineFluid,
249
+ fieldOutlineStyle,
250
+ ]}
251
+ >
236
252
  <Pressable
237
253
  ref={triggerRef}
238
254
  accessibilityRole="button"
239
255
  accessibilityState={{ disabled, expanded: open }}
240
256
  disabled={disabled}
241
257
  onPress={openPicker}
242
- style={[calendarFieldMetrics.container, fieldStyles.container]}
258
+ style={[
259
+ calendarFieldMetrics.container,
260
+ isFluidField && styles.fieldContainerFluid,
261
+ fieldStyles.container,
262
+ fieldStyle,
263
+ ]}
243
264
  >
244
265
  <Text
245
266
  style={[calendarFieldMetrics.value, valueTextStyle]}
@@ -327,11 +348,24 @@ const styles = StyleSheet.create({
327
348
  maxWidth: "100%",
328
349
  gap: SELECT_LABEL_GAP,
329
350
  },
351
+ wrapperFluid: {
352
+ width: "100%",
353
+ },
330
354
  fieldOutline: {
331
355
  width: CALENDAR_FIELD_WIDTH,
332
356
  maxWidth: "100%",
333
357
  alignSelf: "flex-start",
334
358
  },
359
+ fieldOutlineFluid: {
360
+ width: "100%",
361
+ alignSelf: "stretch",
362
+ },
363
+ fieldContainerFluid: {
364
+ width: "100%",
365
+ minWidth: 0,
366
+ maxWidth: "100%",
367
+ alignSelf: "stretch",
368
+ },
335
369
  label: {
336
370
  ...calendarLabelTypography,
337
371
  },
@@ -0,0 +1,234 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { fn } from '@storybook/test';
3
+ import { useState } from 'react';
4
+
5
+ import { Button } from '../../components/Button';
6
+ import { WebLayoutCanvas } from '../layout/story-helpers';
7
+
8
+ import { Drawer } from './Drawer';
9
+ import { HistoryTimeline } from './HistoryTimeline';
10
+ import { StatusBadge } from './StatusBadge';
11
+
12
+ const historyEvents = [
13
+ {
14
+ id: 'received',
15
+ title: 'Հայտը ստացվել է',
16
+ timestamp: '24.06.2026, 14:20',
17
+ },
18
+ {
19
+ id: 'approved',
20
+ title: 'Հայտը հաստատվել է, սպասում է վճարման',
21
+ timestamp: '24.06.2026, 15:05',
22
+ },
23
+ ];
24
+
25
+ const meta = {
26
+ title: 'Web Components/Drawer',
27
+ component: Drawer,
28
+ parameters: {
29
+ layout: 'fullscreen',
30
+ webLayout: true,
31
+ },
32
+ args: {
33
+ open: true,
34
+ title: 'Հայտ #00042',
35
+ subtitle: 'Ստացվել է 24.06.2026, 14:20',
36
+ closeAriaLabel: 'Փակել',
37
+ onClose: fn(),
38
+ },
39
+ } satisfies Meta<typeof Drawer>;
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof meta>;
43
+
44
+ function DetailField({ label, value }: { label: string; value: string }) {
45
+ return (
46
+ <div className="flex items-center justify-between gap-4">
47
+ <span className="min-w-0 flex-1 font-sans text-sm font-medium leading-[19px] tracking-normal text-storm-gray-500">
48
+ {label}
49
+ </span>
50
+ <span className="shrink-0 text-right font-sans text-sm font-medium leading-[14px] tracking-normal text-storm-gray-900">
51
+ {value}
52
+ </span>
53
+ </div>
54
+ );
55
+ }
56
+
57
+ function DetailsSection({ title, children }: { title: string; children: React.ReactNode }) {
58
+ return (
59
+ <section className="flex flex-col gap-3">
60
+ <h3 className="font-sans text-xs font-semibold uppercase leading-4 tracking-normal text-storm-gray-200">
61
+ {title}
62
+ </h3>
63
+ <div className="flex flex-col gap-4">{children}</div>
64
+ </section>
65
+ );
66
+ }
67
+
68
+ function RequestDetailsBody() {
69
+ return (
70
+ <>
71
+ <div className="flex flex-col gap-8 pb-6">
72
+ <div className="flex items-center justify-between gap-4">
73
+ <span className="font-sans text-sm font-medium leading-[19px] tracking-normal text-storm-gray-500">
74
+ Կարգավիճակ
75
+ </span>
76
+ <StatusBadge tone="warning">Սպասում է վճարման</StatusBadge>
77
+ </div>
78
+
79
+ <DetailsSection title="Կազմակերպության տվյալներ">
80
+ <DetailField label="Հասցե" value="Արաբկիր 12" />
81
+ <DetailField label="Կազմակերպություն" value="Manuk's org" />
82
+ <DetailField label="Սակագնային պլան" value="150+ Apartments" />
83
+ <DetailField label="Անհատական գին" value="10 ֏" />
84
+ <DetailField label="Բնակարանների առավելագույն քանակ" value="120" />
85
+ </DetailsSection>
86
+
87
+ <DetailsSection title="Կոնտակտային անձ">
88
+ <DetailField label="Անուն / Ազգանուն" value="Manuk Grigoryan" />
89
+ <DetailField label="Էլ. փոստ" value="manuk@example.com" />
90
+ <DetailField label="Հեռախոսահամար" value="+374 99 000000" />
91
+ </DetailsSection>
92
+ </div>
93
+
94
+ <section className="-mx-6 flex flex-col justify-between border-t border-dashed border-storm-gray-50 px-6 pt-6 pb-0">
95
+ <div className="flex flex-col gap-3">
96
+ <h3 className="font-sans text-xs font-semibold uppercase leading-4 tracking-normal text-storm-gray-200">
97
+ Գործողությունների պատմություն
98
+ </h3>
99
+ <HistoryTimeline events={historyEvents} />
100
+ </div>
101
+ </section>
102
+ </>
103
+ );
104
+ }
105
+
106
+ const footerButton = (
107
+ <div className="w-full">
108
+ <Button
109
+ title="Կրկին ուղարկել վճարման հղումը"
110
+ onPress={fn()}
111
+ variant="primary"
112
+ className="btn shrink-0 justify-center!"
113
+ textClassName="text-center!"
114
+ style={{
115
+ width: '100%',
116
+ height: 52,
117
+ minHeight: 52,
118
+ maxHeight: 52,
119
+ paddingTop: 0,
120
+ paddingBottom: 0,
121
+ }}
122
+ />
123
+ </div>
124
+ );
125
+
126
+ function DrawerStoryShell({
127
+ open,
128
+ onClose,
129
+ footer,
130
+ title,
131
+ subtitle,
132
+ }: {
133
+ open: boolean;
134
+ onClose: () => void;
135
+ footer?: React.ReactNode;
136
+ title?: string;
137
+ subtitle?: string;
138
+ }) {
139
+ return (
140
+ <WebLayoutCanvas className="min-h-screen bg-storm-gray-100">
141
+ <Drawer
142
+ open={open}
143
+ title={title ?? 'Հայտ #00042'}
144
+ subtitle={subtitle ?? 'Ստացվել է 24.06.2026, 14:20'}
145
+ closeAriaLabel="Փակել"
146
+ onClose={onClose}
147
+ footer={footer}
148
+ >
149
+ <RequestDetailsBody />
150
+ </Drawer>
151
+ </WebLayoutCanvas>
152
+ );
153
+ }
154
+
155
+ export const Default: Story = {
156
+ args: {
157
+ children: null,
158
+ },
159
+ render: (args) => (
160
+ <DrawerStoryShell open={args.open} onClose={args.onClose} title={args.title} subtitle={args.subtitle} />
161
+ ),
162
+ };
163
+
164
+ export const WithFooter: Story = {
165
+ args: {
166
+ children: null,
167
+ },
168
+ render: (args) => (
169
+ <DrawerStoryShell
170
+ open={args.open}
171
+ onClose={args.onClose}
172
+ title={args.title}
173
+ subtitle={args.subtitle}
174
+ footer={footerButton}
175
+ />
176
+ ),
177
+ };
178
+
179
+ export const WithoutSubtitle: Story = {
180
+ args: {
181
+ children: null,
182
+ subtitle: undefined,
183
+ title: 'Կարգավորումներ',
184
+ },
185
+ render: (args) => (
186
+ <WebLayoutCanvas className="min-h-screen bg-storm-gray-100">
187
+ <Drawer
188
+ open={args.open}
189
+ title={args.title}
190
+ closeAriaLabel="Փակել"
191
+ onClose={args.onClose}
192
+ >
193
+ <p className="font-sans text-sm leading-[19px] text-storm-gray-500">
194
+ Drawer content without a subtitle line in the header.
195
+ </p>
196
+ </Drawer>
197
+ </WebLayoutCanvas>
198
+ ),
199
+ };
200
+
201
+ function InteractiveDrawerStory() {
202
+ const [open, setOpen] = useState(false);
203
+
204
+ return (
205
+ <WebLayoutCanvas className="min-h-screen bg-storm-gray-100 p-6">
206
+ <button
207
+ type="button"
208
+ className="rounded-xl border border-storm-gray-50 bg-white px-4 py-2 text-sm font-medium text-slate-blue"
209
+ onClick={() => setOpen(true)}
210
+ >
211
+ Բացել drawer-ը
212
+ </button>
213
+
214
+ <Drawer
215
+ open={open}
216
+ title="Հայտ #00042"
217
+ subtitle="Ստացվել է 24.06.2026, 14:20"
218
+ closeAriaLabel="Փակել"
219
+ onClose={() => setOpen(false)}
220
+ footer={footerButton}
221
+ >
222
+ <RequestDetailsBody />
223
+ </Drawer>
224
+ </WebLayoutCanvas>
225
+ );
226
+ }
227
+
228
+ export const Interactive: Story = {
229
+ args: {
230
+ children: null,
231
+ open: false,
232
+ },
233
+ render: () => <InteractiveDrawerStory />,
234
+ };
@@ -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="font-sans text-[18px] font-bold leading-6 tracking-normal text-black"
131
+ >
132
+ {title}
133
+ </h2>
134
+ {subtitle ? (
135
+ <p
136
+ id="homepad-drawer-subtitle"
137
+ className="font-sans text-sm font-normal leading-[19px] 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="font-sans text-sm font-medium leading-[19px] tracking-normal text-slate-blue">
35
+ {event.title}
36
+ </p>
37
+ <p className="font-sans text-xs font-normal leading-4 tracking-normal text-storm-gray-300">
38
+ {event.timestamp}
39
+ </p>
40
+ </div>
41
+ </li>
42
+ );
43
+ })}
44
+ </ol>
45
+ );
46
+ }
@@ -77,8 +77,8 @@ export function Modal({
77
77
 
78
78
  const sharedButtonClassName = cn(
79
79
  'btn h-13 justify-center!',
80
- footerLayout === 'split' && 'min-w-0 flex-1',
81
- buttonClassName,
80
+ footerLayout === 'split' && 'min-w-0 flex-1 basis-0',
81
+ footerLayout !== 'split' && buttonClassName,
82
82
  );
83
83
 
84
84
  return (
@@ -116,14 +116,14 @@ export function Modal({
116
116
  <div className="flex min-w-0 flex-col gap-2">
117
117
  <h2
118
118
  id="homepad-modal-title"
119
- className="font-sans text-lg font-bold leading-none tracking-normal text-black"
119
+ className="font-sans text-lg font-bold leading-6 tracking-normal text-black"
120
120
  >
121
121
  {title}
122
122
  </h2>
123
123
  {subtitle ? (
124
124
  <p
125
125
  id="homepad-modal-subtitle"
126
- className="font-sans text-xs font-normal leading-none tracking-normal text-storm-gray-400"
126
+ className="font-sans text-xs font-normal leading-4 tracking-normal text-storm-gray-400"
127
127
  >
128
128
  {subtitle}
129
129
  </p>
@@ -135,7 +135,7 @@ export function Modal({
135
135
 
136
136
  <footer
137
137
  className={cn(
138
- 'flex gap-4',
138
+ 'flex shrink-0 gap-4',
139
139
  footerLayout === 'split' ? 'w-full' : footerAlignClasses[footerAlign],
140
140
  )}
141
141
  >
@@ -16,12 +16,12 @@ export type TableActionButtonProps = {
16
16
  'onClick' | 'title' | 'aria-expanded' | 'aria-haspopup' | 'aria-controls'
17
17
  >;
18
18
 
19
- const actionButtonClassName =
19
+ const inlineActionClassName =
20
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';
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