@scripso-homepad/ui 0.4.14 → 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.14",
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,
@@ -99,6 +99,8 @@ export function Input({
99
99
  showPasswordToggle,
100
100
  onFocus,
101
101
  onBlur,
102
+ multiline,
103
+ textAlignVertical,
102
104
  ...props
103
105
  }: InputProps) {
104
106
  const wrapperRef = useRef<ComponentRef<typeof View>>(null);
@@ -120,6 +122,9 @@ export function Input({
120
122
  const effectiveSecureTextEntry = passwordToggleEnabled
121
123
  ? !passwordVisible
122
124
  : secureTextEntry;
125
+ // Keep placeholder and typed text on the same vertical line (esp. Android).
126
+ const resolvedTextAlignVertical =
127
+ textAlignVertical ?? (multiline ? "top" : "center");
123
128
  const visualState = resolveInputVisualState({
124
129
  focused,
125
130
  error: hasError,
@@ -187,12 +192,16 @@ export function Input({
187
192
  ref={inputRef}
188
193
  style={[
189
194
  inputFieldMetrics.input,
195
+ !multiline ? inputFieldMetrics.inputSingleLine : null,
190
196
  fieldStyles.text,
191
197
  style,
192
198
  ]}
193
199
  placeholderTextColor={fieldStyles.placeholder}
194
200
  editable={editable}
201
+ multiline={multiline}
195
202
  secureTextEntry={effectiveSecureTextEntry}
203
+ textAlign="left"
204
+ textAlignVertical={resolvedTextAlignVertical}
196
205
  onFocus={handleFocus}
197
206
  onBlur={handleBlur}
198
207
  accessibilityState={{ disabled: isDisabled }}
@@ -215,6 +224,7 @@ export function Input({
215
224
  const styles = StyleSheet.create({
216
225
  wrapper: {
217
226
  width: "100%",
227
+ minWidth: 0,
218
228
  gap: spacing.sm,
219
229
  },
220
230
  iconSlot: {
@@ -144,10 +144,10 @@ export const inputFieldMetrics = StyleSheet.create({
144
144
  input: {
145
145
  flex: 1,
146
146
  minWidth: 0,
147
+ width: "100%",
147
148
  fontFamily: fonts.sans,
148
149
  fontSize: fontSize.md,
149
150
  fontWeight: fontWeight.medium,
150
- lineHeight: INPUT_TEXT_LINE_HEIGHT,
151
151
  paddingVertical: 0,
152
152
  paddingHorizontal: 0,
153
153
  margin: 0,
@@ -156,13 +156,23 @@ export const inputFieldMetrics = StyleSheet.create({
156
156
  ...(Platform.OS === "android"
157
157
  ? { includeFontPadding: false }
158
158
  : null),
159
+ ...(Platform.OS === "ios"
160
+ ? { lineHeight: INPUT_TEXT_LINE_HEIGHT, paddingVertical: 2 }
161
+ : null),
159
162
  ...(Platform.OS === "web"
160
163
  ? ({
161
164
  alignSelf: "center",
162
165
  outlineStyle: "none",
166
+ lineHeight: INPUT_TEXT_LINE_HEIGHT,
163
167
  } as TextStyle)
164
168
  : {
165
169
  alignSelf: "stretch",
166
170
  }),
167
171
  },
172
+ /** Single-line: avoid Android lineHeight jump between placeholder and typed text. */
173
+ inputSingleLine: {
174
+ ...(Platform.OS === "android"
175
+ ? ({ textAlignVertical: "center" } as TextStyle)
176
+ : null),
177
+ },
168
178
  });
@@ -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!"
@@ -151,8 +151,11 @@ export function Modal({
151
151
  <footer
152
152
  className={cn(
153
153
  footerLayout === 'split'
154
- ? 'grid w-full shrink-0 grid-cols-2 gap-4'
155
- : cn('flex shrink-0 gap-4', footerAlignClasses[footerAlign]),
154
+ ? 'grid w-full shrink-0 grid-cols-1 gap-3 min-[400px]:grid-cols-2 min-[400px]:gap-4'
155
+ : cn(
156
+ 'flex shrink-0 flex-col-reverse gap-3 sm:flex-row sm:gap-4',
157
+ footerAlignClasses[footerAlign],
158
+ ),
156
159
  )}
157
160
  >
158
161
  <Button
@@ -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)}>
@@ -207,7 +207,8 @@ export function TableActionsCell({ className, children, ...props }: TableActions
207
207
  tableCellPaddingClassName,
208
208
  tableBodyClassName,
209
209
  tableStickyRightShadowClassName,
210
- disabled && tableRowDisabledCellClassName,
210
+ // Keep an opaque sticky background so scrolled cells never bleed through.
211
+ disabled && 'bg-white [&>*]:opacity-70',
211
212
  className,
212
213
  )}
213
214
  {...props}
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
+ }