@scripso-homepad/ui 0.4.41 → 0.4.42

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.41",
3
+ "version": "0.4.42",
4
4
  "type": "module",
5
5
  "description": "Cross-platform UI components for Homepad (React Web + React Native)",
6
6
  "license": "MIT",
@@ -143,7 +143,7 @@ export function Drawer({
143
143
  };
144
144
  }, [isPresent, onClose]);
145
145
 
146
- if (!isPresent) {
146
+ if (!open && !isPresent) {
147
147
  return null;
148
148
  }
149
149
 
@@ -181,9 +181,7 @@ export function Drawer({
181
181
  className,
182
182
  )}
183
183
  >
184
- <header
185
- className={cn('shrink-0 border-b border-storm-gray-50', headerClassName)}
186
- >
184
+ <header className={cn('shrink-0 border-b border-storm-gray-50', headerClassName)}>
187
185
  <div className="flex items-start justify-between pt-6 pr-6 pb-4 pl-6">
188
186
  <div className="min-w-0 flex flex-col gap-1">
189
187
  <h2
@@ -211,7 +209,10 @@ export function Drawer({
211
209
  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"
212
210
  onClick={onClose}
213
211
  >
214
- <X className="size-5" strokeWidth={1.75} />
212
+ <X
213
+ className="size-5"
214
+ strokeWidth={1.75}
215
+ />
215
216
  </button>
216
217
  </div>
217
218
 
@@ -5,6 +5,7 @@ import { Button } from '../../components/Button';
5
5
  import { useBackdropDismiss } from '../hooks/useBackdropDismiss';
6
6
  import { cn } from '../utils/cn';
7
7
  import './drawer-scroll.css';
8
+ import './modal-motion.css';
8
9
 
9
10
  export type ModalFooterAlign = 'left' | 'center' | 'right';
10
11
  export type ModalFooterLayout = 'inline' | 'split';
@@ -29,6 +30,7 @@ export type ModalProps = {
29
30
  closeOnBackdrop?: boolean;
30
31
  closeOnEscape?: boolean;
31
32
  containerClassName?: string;
33
+ containerDataOnboarding?: string;
32
34
  contentClassName?: string;
33
35
  buttonClassName?: string;
34
36
  };
@@ -59,6 +61,7 @@ export function Modal({
59
61
  closeOnBackdrop = true,
60
62
  closeOnEscape = true,
61
63
  containerClassName,
64
+ containerDataOnboarding,
62
65
  contentClassName,
63
66
  buttonClassName,
64
67
  }: ModalProps) {
@@ -90,14 +93,11 @@ export function Modal({
90
93
  return null;
91
94
  }
92
95
 
93
- const sharedButtonClassName = cn(
94
- 'btn h-13 w-full! min-w-0! justify-center!',
95
- buttonClassName,
96
- );
96
+ const sharedButtonClassName = cn('btn h-13 w-full! min-w-0! justify-center!', buttonClassName);
97
97
 
98
98
  const modal = (
99
99
  <div
100
- className="fixed inset-0 z-[100] flex items-center justify-center p-4 backdrop-blur-[12px] bg-storm-gray-850/40"
100
+ className="homepad-modal-in fixed inset-0 z-[100] flex items-center justify-center bg-storm-gray-850/40 p-4 backdrop-blur-[12px]"
101
101
  onPointerDown={backdropDismiss.onPointerDown}
102
102
  onClick={backdropDismiss.onClick}
103
103
  role="presentation"
@@ -107,6 +107,7 @@ export function Modal({
107
107
  aria-modal="true"
108
108
  aria-labelledby="homepad-modal-title"
109
109
  {...(subtitle ? { 'aria-describedby': 'homepad-modal-subtitle' } : {})}
110
+ {...(containerDataOnboarding ? { 'data-onboarding': containerDataOnboarding } : {})}
110
111
  className={cn(
111
112
  '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',
112
113
  containerClassName,
@@ -146,7 +147,14 @@ export function Modal({
146
147
  </div>
147
148
  </header>
148
149
 
149
- <div className={cn('drawer-scroll-area min-h-0 min-w-0 flex-1 overflow-y-auto', contentClassName)}>{children}</div>
150
+ <div
151
+ className={cn(
152
+ 'drawer-scroll-area min-h-0 min-w-0 flex-1 overflow-y-auto',
153
+ contentClassName,
154
+ )}
155
+ >
156
+ {children}
157
+ </div>
150
158
 
151
159
  <footer
152
160
  className={cn(
@@ -1,21 +1,29 @@
1
- import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
2
- import type { ReactNode } from 'react';
3
- import { createPortal } from 'react-dom';
4
-
5
- import { useOnClickOutside } from '../hooks/useOnClickOutside';
6
- import { TableMenuCheckIcon } from '../icons/TableMenuCheckIcon';
7
- import { TableMenuCloseIcon } from '../icons/TableMenuCloseIcon';
8
- import { TableMoreIcon } from '../icons/TableMoreIcon';
9
- import { cn } from '../utils/cn';
10
- import { TableActionButton } from './TableActionButton';
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import type { ReactNode } from "react";
10
+ import { createPortal } from "react-dom";
11
+
12
+ import { useOnClickOutside } from "../hooks/useOnClickOutside";
13
+ import { TableMenuCheckIcon } from "../icons/TableMenuCheckIcon";
14
+ import { TableMenuCloseIcon } from "../icons/TableMenuCloseIcon";
15
+ import { TableMoreIcon } from "../icons/TableMoreIcon";
16
+ import { cn } from "../utils/cn";
17
+ import { TableActionButton } from "./TableActionButton";
11
18
 
12
19
  const MENU_WIDTH_PX = 231;
13
20
  const MENU_OFFSET_PX = 4;
14
21
  const MENU_VIEWPORT_MARGIN_PX = 8;
15
22
 
16
- const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
23
+ const useIsomorphicLayoutEffect =
24
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
17
25
 
18
- export type TableActionsMenuItemTone = 'default' | 'success' | 'danger';
26
+ export type TableActionsMenuItemTone = "default" | "success" | "danger";
19
27
 
20
28
  export type TableActionsMenuItem = {
21
29
  id: string;
@@ -37,6 +45,10 @@ export type TableActionsMenuProps = {
37
45
  menuWidth?: number;
38
46
  /** Size the menu to its content width instead of a fixed pixel width. Defaults to true so labels stay on one line. */
39
47
  menuFitContent?: boolean;
48
+
49
+ /** Keep the menu open while a page tour is explaining this row. */
50
+ forceOpen?: boolean;
51
+ menuOnboardingId?: string;
40
52
  };
41
53
 
42
54
  type MenuPosition = {
@@ -45,12 +57,15 @@ type MenuPosition = {
45
57
  };
46
58
 
47
59
  function clampMenuLeft(left: number, menuWidth: number): number {
48
- if (typeof window === 'undefined') {
60
+ if (typeof window === "undefined") {
49
61
  return left;
50
62
  }
51
63
 
52
64
  const minLeft = MENU_VIEWPORT_MARGIN_PX;
53
- const maxLeft = Math.max(minLeft, window.innerWidth - menuWidth - MENU_VIEWPORT_MARGIN_PX);
65
+ const maxLeft = Math.max(
66
+ minLeft,
67
+ window.innerWidth - menuWidth - MENU_VIEWPORT_MARGIN_PX,
68
+ );
54
69
 
55
70
  return Math.min(Math.max(left, minLeft), maxLeft);
56
71
  }
@@ -61,9 +76,9 @@ function resolveMenuItemIcon(item: TableActionsMenuItem): ReactNode | null {
61
76
  }
62
77
 
63
78
  switch (item.tone) {
64
- case 'success':
79
+ case "success":
65
80
  return <TableMenuCheckIcon />;
66
- case 'danger':
81
+ case "danger":
67
82
  return <TableMenuCloseIcon />;
68
83
  default:
69
84
  return null;
@@ -78,6 +93,8 @@ export function TableActionsMenu({
78
93
  triggerIcon,
79
94
  menuWidth = MENU_WIDTH_PX,
80
95
  menuFitContent = true,
96
+ forceOpen,
97
+ menuOnboardingId,
81
98
  }: TableActionsMenuProps) {
82
99
  const [open, setOpen] = useState(false);
83
100
  const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
@@ -87,8 +104,20 @@ export function TableActionsMenu({
87
104
  const menuId = useId();
88
105
 
89
106
  const closeMenu = useCallback(() => {
107
+ if (forceOpen) {
108
+ return;
109
+ }
110
+
90
111
  setOpen(false);
91
- }, []);
112
+ }, [forceOpen]);
113
+
114
+ useEffect(() => {
115
+ if (forceOpen === undefined) {
116
+ return;
117
+ }
118
+
119
+ setOpen(forceOpen);
120
+ }, [forceOpen]);
92
121
 
93
122
  const updateMenuPosition = useCallback(() => {
94
123
  const trigger = triggerRef.current;
@@ -99,14 +128,19 @@ export function TableActionsMenu({
99
128
 
100
129
  const rect = trigger.getBoundingClientRect();
101
130
  const viewportMaxWidth =
102
- typeof window === 'undefined'
131
+ typeof window === "undefined"
103
132
  ? menuWidth
104
133
  : Math.max(menuWidth, window.innerWidth - MENU_VIEWPORT_MARGIN_PX * 2);
105
134
 
106
135
  const measuredWidth = menuFitContent
107
- ? (menuEl?.offsetWidth || menuEl?.getBoundingClientRect().width || menuWidth)
136
+ ? menuEl?.offsetWidth ||
137
+ menuEl?.getBoundingClientRect().width ||
138
+ menuWidth
108
139
  : menuWidth;
109
- const resolvedMenuWidth = Math.min(Math.max(measuredWidth, 1), viewportMaxWidth);
140
+ const resolvedMenuWidth = Math.min(
141
+ Math.max(measuredWidth, 1),
142
+ viewportMaxWidth,
143
+ );
110
144
 
111
145
  setMenuPosition({
112
146
  top: rect.bottom + MENU_OFFSET_PX,
@@ -132,27 +166,31 @@ export function TableActionsMenu({
132
166
  }
133
167
 
134
168
  const closeOnScroll = () => {
169
+ if (forceOpen) {
170
+ return;
171
+ }
172
+
135
173
  closeMenu();
136
174
  };
137
175
 
138
- window.addEventListener('resize', updateMenuPosition);
139
- window.addEventListener('scroll', closeOnScroll, true);
176
+ window.addEventListener("resize", updateMenuPosition);
177
+ window.addEventListener("scroll", closeOnScroll, true);
140
178
 
141
179
  const closeOnEscape = (event: KeyboardEvent) => {
142
- if (event.key === 'Escape') {
180
+ if (event.key === "Escape") {
143
181
  closeMenu();
144
182
  triggerRef.current?.focus();
145
183
  }
146
184
  };
147
185
 
148
- document.addEventListener('keydown', closeOnEscape);
186
+ document.addEventListener("keydown", closeOnEscape);
149
187
 
150
188
  return () => {
151
- window.removeEventListener('resize', updateMenuPosition);
152
- window.removeEventListener('scroll', closeOnScroll, true);
153
- document.removeEventListener('keydown', closeOnEscape);
189
+ window.removeEventListener("resize", updateMenuPosition);
190
+ window.removeEventListener("scroll", closeOnScroll, true);
191
+ document.removeEventListener("keydown", closeOnEscape);
154
192
  };
155
- }, [closeMenu, open, updateMenuPosition]);
193
+ }, [closeMenu, forceOpen, open, updateMenuPosition]);
156
194
 
157
195
  if (items.length === 0) {
158
196
  return null;
@@ -164,17 +202,18 @@ export function TableActionsMenu({
164
202
  id={menuId}
165
203
  role="menu"
166
204
  style={{
167
- position: 'fixed',
205
+ position: "fixed",
168
206
  top: menuPosition?.top ?? 0,
169
207
  left: menuPosition?.left ?? 0,
170
208
  maxWidth: `calc(100vw - ${MENU_VIEWPORT_MARGIN_PX * 2}px)`,
171
- ...(menuFitContent ? { width: 'max-content' } : { width: menuWidth }),
209
+ ...(menuFitContent ? { width: "max-content" } : { width: menuWidth }),
172
210
  // Hide until we have a measured viewport-safe position.
173
- visibility: menuPosition != null ? 'visible' : 'hidden',
174
- pointerEvents: menuPosition != null ? 'auto' : 'none',
211
+ visibility: menuPosition != null ? "visible" : "hidden",
212
+ pointerEvents: menuPosition != null ? "auto" : "none",
175
213
  }}
214
+ {...(menuOnboardingId ? { "data-onboarding": menuOnboardingId } : {})}
176
215
  className={cn(
177
- 'z-50 overflow-hidden rounded-2xl border border-storm-gray-50 bg-white shadow-[0_4px_20px_0_rgba(0,0,0,0.05)]',
216
+ "z-50 overflow-hidden rounded-2xl border border-storm-gray-50 bg-white shadow-[0_4px_20px_0_rgba(0,0,0,0.05)]",
178
217
  menuClassName,
179
218
  )}
180
219
  >
@@ -196,25 +235,25 @@ export function TableActionsMenu({
196
235
  item.onSelect();
197
236
  }}
198
237
  className={cn(
199
- 'flex h-[51px] w-full cursor-pointer items-center gap-2.5 px-3 text-left typography-14 font-medium transition-colors hover:bg-storm-gray-0',
200
- index > 0 && 'border-t border-storm-gray-50',
201
- item.disabled && 'cursor-not-allowed opacity-50',
202
- item.tone === 'danger'
203
- ? 'text-[#AE0011]'
204
- : item.tone === 'success'
205
- ? 'text-green-600'
206
- : 'text-storm-gray-900',
238
+ "flex h-[51px] w-full cursor-pointer items-center gap-2.5 px-3 text-left typography-14 font-medium transition-colors hover:bg-storm-gray-0",
239
+ index > 0 && "border-t border-storm-gray-50",
240
+ item.disabled && "cursor-not-allowed opacity-50",
241
+ item.tone === "danger"
242
+ ? "text-[#AE0011]"
243
+ : item.tone === "success"
244
+ ? "text-green-600"
245
+ : "text-storm-gray-900",
207
246
  )}
208
247
  >
209
248
  {icon ? (
210
249
  <span
211
250
  className={cn(
212
- 'inline-flex size-5 shrink-0 items-center justify-center',
213
- item.tone === 'danger'
214
- ? 'text-[#AE0011]'
215
- : item.tone === 'success'
216
- ? 'text-green-600'
217
- : 'text-storm-gray-200',
251
+ "inline-flex size-5 shrink-0 items-center justify-center",
252
+ item.tone === "danger"
253
+ ? "text-[#AE0011]"
254
+ : item.tone === "success"
255
+ ? "text-green-600"
256
+ : "text-storm-gray-200",
218
257
  )}
219
258
  >
220
259
  {icon}
@@ -228,7 +267,7 @@ export function TableActionsMenu({
228
267
  ) : null;
229
268
 
230
269
  return (
231
- <div ref={rootRef} className={cn('relative', className)}>
270
+ <div ref={rootRef} className={cn("relative", className)}>
232
271
  <TableActionButton
233
272
  ref={triggerRef}
234
273
  variant="menu"
@@ -240,7 +279,9 @@ export function TableActionsMenu({
240
279
  aria-controls={menuId}
241
280
  />
242
281
 
243
- {typeof document !== 'undefined' && menu != null ? createPortal(menu, document.body) : null}
282
+ {typeof document !== "undefined" && menu != null
283
+ ? createPortal(menu, document.body)
284
+ : null}
244
285
  </div>
245
286
  );
246
287
  }
@@ -0,0 +1,19 @@
1
+ @keyframes homepad-modal-in {
2
+ from {
3
+ opacity: 0;
4
+ }
5
+
6
+ to {
7
+ opacity: 1;
8
+ }
9
+ }
10
+
11
+ .homepad-modal-in {
12
+ animation: homepad-modal-in 300ms ease;
13
+ }
14
+
15
+ @media (prefers-reduced-motion: reduce) {
16
+ .homepad-modal-in {
17
+ animation: none;
18
+ }
19
+ }
@@ -1,7 +1,7 @@
1
- import type { MouseEvent } from 'react';
1
+ import type { MouseEvent } from "react";
2
2
 
3
- import { cn } from '../utils/cn';
4
- import type { AdminNavItem } from './types';
3
+ import { cn } from "../utils/cn";
4
+ import type { AdminNavItem } from "./types";
5
5
 
6
6
  export type SidebarNavItemProps = {
7
7
  item: AdminNavItem;
@@ -10,7 +10,12 @@ export type SidebarNavItemProps = {
10
10
  onNavigate?: (item: AdminNavItem) => void;
11
11
  };
12
12
 
13
- export function SidebarNavItem({ item, isOpen, isActive, onNavigate }: SidebarNavItemProps) {
13
+ export function SidebarNavItem({
14
+ item,
15
+ isOpen,
16
+ isActive,
17
+ onNavigate,
18
+ }: SidebarNavItemProps) {
14
19
  if (item.hidden) {
15
20
  return null;
16
21
  }
@@ -30,31 +35,34 @@ export function SidebarNavItem({ item, isOpen, isActive, onNavigate }: SidebarNa
30
35
  <button
31
36
  type="button"
32
37
  onClick={handleClick}
33
- aria-current={isActive ? 'page' : undefined}
38
+ aria-current={isActive ? "page" : undefined}
39
+ data-onboarding={item.onboardingId}
34
40
  aria-disabled={item.disabled}
35
41
  disabled={item.disabled}
36
42
  tabIndex={item.disabled ? -1 : undefined}
37
43
  className={cn(
38
- 'relative flex items-center rounded-xl px-4 py-3 transition-[background-color,color,gap,padding] duration-300 ease-in-out',
39
- isOpen ? 'w-full gap-4' : 'w-full justify-center gap-0 px-3',
40
- item.disabled && 'pointer-events-none opacity-50',
44
+ "relative flex items-center rounded-xl px-4 py-3 transition-[background-color,color,gap,padding] duration-300 ease-in-out",
45
+ isOpen ? "w-full gap-4" : "w-full justify-center gap-0 px-3",
46
+ item.disabled && "pointer-events-none opacity-50",
41
47
  isActive
42
- ? 'bg-black/12 text-white shadow-none'
43
- : 'text-navy-100 shadow-none hover:bg-white/5',
48
+ ? "bg-black/12 text-white shadow-none"
49
+ : "text-navy-100 shadow-none hover:bg-white/5",
44
50
  )}
45
51
  >
46
52
  <span
47
53
  aria-hidden
48
54
  className={cn(
49
- 'absolute top-1.5 -left-1 h-8 w-2 rounded-full bg-white transition-[opacity,transform] duration-300 ease-in-out',
50
- isActive ? 'scale-100 opacity-100' : 'scale-75 opacity-0',
55
+ "absolute top-1.5 -left-1 h-8 w-2 rounded-full bg-white transition-[opacity,transform] duration-300 ease-in-out",
56
+ isActive ? "scale-100 opacity-100" : "scale-75 opacity-0",
51
57
  )}
52
58
  />
53
- <span className="flex size-5 shrink-0 items-center justify-center">{item.icon}</span>
59
+ <span className="flex size-5 shrink-0 items-center justify-center">
60
+ {item.icon}
61
+ </span>
54
62
  <span
55
63
  className={cn(
56
- 'overflow-hidden typography-14 font-semibold whitespace-nowrap transition-[max-width,opacity] duration-300 ease-in-out',
57
- isOpen ? 'max-w-[180px] opacity-100' : 'max-w-0 opacity-0',
64
+ "overflow-hidden typography-14 font-semibold whitespace-nowrap transition-[max-width,opacity] duration-300 ease-in-out",
65
+ isOpen ? "max-w-[180px] opacity-100" : "max-w-0 opacity-0",
58
66
  )}
59
67
  >
60
68
  {item.label}
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import type { ReactNode } from "react";
2
2
 
3
3
  export type AdminNavItem = {
4
4
  to: string;
@@ -8,6 +8,7 @@ export type AdminNavItem = {
8
8
  hidden?: boolean;
9
9
  disabled?: boolean;
10
10
  onClick?: () => void;
11
+ onboardingId?: string;
11
12
  };
12
13
 
13
14
  export type AdminSidebarUser = {