@scripso-homepad/ui 0.4.15 → 0.4.16

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.15",
3
+ "version": "0.4.16",
4
4
  "type": "module",
5
5
  "description": "Cross-platform UI components for Homepad (React Web + React Native)",
6
6
  "license": "MIT",
@@ -53,7 +53,6 @@
53
53
  "react-native": ">=0.74",
54
54
  "react-native-svg": ">=13",
55
55
  "react-native-web": ">=0.19",
56
- "react-router-dom": ">=6",
57
56
  "sonner": ">=2.0.0"
58
57
  },
59
58
  "peerDependenciesMeta": {
@@ -63,9 +62,6 @@
63
62
  "react-native-web": {
64
63
  "optional": true
65
64
  },
66
- "react-router-dom": {
67
- "optional": true
68
- },
69
65
  "lucide-react": {
70
66
  "optional": true
71
67
  },
@@ -95,7 +91,6 @@
95
91
  "react-native": "^0.76.5",
96
92
  "react-native-svg": "^15.11.2",
97
93
  "react-native-web": "^0.19.13",
98
- "react-router-dom": "^7.9.1",
99
94
  "sonner": "^2.0.7",
100
95
  "storybook": "^8.4.7",
101
96
  "tailwindcss": "^4.1.13",
@@ -69,6 +69,8 @@ const variantConfig: Record<ButtonVariant, VariantStyleSet> = {
69
69
 
70
70
  const sizeConfig = {
71
71
  lg: {
72
+ /** Matches Tailwind `h-13` so size stays stable before web classNames attach. */
73
+ minHeight: 52,
72
74
  borderRadius: 16,
73
75
  paddingTop: 8,
74
76
  paddingRight: 8,
@@ -82,6 +84,7 @@ const sizeConfig = {
82
84
  iconSize: 20,
83
85
  },
84
86
  sm: {
87
+ minHeight: 40,
85
88
  borderRadius: 12,
86
89
  paddingTop: 8,
87
90
  paddingRight: 8,
@@ -138,6 +141,7 @@ export function Button({
138
141
  const containerStyle = [
139
142
  styles.base,
140
143
  {
144
+ minHeight: metrics.minHeight,
141
145
  borderRadius: metrics.borderRadius,
142
146
  backgroundColor: preset.backgroundColor,
143
147
  paddingTop: metrics.paddingTop,
@@ -224,6 +224,7 @@ export function Input({
224
224
  const styles = StyleSheet.create({
225
225
  wrapper: {
226
226
  width: "100%",
227
+ minWidth: 0,
227
228
  gap: spacing.sm,
228
229
  },
229
230
  iconSlot: {
@@ -41,6 +41,10 @@ function resolveWebElement(ref: React.RefObject<unknown>): ClassListElement | nu
41
41
  /**
42
42
  * Applies CSS class names to the underlying DOM node on web.
43
43
  * Keeps TouchableOpacity/Text as the render path so default RN styles stay intact.
44
+ *
45
+ * Retries via rAF when the host node is not ready yet — otherwise classes like
46
+ * `h-13` / `w-full` would only land after a later re-render (e.g. typing in a form),
47
+ * which visibly changes button size.
44
48
  */
45
49
  export function useApplyWebClassName(
46
50
  ref: React.RefObject<unknown>,
@@ -50,14 +54,30 @@ export function useApplyWebClassName(
50
54
  useLayoutEffect(() => {
51
55
  if (!enabled || Platform.OS !== "web" || !className?.trim()) return;
52
56
 
53
- const element = resolveWebElement(ref);
54
- if (!element) return;
55
-
57
+ let cancelled = false;
58
+ let rafId = 0;
59
+ let appliedElement: ClassListElement | null = null;
56
60
  const classes = className.trim().split(/\s+/);
57
- element.classList.add(...classes);
61
+
62
+ const apply = () => {
63
+ if (cancelled) return;
64
+
65
+ const element = resolveWebElement(ref);
66
+ if (!element) {
67
+ rafId = requestAnimationFrame(apply);
68
+ return;
69
+ }
70
+
71
+ appliedElement = element;
72
+ element.classList.add(...classes);
73
+ };
74
+
75
+ apply();
58
76
 
59
77
  return () => {
60
- element.classList.remove(...classes);
78
+ cancelled = true;
79
+ if (rafId) cancelAnimationFrame(rafId);
80
+ appliedElement?.classList.remove(...classes);
61
81
  };
62
82
  }, [ref, className, enabled]);
63
83
  }
@@ -11,6 +11,8 @@ export type ConfirmModalProps = {
11
11
  confirmText: string;
12
12
  onCancel: () => void;
13
13
  onConfirm: () => void;
14
+ /** Optional centered icon above the title (e.g. trash for delete confirmations). */
15
+ icon?: ReactNode;
14
16
  cancelDisabled?: boolean;
15
17
  confirmDisabled?: boolean;
16
18
  closeOnBackdrop?: boolean;
@@ -26,6 +28,7 @@ export function ConfirmModal({
26
28
  confirmText,
27
29
  onCancel,
28
30
  onConfirm,
31
+ icon,
29
32
  cancelDisabled = false,
30
33
  confirmDisabled = false,
31
34
  closeOnBackdrop = true,
@@ -75,7 +78,12 @@ export function ConfirmModal({
75
78
  event.stopPropagation();
76
79
  }}
77
80
  >
78
- <header className="flex flex-col gap-3 text-center">
81
+ <header className="flex flex-col items-center gap-3 text-center">
82
+ {icon ? (
83
+ <div className="flex size-14 items-center justify-center rounded-2xl bg-storm-gray-50 text-storm-gray-500">
84
+ {icon}
85
+ </div>
86
+ ) : null}
79
87
  <h2
80
88
  id="homepad-confirm-modal-title"
81
89
  className="typography-title tracking-normal text-black"
@@ -95,7 +103,7 @@ export function ConfirmModal({
95
103
  title={cancelText}
96
104
  onPress={onCancel}
97
105
  variant="gray"
98
- size='sm'
106
+ size="sm"
99
107
  disabled={cancelDisabled}
100
108
  className={sharedButtonClassName}
101
109
  textClassName="text-center!"
@@ -104,7 +112,7 @@ export function ConfirmModal({
104
112
  title={confirmText}
105
113
  onPress={onConfirm}
106
114
  variant="primary"
107
- size='sm'
115
+ size="sm"
108
116
  disabled={confirmDisabled}
109
117
  className={sharedButtonClassName}
110
118
  textClassName="text-center!"
@@ -33,7 +33,7 @@ export type TableActionsMenuProps = {
33
33
  menuClassName?: string;
34
34
  /** Dropdown menu width in pixels. Defaults to 231. Ignored when menuFitContent is true. */
35
35
  menuWidth?: number;
36
- /** Size the menu to its content width instead of a fixed pixel width. */
36
+ /** Size the menu to its content width instead of a fixed pixel width. Defaults to true so labels stay on one line. */
37
37
  menuFitContent?: boolean;
38
38
  };
39
39
 
@@ -74,7 +74,7 @@ export function TableActionsMenu({
74
74
  className,
75
75
  menuClassName,
76
76
  menuWidth = MENU_WIDTH_PX,
77
- menuFitContent = false,
77
+ menuFitContent = true,
78
78
  }: TableActionsMenuProps) {
79
79
  const [open, setOpen] = useState(false);
80
80
  const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null);
@@ -89,14 +89,21 @@ export function TableActionsMenu({
89
89
 
90
90
  const updateMenuPosition = useCallback(() => {
91
91
  const trigger = triggerRef.current;
92
+ const menuEl = menuRef.current;
92
93
  if (!trigger) {
93
94
  return;
94
95
  }
95
96
 
96
97
  const rect = trigger.getBoundingClientRect();
97
- const resolvedMenuWidth = menuFitContent
98
- ? (menuRef.current?.getBoundingClientRect().width ?? menuWidth)
98
+ const viewportMaxWidth =
99
+ typeof window === 'undefined'
100
+ ? menuWidth
101
+ : Math.max(menuWidth, window.innerWidth - MENU_VIEWPORT_MARGIN_PX * 2);
102
+
103
+ const measuredWidth = menuFitContent
104
+ ? (menuEl?.offsetWidth || menuEl?.getBoundingClientRect().width || menuWidth)
99
105
  : menuWidth;
106
+ const resolvedMenuWidth = Math.min(Math.max(measuredWidth, 1), viewportMaxWidth);
100
107
 
101
108
  setMenuPosition({
102
109
  top: rect.bottom + MENU_OFFSET_PX,
@@ -106,6 +113,7 @@ export function TableActionsMenu({
106
113
 
107
114
  useOnClickOutside([rootRef, menuRef], closeMenu, open);
108
115
 
116
+ // Mount the menu when open (possibly off-screen), then measure and place it.
109
117
  useIsomorphicLayoutEffect(() => {
110
118
  if (!open) {
111
119
  setMenuPosition(null);
@@ -113,15 +121,7 @@ export function TableActionsMenu({
113
121
  }
114
122
 
115
123
  updateMenuPosition();
116
- }, [open, updateMenuPosition]);
117
-
118
- useIsomorphicLayoutEffect(() => {
119
- if (!open || !menuFitContent) {
120
- return;
121
- }
122
-
123
- updateMenuPosition();
124
- }, [items, menuFitContent, open, updateMenuPosition]);
124
+ }, [open, items, menuFitContent, updateMenuPosition]);
125
125
 
126
126
  useEffect(() => {
127
127
  if (!open) {
@@ -155,58 +155,60 @@ export function TableActionsMenu({
155
155
  return null;
156
156
  }
157
157
 
158
- const menu =
159
- open && menuPosition != null ? (
160
- <div
161
- ref={menuRef}
162
- id={menuId}
163
- role="menu"
164
- style={{
165
- position: 'fixed',
166
- top: menuPosition.top,
167
- left: menuPosition.left,
168
- ...(menuFitContent ? { width: 'max-content' } : { width: menuWidth }),
169
- }}
170
- className={cn(
171
- 'z-50 overflow-hidden rounded-2xl border border-storm-gray-50 bg-white shadow-[0_4px_20px_0_rgba(0,0,0,0.05)]',
172
- menuClassName,
173
- )}
174
- >
175
- {items.map((item, index) => {
176
- const icon = resolveMenuItemIcon(item);
177
-
178
- return (
179
- <button
180
- key={item.id}
181
- type="button"
182
- role="menuitem"
183
- disabled={item.disabled}
184
- onClick={() => {
185
- if (item.disabled) {
186
- return;
187
- }
188
-
189
- closeMenu();
190
- item.onSelect();
191
- }}
192
- className={cn(
193
- 'flex h-[51px] 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',
194
- menuFitContent ? 'w-auto' : 'w-full',
195
- index > 0 && 'border-t border-storm-gray-50',
196
- item.disabled && 'cursor-not-allowed opacity-50',
197
- )}
198
- >
199
- {icon ? (
200
- <span className="inline-flex size-5 shrink-0 items-center justify-center text-storm-gray-200">
201
- {icon}
202
- </span>
203
- ) : null}
204
- <span>{item.label}</span>
205
- </button>
206
- );
207
- })}
208
- </div>
209
- ) : null;
158
+ const menu = open ? (
159
+ <div
160
+ ref={menuRef}
161
+ id={menuId}
162
+ role="menu"
163
+ style={{
164
+ position: 'fixed',
165
+ top: menuPosition?.top ?? 0,
166
+ left: menuPosition?.left ?? 0,
167
+ maxWidth: `calc(100vw - ${MENU_VIEWPORT_MARGIN_PX * 2}px)`,
168
+ ...(menuFitContent ? { width: 'max-content' } : { width: menuWidth }),
169
+ // Hide until we have a measured viewport-safe position.
170
+ visibility: menuPosition != null ? 'visible' : 'hidden',
171
+ pointerEvents: menuPosition != null ? 'auto' : 'none',
172
+ }}
173
+ className={cn(
174
+ 'z-50 overflow-hidden rounded-2xl border border-storm-gray-50 bg-white shadow-[0_4px_20px_0_rgba(0,0,0,0.05)]',
175
+ menuClassName,
176
+ )}
177
+ >
178
+ {items.map((item, index) => {
179
+ const icon = resolveMenuItemIcon(item);
180
+
181
+ return (
182
+ <button
183
+ key={item.id}
184
+ type="button"
185
+ role="menuitem"
186
+ disabled={item.disabled}
187
+ onClick={() => {
188
+ if (item.disabled) {
189
+ return;
190
+ }
191
+
192
+ closeMenu();
193
+ item.onSelect();
194
+ }}
195
+ className={cn(
196
+ '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',
197
+ index > 0 && 'border-t border-storm-gray-50',
198
+ item.disabled && 'cursor-not-allowed opacity-50',
199
+ )}
200
+ >
201
+ {icon ? (
202
+ <span className="inline-flex size-5 shrink-0 items-center justify-center text-storm-gray-200">
203
+ {icon}
204
+ </span>
205
+ ) : null}
206
+ <span className="whitespace-nowrap">{item.label}</span>
207
+ </button>
208
+ );
209
+ })}
210
+ </div>
211
+ ) : null;
210
212
 
211
213
  return (
212
214
  <div ref={rootRef} className={cn('relative', className)}>
package/src/web/index.ts CHANGED
@@ -16,6 +16,8 @@ export type { SidebarMobileHeaderProps } from './layout/SidebarMobileHeader';
16
16
  export { SidebarNavItem } from './layout/SidebarNavItem';
17
17
  export type { SidebarNavItemProps } from './layout/SidebarNavItem';
18
18
 
19
+ export { isNavItemActive } from './layout/nav-active';
20
+
19
21
  export { SidebarUserCard } from './layout/SidebarUserCard';
20
22
  export type { SidebarUserCardProps } from './layout/SidebarUserCard';
21
23
 
@@ -39,6 +39,7 @@ function DashboardLayoutDemo() {
39
39
  branding: storyBranding,
40
40
  labels: storyLabels.sidebar,
41
41
  sidebarStorageKey: 'homepad.storybook.sidebar.open',
42
+ pathname: '/',
42
43
  user: storyUser,
43
44
  mobileOpen: mobileMenuOpen,
44
45
  onMobileClose: () => setMobileMenuOpen(false),
@@ -25,6 +25,7 @@ const meta = {
25
25
  branding: storyBranding,
26
26
  labels: storyLabels.sidebar,
27
27
  sidebarStorageKey: 'homepad.storybook.sidebar.open',
28
+ pathname: '/',
28
29
  user: storyUser,
29
30
  mobileOpen: true,
30
31
  onLogout: fn(),
@@ -1,9 +1,9 @@
1
1
  import { PanelLeftClose, PanelLeftOpen, X } from 'lucide-react';
2
2
  import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
3
- import { useLocation } from 'react-router-dom';
4
3
 
5
4
  import { useMediaQuery } from '../hooks/useMediaQuery';
6
5
  import { cn } from '../utils/cn';
6
+ import { isNavItemActive } from './nav-active';
7
7
  import { SidebarNavItem } from './SidebarNavItem';
8
8
  import { SidebarUserCard } from './SidebarUserCard';
9
9
  import type { AdminNavItem, AdminSidebarBranding, AdminSidebarLabels, AdminSidebarUser } from './types';
@@ -14,11 +14,20 @@ export type SidebarProps = {
14
14
  branding: AdminSidebarBranding;
15
15
  labels: AdminSidebarLabels;
16
16
  sidebarStorageKey: string;
17
+ /**
18
+ * Current location pathname from the host app router.
19
+ * Pass `useLocation().pathname` from the app that owns `BrowserRouter`.
20
+ */
21
+ pathname: string;
17
22
  user?: AdminSidebarUser;
18
23
  onLogout?: () => void;
19
24
  className?: string;
20
25
  mobileOpen: boolean;
21
26
  onMobileClose?: () => void;
27
+ /**
28
+ * Host-owned navigation. Call `navigate(item.to)` from the app router here.
29
+ * The sidebar never imports react-router, so this must perform the route change.
30
+ */
22
31
  onNavItemClick?: (item: AdminNavItem) => void;
23
32
  collapseIcon?: ReactNode;
24
33
  expandIcon?: ReactNode;
@@ -51,6 +60,7 @@ export function Sidebar({
51
60
  branding,
52
61
  labels,
53
62
  sidebarStorageKey,
63
+ pathname,
54
64
  user,
55
65
  onLogout,
56
66
  className,
@@ -64,7 +74,6 @@ export function Sidebar({
64
74
  collapsed,
65
75
  onCollapsedChange,
66
76
  }: SidebarProps) {
67
- const location = useLocation();
68
77
  const isDesktop = useMediaQuery('(min-width: 768px)');
69
78
  const [internalCollapsed, setInternalCollapsed] = useState(() =>
70
79
  readInitialCollapsedState(sidebarStorageKey, initialCollapsed),
@@ -110,19 +119,19 @@ export function Sidebar({
110
119
  [isDesktop, onMobileClose, onNavItemClick],
111
120
  );
112
121
 
113
- const pathnameRef = useRef(location.pathname);
122
+ const pathnameRef = useRef(pathname);
114
123
 
115
124
  useEffect(() => {
116
- if (pathnameRef.current === location.pathname) {
125
+ if (pathnameRef.current === pathname) {
117
126
  return;
118
127
  }
119
128
 
120
- pathnameRef.current = location.pathname;
129
+ pathnameRef.current = pathname;
121
130
 
122
131
  if (!isDesktop) {
123
132
  onMobileClose?.();
124
133
  }
125
- }, [location.pathname, isDesktop, onMobileClose]);
134
+ }, [pathname, isDesktop, onMobileClose]);
126
135
 
127
136
  useEffect(() => {
128
137
  if (!mobileOpen || isDesktop) {
@@ -215,6 +224,7 @@ export function Sidebar({
215
224
  key={item.to}
216
225
  item={item}
217
226
  isOpen={isExpanded}
227
+ isActive={isNavItemActive(pathname, item)}
218
228
  onNavigate={handleNavItemNavigate}
219
229
  />
220
230
  ))}
@@ -226,6 +236,7 @@ export function Sidebar({
226
236
  key={item.to}
227
237
  item={item}
228
238
  isOpen={isExpanded}
239
+ isActive={isNavItemActive(pathname, item)}
229
240
  onNavigate={handleNavItemNavigate}
230
241
  />
231
242
  ))}
@@ -1,4 +1,4 @@
1
- import { NavLink } from 'react-router-dom';
1
+ import type { MouseEvent } from 'react';
2
2
 
3
3
  import { cn } from '../utils/cn';
4
4
  import type { AdminNavItem } from './types';
@@ -6,55 +6,66 @@ import type { AdminNavItem } from './types';
6
6
  export type SidebarNavItemProps = {
7
7
  item: AdminNavItem;
8
8
  isOpen: boolean;
9
+ isActive: boolean;
9
10
  onNavigate?: (item: AdminNavItem) => void;
10
11
  };
11
12
 
12
- export function SidebarNavItem({ item, isOpen, onNavigate }: SidebarNavItemProps) {
13
+ function isModifiedClick(event: MouseEvent<HTMLAnchorElement>): boolean {
14
+ return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0;
15
+ }
16
+
17
+ export function SidebarNavItem({ item, isOpen, isActive, onNavigate }: SidebarNavItemProps) {
13
18
  if (item.hidden) {
14
19
  return null;
15
20
  }
16
21
 
17
- const handleClick = () => {
22
+ const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
23
+ if (item.disabled) {
24
+ event.preventDefault();
25
+ return;
26
+ }
27
+
28
+ // Keep routing in the host app's React Router instance so workspace /
29
+ // published UI copies never fight over duplicate router contexts.
30
+ if (event.defaultPrevented || isModifiedClick(event)) {
31
+ return;
32
+ }
33
+
34
+ event.preventDefault();
18
35
  item.onClick?.();
19
36
  onNavigate?.(item);
20
37
  };
21
38
 
22
39
  return (
23
- <NavLink
24
- to={item.to}
25
- end={item.end}
40
+ <a
41
+ href={item.to}
26
42
  onClick={handleClick}
43
+ aria-current={isActive ? 'page' : undefined}
27
44
  aria-disabled={item.disabled}
28
45
  tabIndex={item.disabled ? -1 : undefined}
29
- className={({ isActive }) =>
30
- cn(
31
- 'relative flex items-center rounded-xl px-4 py-3 transition-[background-color,color,gap,padding] duration-300 ease-in-out',
32
- isOpen ? 'w-full gap-4' : 'justify-center gap-0 px-3',
33
- item.disabled && 'pointer-events-none opacity-50',
34
- isActive ? 'bg-black/12 text-white' : 'text-navy-100 hover:bg-white/5',
35
- )
36
- }
37
- >
38
- {({ isActive }) => (
39
- <>
40
- <span
41
- aria-hidden
42
- className={cn(
43
- 'absolute top-1.5 -left-1 h-8 w-2 rounded-full bg-white transition-[opacity,transform] duration-300 ease-in-out',
44
- isActive ? 'scale-100 opacity-100' : 'scale-75 opacity-0',
45
- )}
46
- />
47
- <span className="flex size-5 shrink-0 items-center justify-center">{item.icon}</span>
48
- <span
49
- className={cn(
50
- 'overflow-hidden typography-14 font-semibold whitespace-nowrap transition-[max-width,opacity] duration-300 ease-in-out',
51
- isOpen ? 'max-w-[180px] opacity-100' : 'max-w-0 opacity-0',
52
- )}
53
- >
54
- {item.label}
55
- </span>
56
- </>
46
+ className={cn(
47
+ 'relative flex items-center rounded-xl px-4 py-3 transition-[background-color,color,gap,padding] duration-300 ease-in-out',
48
+ isOpen ? 'w-full gap-4' : 'justify-center gap-0 px-3',
49
+ item.disabled && 'pointer-events-none opacity-50',
50
+ isActive ? 'bg-black/12 text-white' : 'text-navy-100 hover:bg-white/5',
57
51
  )}
58
- </NavLink>
52
+ >
53
+ <span
54
+ aria-hidden
55
+ className={cn(
56
+ 'absolute top-1.5 -left-1 h-8 w-2 rounded-full bg-white transition-[opacity,transform] duration-300 ease-in-out',
57
+ isActive ? 'scale-100 opacity-100' : 'scale-75 opacity-0',
58
+ )}
59
+ />
60
+ <span className="flex size-5 shrink-0 items-center justify-center">{item.icon}</span>
61
+ <span
62
+ className={cn(
63
+ 'overflow-hidden typography-14 font-semibold whitespace-nowrap transition-[max-width,opacity] duration-300 ease-in-out',
64
+ isOpen ? 'max-w-[180px] opacity-100' : 'max-w-0 opacity-0',
65
+ )}
66
+ >
67
+ {item.label}
68
+ </span>
69
+ </a>
59
70
  );
60
71
  }
@@ -0,0 +1,13 @@
1
+ import type { AdminNavItem } from './types';
2
+
3
+ /**
4
+ * Matches React Router `<NavLink end>` semantics using a host-provided pathname.
5
+ * Keeps `@scripso-homepad/ui` free of react-router so apps never get duplicate contexts.
6
+ */
7
+ export function isNavItemActive(pathname: string, item: Pick<AdminNavItem, 'to' | 'end'>): boolean {
8
+ if (item.end) {
9
+ return pathname === item.to;
10
+ }
11
+
12
+ return pathname === item.to || pathname.startsWith(`${item.to}/`);
13
+ }