@scripso-homepad/ui 0.4.24 → 0.4.26

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.24",
3
+ "version": "0.4.26",
4
4
  "type": "module",
5
5
  "description": "Cross-platform UI components for Homepad (React Web + React Native)",
6
6
  "license": "MIT",
@@ -280,8 +280,8 @@ const styles = StyleSheet.create({
280
280
  gap: spacing.sm,
281
281
  },
282
282
  iconSlot: {
283
- width: INPUT_ICON_SIZE,
284
- height: INPUT_ICON_SIZE,
283
+ minWidth: INPUT_ICON_SIZE,
284
+ minHeight: INPUT_ICON_SIZE,
285
285
  alignItems: "center",
286
286
  justifyContent: "center",
287
287
  flexShrink: 0,
@@ -20,6 +20,7 @@ import {
20
20
  Text,
21
21
  View,
22
22
  type StyleProp,
23
+ type TextStyle,
23
24
  type ViewStyle,
24
25
  } from "react-native";
25
26
  import { ChevronDownIcon } from "../icons/ChevronDownIcon";
@@ -210,6 +211,8 @@ interface SelectBaseProps {
210
211
  fieldClassName?: string;
211
212
  menuClassName?: string;
212
213
  labelClassName?: string;
214
+ /** Styles merged into the field label. */
215
+ labelStyles?: StyleProp<TextStyle>;
213
216
  errorClassName?: string;
214
217
  hintClassName?: string;
215
218
  /** Shorter option rows for compact filter-style dropdowns. */
@@ -268,6 +271,7 @@ export function Select({
268
271
  fieldClassName,
269
272
  menuClassName,
270
273
  labelClassName,
274
+ labelStyles,
271
275
  errorClassName,
272
276
  hintClassName,
273
277
  compact = false,
@@ -434,7 +438,11 @@ export function Select({
434
438
  return (
435
439
  <View ref={wrapperRef} style={[styles.wrapper, containerStyle]}>
436
440
  {label ? (
437
- <Label disabled={isEffectivelyDisabled} style={styles.label} className={labelClassName}>
441
+ <Label
442
+ disabled={isEffectivelyDisabled}
443
+ style={[styles.label, labelStyles]}
444
+ className={labelClassName}
445
+ >
438
446
  {label}
439
447
  </Label>
440
448
  ) : null}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
3
3
 
4
4
  import { Button } from '../../components/Button';
5
5
  import { cn } from '../utils/cn';
6
+ import './drawer-scroll.css';
6
7
 
7
8
  export type ModalFooterAlign = 'left' | 'center' | 'right';
8
9
  export type ModalFooterLayout = 'inline' | 'split';
@@ -146,7 +147,7 @@ export function Modal({
146
147
  </div>
147
148
  </header>
148
149
 
149
- <div className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto', contentClassName)}>{children}</div>
150
+ <div className={cn('drawer-scroll-area min-h-0 min-w-0 flex-1 overflow-y-auto', contentClassName)}>{children}</div>
150
151
 
151
152
  <footer
152
153
  className={cn(
@@ -187,7 +187,7 @@ function RequestsTableStory() {
187
187
  <TableHead>Հեռախոսահամար</TableHead>
188
188
  <TableHead>Հայտի Ամսաթիվ</TableHead>
189
189
  <TableHead>Կարգավիճակ</TableHead>
190
- <TableHead stickyRight className="w-px p-0" aria-hidden />
190
+ <TableHead className="w-px p-0" aria-hidden />
191
191
  </TableRow>
192
192
  </TableHeader>
193
193
  <TableBody>
@@ -375,7 +375,7 @@ function LargeScrollableTableStory() {
375
375
  <TableHead className="min-w-40">Ընդամենը ամսական</TableHead>
376
376
  <TableHead className="min-w-40">Վճարման կարգավիճակ</TableHead>
377
377
  <TableHead className="min-w-32">Կարգավիճակ</TableHead>
378
- <TableHead stickyRight className="w-px p-0" aria-hidden />
378
+ <TableHead className="w-px p-0" aria-hidden />
379
379
  </TableRow>
380
380
  </TableHeader>
381
381
  <TableBody>
@@ -1,10 +1,19 @@
1
1
  .drawer-scroll-area {
2
2
  scrollbar-color: var(--color-storm-gray-200, #d5d9dd) transparent;
3
- scrollbar-width: thin;
3
+ /* Chrome 121+ ignores ::-webkit-scrollbar width when scrollbar-width is set. */
4
+ scrollbar-width: unset;
5
+ }
6
+
7
+ /* Firefox has no ::-webkit-scrollbar; thin is the slimmest standard option. */
8
+ @supports not selector(::-webkit-scrollbar) {
9
+ .drawer-scroll-area {
10
+ scrollbar-width: thin;
11
+ }
4
12
  }
5
13
 
6
14
  .drawer-scroll-area::-webkit-scrollbar {
7
- width: 4px;
15
+ width: 3px;
16
+ height: 3px;
8
17
  -webkit-appearance: none;
9
18
  appearance: none;
10
19
  }
@@ -13,8 +13,6 @@ import {
13
13
  tableHeadSortButtonAlignClasses,
14
14
  tableHeaderClassName,
15
15
  tableRowDisabledCellClassName,
16
- tableStickyRightHeadClassName,
17
- tableStickyRightShadowClassName,
18
16
  type TableCellVariant,
19
17
  } from './constants';
20
18
  import { TableRowDisabledProvider, useTableRowDisabled } from './table-row-context';
@@ -82,8 +80,6 @@ export function TableRow({ className, disabled = false, ...props }: TableRowProp
82
80
 
83
81
  export type TableHeadProps = ThHTMLAttributes<HTMLTableCellElement> & {
84
82
  align?: keyof typeof tableAlignClasses;
85
- /** Pins the column to the right edge during horizontal scroll (e.g. actions menu). */
86
- stickyRight?: boolean;
87
83
  /** Enables sort UI and click handler for this column header. */
88
84
  sortable?: boolean;
89
85
  /** Active sort direction for this column. Omit or `null` when unsorted. */
@@ -111,7 +107,6 @@ function resolveAriaSort(
111
107
  export function TableHead({
112
108
  className,
113
109
  align = 'left',
114
- stickyRight = false,
115
110
  sortable = false,
116
111
  sortDirection = null,
117
112
  onSort,
@@ -142,11 +137,6 @@ export function TableHead({
142
137
  'sticky top-0 z-10',
143
138
  tableHeadPaddingClassName,
144
139
  tableHeaderClassName,
145
- stickyRight && [
146
- 'sticky right-0 z-20',
147
- tableStickyRightHeadClassName,
148
- tableStickyRightShadowClassName,
149
- ],
150
140
  !isSortable && tableAlignClasses[align],
151
141
  className,
152
142
  )}
@@ -203,17 +193,15 @@ export function TableActionsCell({ className, children, ...props }: TableActions
203
193
  return (
204
194
  <td
205
195
  className={cn(
206
- 'sticky right-0 z-10 whitespace-nowrap bg-white',
196
+ 'whitespace-nowrap',
207
197
  tableCellPaddingClassName,
208
198
  tableBodyClassName,
209
- tableStickyRightShadowClassName,
210
- // Keep an opaque sticky background so scrolled cells never bleed through.
211
- disabled && 'bg-white [&>*]:opacity-70',
199
+ disabled && tableRowDisabledCellClassName,
212
200
  className,
213
201
  )}
214
202
  {...props}
215
203
  >
216
- <div className="relative z-[1] flex items-center justify-end gap-2">{children}</div>
204
+ <div className="flex items-center justify-end gap-2">{children}</div>
217
205
  </td>
218
206
  );
219
207
  }
@@ -1,13 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
 
3
3
  import { cn } from '../../utils/cn';
4
- import {
5
- resolveTableMinWidth,
6
- resolveTableSize,
7
- tableScrollAtEndClassName,
8
- tableScrollHasOverflowClassName,
9
- } from './constants';
10
- import { useHorizontalScrollState } from './use-horizontal-scroll-state';
4
+ import { resolveTableMinWidth, resolveTableSize } from './constants';
11
5
 
12
6
  export type TableScrollAreaProps = {
13
7
  children: ReactNode;
@@ -26,15 +20,10 @@ export function TableScrollArea({
26
20
  height,
27
21
  fillHeight = false,
28
22
  }: TableScrollAreaProps) {
29
- const { scrollRef, scrollState } = useHorizontalScrollState();
30
-
31
23
  return (
32
24
  <div
33
- ref={scrollRef}
34
25
  className={cn(
35
26
  'table-scroll-area w-full overflow-auto',
36
- scrollState.hasOverflow && tableScrollHasOverflowClassName,
37
- scrollState.atEnd && tableScrollAtEndClassName,
38
27
  fillHeight && 'min-h-0 flex-1',
39
28
  className,
40
29
  )}
@@ -1,13 +1,5 @@
1
1
  export const tableBorderClassName = 'border-storm-gray-50';
2
2
 
3
- export const tableStickyRightShadowClassName = 'table-sticky-right-shadow';
4
-
5
- export const tableStickyRightHeadClassName = 'table-sticky-right-head';
6
-
7
- export const tableScrollAtEndClassName = 'table-scroll-at-end';
8
-
9
- export const tableScrollHasOverflowClassName = 'table-scroll-has-overflow';
10
-
11
3
  export const tableRowDisabledCellClassName = 'bg-[#ECEEF0]/30 [&>*]:opacity-70';
12
4
 
13
5
  export const DEFAULT_TABLE_BODY_MIN_HEIGHT = 320;
@@ -11,10 +11,7 @@ export {
11
11
  tableFooterShellClassName,
12
12
  tableHeadPaddingClassName,
13
13
  tableRowDisabledCellClassName,
14
- tableScrollAtEndClassName,
15
- tableScrollHasOverflowClassName,
16
14
  tableShellClassName,
17
- tableStickyRightShadowClassName,
18
15
  } from './constants';
19
16
  export type { TableCellVariant } from './constants';
20
17
 
@@ -52,30 +52,3 @@
52
52
  .table-scroll-area::-webkit-scrollbar-corner {
53
53
  background: transparent;
54
54
  }
55
-
56
- .table-sticky-right-shadow::before {
57
- content: '';
58
- position: absolute;
59
- top: 0;
60
- bottom: 0;
61
- left: 0;
62
- z-index: 0;
63
- width: 12px;
64
- transform: translateX(-100%);
65
- background: linear-gradient(90deg, rgba(21, 26, 30, 0) 0%, rgba(21, 26, 30, 0.08) 100%);
66
- pointer-events: none;
67
- opacity: 0;
68
- transition: opacity 150ms ease;
69
- }
70
-
71
- .table-scroll-has-overflow:not(.table-scroll-at-end) .table-sticky-right-shadow::before {
72
- opacity: 1;
73
- }
74
-
75
- .table-sticky-right-head {
76
- background-color: var(--color-storm-gray-0, #fbfcfc);
77
- }
78
-
79
- .table-scroll-has-overflow:not(.table-scroll-at-end) .table-sticky-right-head {
80
- background-color: #fff;
81
- }
@@ -1,5 +1,13 @@
1
1
  import { ChevronsUpDown } from 'lucide-react';
2
- import { useCallback, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
2
+ import {
3
+ useCallback,
4
+ useId,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ type ReactNode,
10
+ } from 'react';
3
11
 
4
12
  import { BuildingIcon } from '../icons/BuildingIcon';
5
13
  import { useOnClickOutside } from '../hooks/useOnClickOutside';
@@ -18,6 +26,31 @@ export type BuildingSelectProps = {
18
26
  chevronIcon?: ReactNode;
19
27
  };
20
28
 
29
+ function useTruncatedTitle(text: string) {
30
+ const ref = useRef<HTMLSpanElement>(null);
31
+ const [isTruncated, setIsTruncated] = useState(false);
32
+
33
+ useLayoutEffect(() => {
34
+ const element = ref.current;
35
+
36
+ if (!element) {
37
+ return;
38
+ }
39
+
40
+ const update = () => {
41
+ setIsTruncated(element.scrollWidth > element.clientWidth + 1);
42
+ };
43
+
44
+ update();
45
+ const observer = new ResizeObserver(update);
46
+ observer.observe(element);
47
+
48
+ return () => observer.disconnect();
49
+ }, [text]);
50
+
51
+ return { ref, title: isTruncated ? text : undefined };
52
+ }
53
+
21
54
  export function BuildingSelect({
22
55
  options,
23
56
  value,
@@ -37,6 +70,7 @@ export function BuildingSelect({
37
70
  const selectedOption = options.find((option) => option.value === value);
38
71
  const displayLabel = selectedOption?.label ?? labels.selectBuilding;
39
72
  const selectableOptions = options.filter((option) => !option.disabled);
73
+ const { ref: labelRef, title: truncatedTitle } = useTruncatedTitle(displayLabel);
40
74
 
41
75
  const closeMenu = useCallback(() => {
42
76
  setOpen(false);
@@ -123,7 +157,7 @@ export function BuildingSelect({
123
157
  };
124
158
 
125
159
  return (
126
- <div ref={rootRef} className={cn('relative w-57.5', className)}>
160
+ <div ref={rootRef} className={cn('relative w-72', className)}>
127
161
  <button
128
162
  type="button"
129
163
  disabled={disabled}
@@ -131,17 +165,21 @@ export function BuildingSelect({
131
165
  aria-expanded={open}
132
166
  aria-controls={listboxId}
133
167
  aria-label={labels.selectBuilding}
168
+ title={truncatedTitle}
134
169
  onClick={() => (open ? closeMenu() : openMenu())}
135
170
  onKeyDown={handleTriggerKeyDown}
136
171
  className={cn(
137
- 'flex h-11 w-full items-center gap-3 rounded-xl border border-storm-gray-50 bg-white p-3 text-left transition-colors',
172
+ 'flex h-11 w-full items-center gap-3 rounded-xl border border-storm-gray-50 bg-white px-3.5 py-3 text-left transition-colors',
138
173
  !disabled && 'cursor-pointer hover:bg-storm-gray-50/60',
139
174
  open && 'bg-storm-gray-50/60',
140
175
  disabled && 'cursor-not-allowed opacity-60',
141
176
  )}
142
177
  >
143
- {buildingIcon ?? <BuildingIcon className="shrink-0 text-navy" />}
144
- <span className="min-w-0 flex-1 truncate typography-14 font-medium text-slate-blue">
178
+ {buildingIcon ?? <BuildingIcon className="size-5 shrink-0 text-navy" />}
179
+ <span
180
+ ref={labelRef}
181
+ className="min-w-0 flex-1 truncate typography-14 font-medium text-slate-blue"
182
+ >
145
183
  {displayLabel}
146
184
  </span>
147
185
  {chevronIcon ?? (
@@ -196,7 +234,9 @@ export function BuildingSelect({
196
234
  option.disabled && 'cursor-not-allowed opacity-50',
197
235
  )}
198
236
  >
199
- <span className="truncate">{option.label}</span>
237
+ <span className="min-w-0 truncate" title={option.label}>
238
+ {option.label}
239
+ </span>
200
240
  </button>
201
241
  </li>
202
242
  );
@@ -218,7 +218,7 @@ export function Sidebar({
218
218
  </button>
219
219
  </div>
220
220
 
221
- <nav className="flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden">
221
+ <nav className="flex w-full min-h-0 flex-1 flex-col gap-2 overflow-x-hidden overflow-y-auto">
222
222
  {visibleNavItems.map((item) => (
223
223
  <SidebarNavItem
224
224
  key={item.to}
@@ -228,27 +228,26 @@ export function Sidebar({
228
228
  onNavigate={handleNavItemNavigate}
229
229
  />
230
230
  ))}
231
+ <div className="mt-auto flex w-full flex-col gap-2">
232
+ {visibleFooterNavItems.map((item) => (
233
+ <SidebarNavItem
234
+ key={item.to}
235
+ item={item}
236
+ isOpen={isExpanded}
237
+ isActive={isNavItemActive(pathname, item)}
238
+ onNavigate={handleNavItemNavigate}
239
+ />
240
+ ))}
241
+ {user ? (
242
+ <SidebarUserCard
243
+ user={user}
244
+ isOpen={isExpanded}
245
+ logoutLabel={labels.logout}
246
+ onLogout={onLogout}
247
+ />
248
+ ) : null}
249
+ </div>
231
250
  </nav>
232
-
233
- <div className="flex w-full flex-col gap-4">
234
- {visibleFooterNavItems.map((item) => (
235
- <SidebarNavItem
236
- key={item.to}
237
- item={item}
238
- isOpen={isExpanded}
239
- isActive={isNavItemActive(pathname, item)}
240
- onNavigate={handleNavItemNavigate}
241
- />
242
- ))}
243
- {user ? (
244
- <SidebarUserCard
245
- user={user}
246
- isOpen={isExpanded}
247
- logoutLabel={labels.logout}
248
- onLogout={onLogout}
249
- />
250
- ) : null}
251
- </div>
252
251
  </aside>
253
252
  </>
254
253
  );
@@ -36,9 +36,9 @@ export function SidebarNavItem({ item, isOpen, isActive, onNavigate }: SidebarNa
36
36
  tabIndex={item.disabled ? -1 : undefined}
37
37
  className={cn(
38
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' : 'justify-center gap-0 px-3',
39
+ isOpen ? 'w-full gap-4' : 'w-full justify-center gap-0 px-3',
40
40
  item.disabled && 'pointer-events-none opacity-50',
41
- isActive ? 'bg-black/12 text-white' : 'text-navy-100 hover:bg-white/5',
41
+ isActive ? 'bg-black/12 text-white shadow-none' : 'text-navy-100 shadow-none hover:bg-white/5',
42
42
  )}
43
43
  >
44
44
  <span
@@ -1,4 +1,5 @@
1
1
  import { LogOut } from 'lucide-react';
2
+ import { useEffect, useId, useRef, useState } from 'react';
2
3
 
3
4
  import { cn } from '../utils/cn';
4
5
  import type { AdminSidebarUser } from './types';
@@ -23,6 +24,9 @@ function getInitials(fullName: string, email: string): string {
23
24
  return email.slice(0, 2).toUpperCase();
24
25
  }
25
26
 
27
+ const avatarCircleClassName =
28
+ 'flex size-11 shrink-0 items-center justify-center rounded-full bg-white/5';
29
+
26
30
  export function SidebarUserCard({
27
31
  user,
28
32
  isOpen,
@@ -31,37 +35,112 @@ export function SidebarUserCard({
31
35
  className,
32
36
  }: SidebarUserCardProps) {
33
37
  const initials = user.initials ?? getInitials(user.fullName, user.email);
38
+ const [logoutOpen, setLogoutOpen] = useState(false);
39
+ const rootRef = useRef<HTMLDivElement>(null);
40
+ const logoutMenuId = useId();
34
41
 
35
- return (
42
+ useEffect(() => {
43
+ if (isOpen) {
44
+ setLogoutOpen(false);
45
+ }
46
+ }, [isOpen]);
47
+
48
+ useEffect(() => {
49
+ if (isOpen || !logoutOpen) {
50
+ return;
51
+ }
52
+
53
+ function handlePointerDown(event: MouseEvent) {
54
+ if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
55
+ setLogoutOpen(false);
56
+ }
57
+ }
58
+
59
+ function handleKeyDown(event: KeyboardEvent) {
60
+ if (event.key === 'Escape') {
61
+ setLogoutOpen(false);
62
+ }
63
+ }
64
+
65
+ document.addEventListener('mousedown', handlePointerDown);
66
+ document.addEventListener('keydown', handleKeyDown);
67
+
68
+ return () => {
69
+ document.removeEventListener('mousedown', handlePointerDown);
70
+ document.removeEventListener('keydown', handleKeyDown);
71
+ };
72
+ }, [isOpen, logoutOpen]);
73
+
74
+ const avatar = (
36
75
  <div
37
- className={cn(
38
- 'flex w-full items-center rounded-xl transition-[background-color,justify-content,gap,padding] duration-300 ease-in-out',
39
- isOpen ? 'justify-between gap-3 bg-black/12 p-3' : 'justify-center px-3 py-3',
40
- className,
41
- )}
76
+ className={cn(avatarCircleClassName, 'typography-14 font-bold text-white')}
77
+ aria-hidden
42
78
  >
79
+ {initials}
80
+ </div>
81
+ );
82
+
83
+ if (!isOpen) {
84
+ return (
43
85
  <div
44
- className="flex size-11 shrink-0 items-center justify-center rounded-full bg-white/5 typography-14 font-bold text-white"
45
- aria-hidden
86
+ ref={rootRef}
87
+ className={cn('flex w-full flex-col items-center gap-2', className)}
46
88
  >
47
- {initials}
48
- </div>
49
- {isOpen ? (
50
- <>
51
- <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
52
- <p className="truncate typography-14 font-bold text-white">{user.fullName}</p>
53
- <p className="truncate typography-12 text-storm-gray-100">{user.email}</p>
54
- </div>
89
+ {logoutOpen ? (
55
90
  <button
56
91
  type="button"
57
- onClick={() => onLogout?.()}
58
- className="shrink-0 cursor-pointer text-navy-150 transition-[opacity,transform] duration-300 ease-in-out hover:opacity-80 active:scale-95"
92
+ id={logoutMenuId}
93
+ onClick={() => {
94
+ setLogoutOpen(false);
95
+ onLogout?.();
96
+ }}
97
+ className={cn(
98
+ avatarCircleClassName,
99
+ 'cursor-pointer bg-black/20 text-white',
100
+ 'transition-[background-color,transform] duration-300 ease-in-out',
101
+ 'hover:bg-white/10 active:scale-95',
102
+ )}
59
103
  aria-label={logoutLabel}
60
104
  >
61
105
  <LogOut size={20} strokeWidth={1.75} />
62
106
  </button>
63
- </>
64
- ) : null}
107
+ ) : null}
108
+ <button
109
+ type="button"
110
+ onClick={() => setLogoutOpen((open) => !open)}
111
+ className="flex cursor-pointer items-center justify-center transition-transform duration-300 ease-in-out active:scale-95"
112
+ aria-expanded={logoutOpen}
113
+ aria-haspopup="true"
114
+ aria-controls={logoutOpen ? logoutMenuId : undefined}
115
+ aria-label={user.fullName}
116
+ >
117
+ {avatar}
118
+ </button>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ return (
124
+ <div
125
+ className={cn(
126
+ 'flex w-full items-center justify-between gap-3 rounded-xl bg-black/12 p-3',
127
+ 'transition-[background-color,justify-content,gap,padding] duration-300 ease-in-out',
128
+ className,
129
+ )}
130
+ >
131
+ {avatar}
132
+ <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
133
+ <p className="truncate typography-14 font-bold text-white">{user.fullName}</p>
134
+ <p className="truncate typography-12 text-storm-gray-100">{user.email}</p>
135
+ </div>
136
+ <button
137
+ type="button"
138
+ onClick={() => onLogout?.()}
139
+ className="shrink-0 cursor-pointer text-navy-150 transition-[opacity,transform] duration-300 ease-in-out hover:opacity-80 active:scale-95"
140
+ aria-label={logoutLabel}
141
+ >
142
+ <LogOut size={20} strokeWidth={1.75} />
143
+ </button>
65
144
  </div>
66
145
  );
67
146
  }
@@ -1,72 +0,0 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
2
-
3
- export type HorizontalScrollState = {
4
- hasOverflow: boolean;
5
- atEnd: boolean;
6
- };
7
-
8
- const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
9
-
10
- function getHorizontalScrollState(element: HTMLElement): HorizontalScrollState {
11
- const inner = element.firstElementChild as HTMLElement | null;
12
- const table = inner?.querySelector('table');
13
- const contentWidth = table?.scrollWidth ?? inner?.scrollWidth ?? element.scrollWidth;
14
- const viewportWidth = element.clientWidth;
15
- const maxScrollLeft = element.scrollWidth - element.clientWidth;
16
- const hasOverflow = contentWidth > viewportWidth + 1;
17
- const atEnd = !hasOverflow || element.scrollLeft >= maxScrollLeft - 1;
18
-
19
- return { hasOverflow, atEnd };
20
- }
21
-
22
- export function useHorizontalScrollState() {
23
- const scrollRef = useRef<HTMLDivElement>(null);
24
- const [scrollState, setScrollState] = useState<HorizontalScrollState>({
25
- hasOverflow: false,
26
- atEnd: true,
27
- });
28
-
29
- const updateScrollState = useCallback(() => {
30
- const element = scrollRef.current;
31
- if (!element) {
32
- return;
33
- }
34
-
35
- setScrollState(getHorizontalScrollState(element));
36
- }, []);
37
-
38
- useIsomorphicLayoutEffect(() => {
39
- const element = scrollRef.current;
40
- if (!element) {
41
- return;
42
- }
43
-
44
- updateScrollState();
45
-
46
- element.addEventListener('scroll', updateScrollState, { passive: true });
47
-
48
- if (typeof ResizeObserver === 'undefined') {
49
- window.addEventListener('resize', updateScrollState);
50
-
51
- return () => {
52
- element.removeEventListener('scroll', updateScrollState);
53
- window.removeEventListener('resize', updateScrollState);
54
- };
55
- }
56
-
57
- const resizeObserver = new ResizeObserver(updateScrollState);
58
- resizeObserver.observe(element);
59
-
60
- const inner = element.firstElementChild;
61
- if (inner) {
62
- resizeObserver.observe(inner);
63
- }
64
-
65
- return () => {
66
- element.removeEventListener('scroll', updateScrollState);
67
- resizeObserver.disconnect();
68
- };
69
- }, [updateScrollState]);
70
-
71
- return { scrollRef, scrollState };
72
- }