@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/dist/{chunk-JNVYKEYT.js → chunk-N73KHOZ4.js} +4 -4
- package/dist/{chunk-JNVYKEYT.js.map → chunk-N73KHOZ4.js.map} +1 -1
- package/dist/index.cjs +12 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/web/index.cjs +165 -131
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.css +8 -29
- package/dist/web/index.css.map +1 -1
- package/dist/web/index.d.cts +1 -3
- package/dist/web/index.d.ts +1 -3
- package/dist/web/index.js +165 -131
- package/dist/web/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Input.tsx +2 -2
- package/src/components/Select.tsx +9 -1
- package/src/web/components/Modal.tsx +2 -1
- package/src/web/components/Table.stories.tsx +2 -2
- package/src/web/components/drawer-scroll.css +11 -2
- package/src/web/components/table/TablePrimitives.tsx +3 -15
- package/src/web/components/table/TableScrollArea.tsx +1 -12
- package/src/web/components/table/constants.ts +0 -8
- package/src/web/components/table/index.ts +0 -3
- package/src/web/components/table-scroll.css +0 -27
- package/src/web/layout/BuildingSelect.tsx +46 -6
- package/src/web/layout/Sidebar.tsx +20 -21
- package/src/web/layout/SidebarNavItem.tsx +2 -2
- package/src/web/layout/SidebarUserCard.tsx +99 -20
- package/src/web/components/table/use-horizontal-scroll-state.ts +0 -72
package/package.json
CHANGED
package/src/components/Input.tsx
CHANGED
|
@@ -280,8 +280,8 @@ const styles = StyleSheet.create({
|
|
|
280
280
|
gap: spacing.sm,
|
|
281
281
|
},
|
|
282
282
|
iconSlot: {
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
'
|
|
196
|
+
'whitespace-nowrap',
|
|
207
197
|
tableCellPaddingClassName,
|
|
208
198
|
tableBodyClassName,
|
|
209
|
-
|
|
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="
|
|
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 {
|
|
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-
|
|
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
|
|
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
|
|
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"
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
86
|
+
ref={rootRef}
|
|
87
|
+
className={cn('flex w-full flex-col items-center gap-2', className)}
|
|
46
88
|
>
|
|
47
|
-
{
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
}
|