@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/dist/{chunk-ANDWP4YU.js → chunk-U7OSCJKA.js} +39 -8
- package/dist/chunk-U7OSCJKA.js.map +1 -0
- package/dist/index.cjs +37 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/web/index.cjs +105 -47
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.d.cts +23 -5
- package/dist/web/index.d.ts +23 -5
- package/dist/web/index.js +69 -43
- package/dist/web/index.js.map +1 -1
- package/package.json +1 -6
- package/src/components/Button.tsx +4 -0
- package/src/components/Input.tsx +10 -0
- package/src/theme/input.ts +11 -1
- package/src/utils/useApplyWebClassName.ts +25 -5
- package/src/web/components/ConfirmModal.tsx +11 -3
- package/src/web/components/Modal.tsx +5 -2
- package/src/web/components/TableActionsMenu.tsx +67 -65
- package/src/web/components/table/TablePrimitives.tsx +2 -1
- package/src/web/index.ts +2 -0
- package/src/web/layout/DashboardLayout.stories.tsx +1 -0
- package/src/web/layout/Sidebar.stories.tsx +1 -0
- package/src/web/layout/Sidebar.tsx +17 -6
- package/src/web/layout/SidebarNavItem.tsx +46 -35
- package/src/web/layout/nav-active.ts +13 -0
- package/dist/chunk-ANDWP4YU.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scripso-homepad/ui",
|
|
3
|
-
"version": "0.4.
|
|
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,
|
package/src/components/Input.tsx
CHANGED
|
@@ -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: {
|
package/src/theme/input.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
let rafId = 0;
|
|
59
|
+
let appliedElement: ClassListElement | null = null;
|
|
56
60
|
const classes = className.trim().split(/\s+/);
|
|
57
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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(
|
|
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 =
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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),
|
|
@@ -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(
|
|
122
|
+
const pathnameRef = useRef(pathname);
|
|
114
123
|
|
|
115
124
|
useEffect(() => {
|
|
116
|
-
if (pathnameRef.current ===
|
|
125
|
+
if (pathnameRef.current === pathname) {
|
|
117
126
|
return;
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
pathnameRef.current =
|
|
129
|
+
pathnameRef.current = pathname;
|
|
121
130
|
|
|
122
131
|
if (!isDesktop) {
|
|
123
132
|
onMobileClose?.();
|
|
124
133
|
}
|
|
125
|
-
}, [
|
|
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 {
|
|
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
|
-
|
|
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
|
-
<
|
|
24
|
-
|
|
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={(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
+
}
|