@scripso-homepad/ui 0.4.36 → 0.4.38

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.36",
3
+ "version": "0.4.38",
4
4
  "type": "module",
5
5
  "description": "Cross-platform UI components for Homepad (React Web + React Native)",
6
6
  "license": "MIT",
@@ -124,7 +124,11 @@ export function Button({
124
124
  icon != null && typeof icon !== "boolean" ? icon : undefined;
125
125
  const hasIcon = showIcon || icon === true || customIcon != null;
126
126
  const resolvedContainerClassName = [
127
- Platform.OS === "web" ? "max-md:px-4!" : "",
127
+ Platform.OS === "web"
128
+ ? hasIcon
129
+ ? "max-md:pl-4! pr-2!"
130
+ : "max-md:px-4!"
131
+ : "",
128
132
  className,
129
133
  ]
130
134
  .filter(Boolean)
@@ -175,12 +175,15 @@ export function PhoneInput({
175
175
  const styles = StyleSheet.create({
176
176
  wrapper: {
177
177
  width: "100%",
178
+ minWidth: 0,
178
179
  gap: spacing.sm,
179
180
  },
180
181
  row: {
181
182
  flexDirection: "row",
182
183
  alignItems: "center",
183
184
  gap: INPUT_ICON_GAP,
185
+ minWidth: 0,
186
+ width: "100%",
184
187
  },
185
188
  countryOutline: {
186
189
  flexShrink: 0,
@@ -190,9 +193,11 @@ const styles = StyleSheet.create({
190
193
  },
191
194
  phoneOutline: {
192
195
  flex: 1,
196
+ minWidth: 0,
193
197
  },
194
198
  phoneField: {
195
199
  flex: 1,
200
+ minWidth: 0,
196
201
  },
197
202
  error: {
198
203
  fontSize: 12,
@@ -598,6 +598,7 @@ const styles = StyleSheet.create({
598
598
  overflow: "hidden",
599
599
  textOverflow: "ellipsis",
600
600
  whiteSpace: "nowrap",
601
+ cursor: "pointer",
601
602
  } as const)
602
603
  : null),
603
604
  },
@@ -98,7 +98,10 @@ export function getSelectFieldStyles(state: InputVisualState): SelectFieldStyleS
98
98
  borderColor: colors.stormGray50,
99
99
  backgroundColor: colors.stormGray50,
100
100
  },
101
- value: { ...valueBase, color: colors.stormGray300 },
101
+ value: {
102
+ ...valueBase,
103
+ color: colors.stormGray300,
104
+ },
102
105
  placeholder: colors.stormGray300,
103
106
  icon: colors.stormGray150,
104
107
  };
@@ -155,6 +158,7 @@ export const selectFieldMetrics = StyleSheet.create({
155
158
  ...(Platform.OS === "web"
156
159
  ? ({
157
160
  boxSizing: "border-box",
161
+ cursor: "pointer",
158
162
  } as ViewStyle)
159
163
  : null),
160
164
  },
@@ -169,6 +173,7 @@ export const selectFieldMetrics = StyleSheet.create({
169
173
  ...(Platform.OS === "web"
170
174
  ? ({
171
175
  outlineStyle: "none",
176
+ cursor: "pointer",
172
177
  } as TextStyle)
173
178
  : null),
174
179
  },
@@ -182,6 +187,7 @@ export const selectDropdownMetrics = StyleSheet.create({
182
187
  backgroundColor: colors.white,
183
188
  padding: SELECT_DROPDOWN_PADDING,
184
189
  overflow: "hidden",
190
+ ...(Platform.OS === "web" ? ({ cursor: "pointer" } as ViewStyle) : null),
185
191
  },
186
192
  option: {
187
193
  width: "100%",
@@ -194,6 +200,7 @@ export const selectDropdownMetrics = StyleSheet.create({
194
200
  paddingLeft: spacing.sm,
195
201
  justifyContent: "center",
196
202
  alignItems: "flex-start",
203
+ ...(Platform.OS === "web" ? ({ cursor: "pointer" } as ViewStyle) : null),
197
204
  },
198
205
  optionLabel: {
199
206
  fontFamily: fonts.sans,
@@ -204,6 +211,7 @@ export const selectDropdownMetrics = StyleSheet.create({
204
211
  textAlign: "left",
205
212
  color: colors.slateBlue,
206
213
  width: "100%",
214
+ ...(Platform.OS === "web" ? ({ cursor: "pointer" } as TextStyle) : null),
207
215
  },
208
216
  optionLabelSelected: {
209
217
  fontWeight: fontWeight.medium,
@@ -148,7 +148,7 @@ export function Drawer({
148
148
  type="button"
149
149
  aria-label={closeAriaLabel}
150
150
  className={cn(
151
- 'absolute inset-0 bg-storm-gray-850/40 backdrop-blur-[12px] transition-opacity duration-300 ease-in-out',
151
+ 'absolute inset-0 z-0 cursor-pointer bg-storm-gray-850/40 backdrop-blur-[12px] transition-opacity duration-300 ease-in-out',
152
152
  isAnimatedIn ? 'opacity-100' : 'opacity-0',
153
153
  )}
154
154
  onClick={onClose}
@@ -160,7 +160,7 @@ export function Drawer({
160
160
  aria-labelledby="homepad-drawer-title"
161
161
  {...(subtitle ? { 'aria-describedby': 'homepad-drawer-subtitle' } : {})}
162
162
  className={cn(
163
- '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 max-md:h-dvh max-md:rounded-none',
163
+ 'relative z-10 flex h-[calc(100dvh-16px)] min-h-0 cursor-auto flex-col overflow-hidden rounded-[16px] bg-white shadow-2xl transition-transform duration-300 ease-in-out max-md:h-dvh max-md:rounded-none',
164
164
  // Keep GPU transform only while animating — settled translate-x-0 blurs text.
165
165
  !isTransformSettled && 'will-change-transform',
166
166
  isAnimatedIn
@@ -42,6 +42,7 @@ export type PaginationProps = {
42
42
  */
43
43
  variant?: PaginationVariant;
44
44
  className?: string;
45
+ /** Hide the entire bar when there is only one page. Page buttons are always hidden in that case. */
45
46
  hideOnSinglePage?: boolean;
46
47
  };
47
48
 
@@ -64,15 +65,16 @@ export function Pagination({
64
65
  const visiblePages = getVisiblePageNumbers(safePage, totalPages, maxPageButtons);
65
66
  const canGoPrevious = safePage > 1;
66
67
  const canGoNext = safePage < totalPages;
68
+ const showPageControls = totalPages > 1;
67
69
 
68
- if (hideOnSinglePage && totalPages <= 1) {
70
+ if (hideOnSinglePage && !showPageControls) {
69
71
  return null;
70
72
  }
71
73
 
72
74
  return (
73
75
  <div
74
76
  className={cn(
75
- 'flex flex-col gap-4 px-4 pb-4 pt-4 font-[family-name:var(--font-sans)] sm:flex-row sm:items-center sm:justify-between',
77
+ 'flex flex-col items-center gap-4 px-4 pb-4 pt-4 text-center font-[family-name:var(--font-sans)] sm:flex-row sm:items-center sm:justify-between sm:text-left',
76
78
  variant === 'standalone' && cn('rounded-xl border bg-storm-gray-0', 'border-storm-gray-50'),
77
79
  dataTableClassNames.pagination,
78
80
  className,
@@ -82,66 +84,68 @@ export function Pagination({
82
84
  {mergedLabels.showing({ start, end, total })}
83
85
  </p>
84
86
 
85
- <nav
86
- aria-label="Pagination"
87
- className="flex items-center gap-2.5"
88
- >
89
- <button
90
- type="button"
91
- onClick={() => onPageChange(safePage - 1)}
92
- disabled={!canGoPrevious}
93
- aria-label={mergedLabels.previousPage}
94
- className={cn(
95
- paginationControlClassName,
96
- canGoPrevious ? paginationHoverClassName : 'cursor-not-allowed opacity-40',
97
- )}
87
+ {showPageControls ? (
88
+ <nav
89
+ aria-label="Pagination"
90
+ className="flex items-center gap-2.5"
98
91
  >
99
- <ChevronLeft
100
- className="size-3.5 text-storm-gray-500"
101
- strokeWidth={1.75}
102
- aria-hidden
103
- />
104
- </button>
92
+ <button
93
+ type="button"
94
+ onClick={() => onPageChange(safePage - 1)}
95
+ disabled={!canGoPrevious}
96
+ aria-label={mergedLabels.previousPage}
97
+ className={cn(
98
+ paginationControlClassName,
99
+ canGoPrevious ? paginationHoverClassName : 'cursor-not-allowed opacity-40',
100
+ )}
101
+ >
102
+ <ChevronLeft
103
+ className="size-3.5 text-storm-gray-500"
104
+ strokeWidth={1.75}
105
+ aria-hidden
106
+ />
107
+ </button>
105
108
 
106
- {visiblePages.map((pageNumber) => {
107
- const isActive = pageNumber === safePage;
109
+ {visiblePages.map((pageNumber) => {
110
+ const isActive = pageNumber === safePage;
108
111
 
109
- return (
110
- <button
111
- key={pageNumber}
112
- type="button"
113
- onClick={() => onPageChange(pageNumber)}
114
- aria-label={mergedLabels.page(pageNumber)}
115
- aria-current={isActive ? 'page' : undefined}
116
- className={cn(
117
- paginationControlClassName,
118
- isActive
119
- ? 'border-storm-gray-50 font-bold text-storm-gray-900'
120
- : paginationHoverClassName,
121
- )}
122
- >
123
- {pageNumber}
124
- </button>
125
- );
126
- })}
112
+ return (
113
+ <button
114
+ key={pageNumber}
115
+ type="button"
116
+ onClick={() => onPageChange(pageNumber)}
117
+ aria-label={mergedLabels.page(pageNumber)}
118
+ aria-current={isActive ? 'page' : undefined}
119
+ className={cn(
120
+ paginationControlClassName,
121
+ isActive
122
+ ? 'border-storm-gray-50 font-bold text-storm-gray-900'
123
+ : paginationHoverClassName,
124
+ )}
125
+ >
126
+ {pageNumber}
127
+ </button>
128
+ );
129
+ })}
127
130
 
128
- <button
129
- type="button"
130
- onClick={() => onPageChange(safePage + 1)}
131
- disabled={!canGoNext}
132
- aria-label={mergedLabels.nextPage}
133
- className={cn(
134
- paginationControlClassName,
135
- canGoNext ? paginationHoverClassName : 'cursor-not-allowed opacity-40',
136
- )}
137
- >
138
- <ChevronRight
139
- className="size-3.5 text-storm-gray-500"
140
- strokeWidth={1.75}
141
- aria-hidden
142
- />
143
- </button>
144
- </nav>
131
+ <button
132
+ type="button"
133
+ onClick={() => onPageChange(safePage + 1)}
134
+ disabled={!canGoNext}
135
+ aria-label={mergedLabels.nextPage}
136
+ className={cn(
137
+ paginationControlClassName,
138
+ canGoNext ? paginationHoverClassName : 'cursor-not-allowed opacity-40',
139
+ )}
140
+ >
141
+ <ChevronRight
142
+ className="size-3.5 text-storm-gray-500"
143
+ strokeWidth={1.75}
144
+ aria-hidden
145
+ />
146
+ </button>
147
+ </nav>
148
+ ) : null}
145
149
  </div>
146
150
  );
147
151
  }
@@ -86,7 +86,7 @@ export function DataTable({
86
86
 
87
87
  return (
88
88
  <DataTableClassNamesContext.Provider value={mergedClassNames}>
89
- <div className={cn('flex flex-col', fillHeight && 'min-h-0 flex-1', mergedClassNames.root)}>
89
+ <div className={cn('flex min-w-0 flex-col', fillHeight && 'min-h-0 flex-1', mergedClassNames.root)}>
90
90
  <div
91
91
  className={cn(
92
92
  tableShellClassName,
@@ -109,6 +109,7 @@ export function DataTable({
109
109
  {hasFooter ? (
110
110
  <div
111
111
  className={cn(
112
+ 'shrink-0',
112
113
  tableFooterShellClassName,
113
114
  tableBorderClassName,
114
115
  mergedClassNames.footer,
@@ -23,7 +23,7 @@ export function TableScrollArea({
23
23
  return (
24
24
  <div
25
25
  className={cn(
26
- 'table-scroll-area w-full overflow-auto',
26
+ 'table-scroll-area w-full min-w-0 max-w-full overflow-auto',
27
27
  fillHeight && 'min-h-0 flex-1',
28
28
  className,
29
29
  )}
@@ -43,6 +43,7 @@ function DashboardLayoutDemo() {
43
43
  user: storyUser,
44
44
  mobileOpen: mobileMenuOpen,
45
45
  onMobileClose: () => setMobileMenuOpen(false),
46
+ onMobileOpen: () => setMobileMenuOpen(true),
46
47
  onLogout: fn(),
47
48
  onNavItemClick: () => setMobileMenuOpen(false),
48
49
  }}
@@ -63,7 +64,7 @@ function DashboardLayoutDemo() {
63
64
  buildingOptions: storyBuildingOptions,
64
65
  selectedBuildingId,
65
66
  onBuildingChange: setSelectedBuildingId,
66
- className: 'hidden md:flex',
67
+ className: 'hidden lg:flex',
67
68
  searchValue: searchQuery,
68
69
  onSearchChange: setSearchQuery,
69
70
  onNotificationsClick: fn(),
@@ -27,14 +27,14 @@ export function DashboardLayout({
27
27
  mainClassName,
28
28
  }: DashboardLayoutProps) {
29
29
  return (
30
- <div className={cn('flex h-screen overflow-hidden flex-row bg-storm-gray-0', className)}>
31
- <div className="h-screen shrink-0">
30
+ <div className={cn('flex h-screen min-w-0 overflow-hidden flex-row bg-storm-gray-0', className)}>
31
+ <div className="max-md:contents md:h-screen md:shrink-0">
32
32
  <Sidebar {...sidebar} />
33
33
  </div>
34
34
  <div className="flex h-screen min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
35
35
  <SidebarMobileHeader {...mobileHeader} />
36
36
  <AppHeader {...header} />
37
- <main className={mainClassName ?? 'min-h-0 flex-1 overflow-y-auto p-4 md:p-6'}>{children}</main>
37
+ <main className={mainClassName ?? 'min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-4 xl:p-6'}>{children}</main>
38
38
  </div>
39
39
  </div>
40
40
  );
@@ -67,6 +67,7 @@ function MobileDrawerStory(args: SidebarProps) {
67
67
  {...args}
68
68
  mobileOpen={mobileOpen}
69
69
  onMobileClose={() => setMobileOpen(false)}
70
+ onMobileOpen={() => setMobileOpen(true)}
70
71
  />
71
72
  </div>
72
73
  </WebLayoutCanvas>
@@ -1,4 +1,3 @@
1
- import { X } from 'lucide-react';
2
1
  import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
3
2
 
4
3
  import { SidebarCollapseIcon } from '../icons/SidebarCollapseIcon';
@@ -26,6 +25,7 @@ export type SidebarProps = {
26
25
  className?: string;
27
26
  mobileOpen: boolean;
28
27
  onMobileClose?: () => void;
28
+ onMobileOpen?: () => void;
29
29
  /**
30
30
  * Host-owned navigation. Call `navigate(item.to)` from the app router here.
31
31
  * The sidebar never imports react-router, so this must perform the route change.
@@ -68,6 +68,7 @@ export function Sidebar({
68
68
  className,
69
69
  mobileOpen,
70
70
  onMobileClose,
71
+ onMobileOpen,
71
72
  onNavItemClick,
72
73
  collapseIcon,
73
74
  expandIcon,
@@ -76,13 +77,15 @@ export function Sidebar({
76
77
  collapsed,
77
78
  onCollapsedChange,
78
79
  }: SidebarProps) {
79
- const isDesktop = useMediaQuery('(min-width: 768px)');
80
+ const isDesktop = useMediaQuery('(min-width: 1024px)');
81
+ const isRailLayout = useMediaQuery('(min-width: 768px)');
80
82
  const [internalCollapsed, setInternalCollapsed] = useState(() =>
81
83
  readInitialCollapsedState(sidebarStorageKey, initialCollapsed),
82
84
  );
83
85
 
84
86
  const isCollapsed = collapsed ?? internalCollapsed;
85
- const isExpanded = isDesktop ? !isCollapsed : true;
87
+ const isExpanded = isDesktop ? !isCollapsed : mobileOpen;
88
+ const showCollapsedRail = isRailLayout && !isDesktop;
86
89
 
87
90
  const setCollapsed = useCallback(
88
91
  (next: boolean) => {
@@ -107,8 +110,14 @@ export function Sidebar({
107
110
  return;
108
111
  }
109
112
 
110
- onMobileClose?.();
111
- }, [isDesktop, onMobileClose, toggleCollapsed]);
113
+ if (mobileOpen) {
114
+ onMobileClose?.();
115
+
116
+ return;
117
+ }
118
+
119
+ onMobileOpen?.();
120
+ }, [isDesktop, mobileOpen, onMobileClose, onMobileOpen, toggleCollapsed]);
112
121
 
113
122
  const handleNavItemNavigate = useCallback(
114
123
  (item: AdminNavItem) => {
@@ -158,21 +167,28 @@ export function Sidebar({
158
167
  aria-label={labels.closeMenu}
159
168
  onClick={onMobileClose}
160
169
  className={cn(
161
- 'fixed inset-0 z-40 bg-navy-800/60 backdrop-blur-[1px] transition-opacity duration-300 ease-in-out md:hidden',
170
+ 'fixed inset-0 z-40 bg-navy-800/60 backdrop-blur-[1px] transition-opacity duration-300 ease-in-out lg:hidden',
162
171
  mobileOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
163
172
  )}
164
173
  />
165
174
 
175
+ {showCollapsedRail && mobileOpen ? (
176
+ <div className="h-screen w-[76px] shrink-0" aria-hidden />
177
+ ) : null}
178
+
166
179
  <aside
167
180
  className={cn(
168
181
  'fixed inset-y-0 left-0 z-50 flex h-screen w-[min(300px,85vw)] flex-col gap-8 overflow-hidden bg-navy p-4 shadow-xl',
169
182
  'transition-[transform,width,padding,gap] duration-300 ease-in-out will-change-[transform,width]',
170
- mobileOpen ? 'translate-x-0' : '-translate-x-full',
171
- 'md:relative md:z-auto md:w-[300px] md:translate-x-0 md:shadow-none',
172
- isExpanded ? 'md:w-[300px]' : 'md:w-[76px] md:items-center md:gap-2 md:px-3 md:py-4',
183
+ mobileOpen ? 'translate-x-0' : '-translate-x-full pointer-events-none',
184
+ 'md:relative md:z-auto md:w-[76px] md:translate-x-0 md:pointer-events-auto md:shadow-none md:items-center md:gap-2 md:px-3 md:py-4',
185
+ mobileOpen &&
186
+ 'md:fixed md:z-50 md:w-[min(300px,85vw)] md:items-stretch md:gap-8 md:p-4 md:shadow-xl',
187
+ 'lg:relative lg:z-auto lg:translate-x-0 lg:pointer-events-auto lg:shadow-none',
188
+ isExpanded ? 'lg:w-[300px] lg:items-stretch lg:gap-8 lg:p-4' : 'lg:w-[76px] lg:items-center lg:gap-2 lg:px-3 lg:py-4',
173
189
  className,
174
190
  )}
175
- aria-hidden={!isDesktop && !mobileOpen}
191
+ aria-hidden={!isDesktop && !mobileOpen && !showCollapsedRail}
176
192
  >
177
193
  <div
178
194
  className={cn(
@@ -201,22 +217,16 @@ export function Sidebar({
201
217
  onClick={handleHeaderAction}
202
218
  className="shrink-0 cursor-pointer text-white transition-[opacity,transform] duration-200 hover:opacity-80 active:scale-95"
203
219
  aria-label={
204
- isDesktop
205
- ? isExpanded
220
+ isExpanded
221
+ ? isDesktop
206
222
  ? labels.collapse
207
- : labels.expand
208
- : labels.closeMenu
223
+ : labels.closeMenu
224
+ : labels.expand
209
225
  }
210
226
  >
211
- {isDesktop ? (
212
- isExpanded ? (
213
- collapseIcon ?? <SidebarCollapseIcon />
214
- ) : (
215
- expandIcon ?? <SidebarExpandIcon />
216
- )
217
- ) : (
218
- closeIcon ?? <X size={20} strokeWidth={1.75} />
219
- )}
227
+ {isExpanded
228
+ ? (collapseIcon ?? closeIcon ?? <SidebarCollapseIcon />)
229
+ : (expandIcon ?? <SidebarExpandIcon />)}
220
230
  </button>
221
231
  </div>
222
232
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
2
  import { fn } from '@storybook/test';
3
3
 
4
+ import { BellIcon } from '../icons/BellIcon';
4
5
  import { WebLayoutCanvas } from './story-helpers';
5
6
  import { SidebarMobileHeader } from './SidebarMobileHeader';
6
7
  import { storyBranding, storyLabels, storyLogoIconDarkSrc } from './story-fixtures';
@@ -45,3 +46,22 @@ export const MenuOpen: Story = {
45
46
  </WebLayoutCanvas>
46
47
  ),
47
48
  };
49
+
50
+ export const WithEndAction: Story = {
51
+ args: {
52
+ end: (
53
+ <button
54
+ type="button"
55
+ className="relative flex size-11 shrink-0 items-center justify-center rounded-xl border border-storm-gray-50 text-navy"
56
+ aria-label="Notifications"
57
+ >
58
+ <BellIcon className="text-navy" />
59
+ </button>
60
+ ),
61
+ },
62
+ render: (args) => (
63
+ <WebLayoutCanvas>
64
+ <SidebarMobileHeader {...args} />
65
+ </WebLayoutCanvas>
66
+ ),
67
+ };
@@ -1,5 +1,6 @@
1
- import { Menu } from 'lucide-react';
1
+ import type { ReactNode } from 'react';
2
2
 
3
+ import { SidebarExpandIcon } from '../icons/SidebarExpandIcon';
3
4
  import { cn } from '../utils/cn';
4
5
  import type { AdminSidebarBranding } from './types';
5
6
 
@@ -9,6 +10,8 @@ export type SidebarMobileHeaderProps = {
9
10
  onOpenMenu: () => void;
10
11
  menuOpen?: boolean;
11
12
  className?: string;
13
+ /** Rendered on the right side of the mobile header (e.g. notifications). */
14
+ end?: ReactNode;
12
15
  };
13
16
 
14
17
  export function SidebarMobileHeader({
@@ -17,6 +20,7 @@ export function SidebarMobileHeader({
17
20
  onOpenMenu,
18
21
  menuOpen = false,
19
22
  className,
23
+ end,
20
24
  }: SidebarMobileHeaderProps) {
21
25
  return (
22
26
  <header
@@ -32,9 +36,9 @@ export function SidebarMobileHeader({
32
36
  aria-label={openMenuLabel}
33
37
  aria-expanded={menuOpen}
34
38
  >
35
- <Menu size={22} strokeWidth={1.75} />
39
+ <SidebarExpandIcon size={22} />
36
40
  </button>
37
- <div className="flex min-w-0 items-center gap-2">
41
+ <div className="flex min-w-0 flex-1 items-center gap-2">
38
42
  <img src={branding.logoIconSrc} alt="" className="h-[30px] w-[42px] shrink-0" />
39
43
  <div className="min-w-0 leading-none">
40
44
  <p className="truncate text-base font-bold tracking-tight text-navy">{branding.logoTitle}</p>
@@ -43,6 +47,7 @@ export function SidebarMobileHeader({
43
47
  </p>
44
48
  </div>
45
49
  </div>
50
+ {end ? <div className="shrink-0">{end}</div> : null}
46
51
  </header>
47
52
  );
48
53
  }