even-toolkit 1.4.1 → 1.5.0
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/CHANGELOG.md +27 -0
- package/README.md +53 -5
- package/dist/glasses/action-bar.d.ts +3 -3
- package/dist/glasses/action-bar.js +7 -7
- package/dist/glasses/action-bar.js.map +1 -1
- package/dist/glasses/action-map.js +3 -3
- package/dist/glasses/action-map.js.map +1 -1
- package/dist/glasses/gestures.d.ts +4 -0
- package/dist/glasses/gestures.d.ts.map +1 -1
- package/dist/glasses/gestures.js +44 -0
- package/dist/glasses/gestures.js.map +1 -1
- package/dist/glasses/glass-chat-display.d.ts +43 -0
- package/dist/glasses/glass-chat-display.d.ts.map +1 -0
- package/dist/glasses/glass-chat-display.js +102 -0
- package/dist/glasses/glass-chat-display.js.map +1 -0
- package/dist/glasses/glass-format.d.ts +50 -0
- package/dist/glasses/glass-format.d.ts.map +1 -0
- package/dist/glasses/glass-format.js +65 -0
- package/dist/glasses/glass-format.js.map +1 -0
- package/dist/glasses/index.d.ts +2 -0
- package/dist/glasses/index.d.ts.map +1 -1
- package/dist/glasses/index.js +2 -0
- package/dist/glasses/index.js.map +1 -1
- package/dist/glasses/useGlasses.js +1 -1
- package/dist/glasses/useGlasses.js.map +1 -1
- package/dist/stt/providers/deepgram.d.ts +1 -0
- package/dist/stt/providers/deepgram.d.ts.map +1 -1
- package/dist/stt/providers/deepgram.js +25 -8
- package/dist/stt/providers/deepgram.js.map +1 -1
- package/dist/web/components/dialog.d.ts.map +1 -1
- package/dist/web/components/dialog.js +16 -1
- package/dist/web/components/dialog.js.map +1 -1
- package/dist/web/components/drawer-shell.d.ts +19 -0
- package/dist/web/components/drawer-shell.d.ts.map +1 -0
- package/dist/web/components/drawer-shell.js +59 -0
- package/dist/web/components/drawer-shell.js.map +1 -0
- package/dist/web/components/list-item.d.ts +1 -1
- package/dist/web/components/list-item.d.ts.map +1 -1
- package/dist/web/components/list-item.js +20 -5
- package/dist/web/components/list-item.js.map +1 -1
- package/dist/web/components/multi-select.d.ts +22 -0
- package/dist/web/components/multi-select.d.ts.map +1 -0
- package/dist/web/components/multi-select.js +52 -0
- package/dist/web/components/multi-select.js.map +1 -0
- package/dist/web/components/select.d.ts +13 -3
- package/dist/web/components/select.d.ts.map +1 -1
- package/dist/web/components/select.js +36 -3
- package/dist/web/components/select.js.map +1 -1
- package/dist/web/components/side-drawer.d.ts +43 -0
- package/dist/web/components/side-drawer.d.ts.map +1 -0
- package/dist/web/components/side-drawer.js +88 -0
- package/dist/web/components/side-drawer.js.map +1 -0
- package/dist/web/icons/svg-icons.js +1 -1
- package/dist/web/icons/svg-icons.js.map +1 -1
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.d.ts.map +1 -1
- package/dist/web/index.js +3 -0
- package/dist/web/index.js.map +1 -1
- package/glasses/action-bar.ts +7 -7
- package/glasses/action-map.ts +3 -3
- package/glasses/gestures.ts +50 -0
- package/glasses/glass-chat-display.ts +152 -0
- package/glasses/glass-format.ts +75 -0
- package/glasses/index.ts +2 -0
- package/glasses/useGlasses.ts +1 -1
- package/package.json +10 -1
- package/stt/providers/deepgram.ts +23 -7
- package/web/components/dialog.tsx +20 -1
- package/web/components/drawer-shell.tsx +145 -0
- package/web/components/list-item.tsx +25 -10
- package/web/components/multi-select.tsx +118 -0
- package/web/components/select.tsx +90 -20
- package/web/components/side-drawer.tsx +246 -0
- package/web/icons/svg-icons.tsx +1 -2
- package/web/index.ts +9 -0
- package/web/theme/utilities.css +11 -0
|
@@ -2,6 +2,7 @@ import { cn } from '../utils/cn';
|
|
|
2
2
|
import { useRef, useState, useCallback } from 'react';
|
|
3
3
|
import type { ReactNode, TouchEvent as ReactTouchEvent } from 'react';
|
|
4
4
|
import { IcEditTrash } from '../icons/svg-icons';
|
|
5
|
+
import { Loading } from './loading';
|
|
5
6
|
|
|
6
7
|
interface ListItemProps {
|
|
7
8
|
title: string;
|
|
@@ -9,7 +10,7 @@ interface ListItemProps {
|
|
|
9
10
|
leading?: ReactNode;
|
|
10
11
|
trailing?: ReactNode;
|
|
11
12
|
onPress?: () => void;
|
|
12
|
-
onDelete?: () => void
|
|
13
|
+
onDelete?: () => void | Promise<void>;
|
|
13
14
|
className?: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -20,22 +21,23 @@ const DIRECTION_LOCK_PX = 10;
|
|
|
20
21
|
function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, className }: ListItemProps) {
|
|
21
22
|
const [offset, setOffset] = useState(0);
|
|
22
23
|
const [swiping, setSwiping] = useState(false);
|
|
24
|
+
const [deleting, setDeleting] = useState(false);
|
|
23
25
|
const startX = useRef(0);
|
|
24
26
|
const startY = useRef(0);
|
|
25
27
|
const currentOffset = useRef(0);
|
|
26
28
|
const direction = useRef<'none' | 'horizontal' | 'vertical'>('none');
|
|
27
29
|
|
|
28
30
|
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
|
29
|
-
if (!onDelete) return;
|
|
31
|
+
if (!onDelete || deleting) return;
|
|
30
32
|
startX.current = e.touches[0].clientX;
|
|
31
33
|
startY.current = e.touches[0].clientY;
|
|
32
34
|
currentOffset.current = offset;
|
|
33
35
|
direction.current = 'none';
|
|
34
36
|
setSwiping(true);
|
|
35
|
-
}, [onDelete, offset]);
|
|
37
|
+
}, [deleting, onDelete, offset]);
|
|
36
38
|
|
|
37
39
|
const onTouchMove = useCallback((e: ReactTouchEvent) => {
|
|
38
|
-
if (!swiping) return;
|
|
40
|
+
if (!swiping || deleting) return;
|
|
39
41
|
const dx = e.touches[0].clientX - startX.current;
|
|
40
42
|
const dy = e.touches[0].clientY - startY.current;
|
|
41
43
|
|
|
@@ -52,7 +54,7 @@ function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, class
|
|
|
52
54
|
|
|
53
55
|
const next = Math.min(0, Math.max(-DELETE_WIDTH, currentOffset.current + dx));
|
|
54
56
|
setOffset(next);
|
|
55
|
-
}, [swiping]);
|
|
57
|
+
}, [deleting, swiping]);
|
|
56
58
|
|
|
57
59
|
const onTouchEnd = useCallback(() => {
|
|
58
60
|
if (!swiping) return;
|
|
@@ -61,6 +63,18 @@ function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, class
|
|
|
61
63
|
setOffset(offset < -SWIPE_THRESHOLD / 2 ? -DELETE_WIDTH : 0);
|
|
62
64
|
}, [swiping, offset]);
|
|
63
65
|
|
|
66
|
+
const handleDeleteClick = useCallback(async () => {
|
|
67
|
+
if (!onDelete || deleting) return;
|
|
68
|
+
setDeleting(true);
|
|
69
|
+
try {
|
|
70
|
+
await Promise.resolve(onDelete());
|
|
71
|
+
} finally {
|
|
72
|
+
setDeleting(false);
|
|
73
|
+
setOffset(0);
|
|
74
|
+
direction.current = 'none';
|
|
75
|
+
}
|
|
76
|
+
}, [deleting, onDelete]);
|
|
77
|
+
|
|
64
78
|
const Comp = onPress ? 'button' : 'div';
|
|
65
79
|
|
|
66
80
|
return (
|
|
@@ -68,22 +82,23 @@ function ListItem({ title, subtitle, leading, trailing, onPress, onDelete, class
|
|
|
68
82
|
{onDelete && offset < 0 && (
|
|
69
83
|
<button
|
|
70
84
|
type="button"
|
|
71
|
-
onClick={
|
|
72
|
-
|
|
85
|
+
onClick={handleDeleteClick}
|
|
86
|
+
disabled={deleting}
|
|
87
|
+
className="absolute right-0 top-0 bottom-0 flex items-center justify-center bg-negative text-text-highlight cursor-pointer disabled:cursor-default"
|
|
73
88
|
style={{ width: DELETE_WIDTH }}
|
|
74
89
|
>
|
|
75
|
-
<IcEditTrash width={20} height={20} />
|
|
90
|
+
{deleting ? <Loading size={18} className="text-text-highlight" /> : <IcEditTrash width={20} height={20} />}
|
|
76
91
|
</button>
|
|
77
92
|
)}
|
|
78
93
|
<Comp
|
|
79
94
|
type={onPress ? 'button' : undefined}
|
|
80
|
-
onClick={onPress}
|
|
95
|
+
onClick={deleting ? undefined : onPress}
|
|
81
96
|
onTouchStart={onTouchStart}
|
|
82
97
|
onTouchMove={onTouchMove}
|
|
83
98
|
onTouchEnd={onTouchEnd}
|
|
84
99
|
className={cn(
|
|
85
100
|
'flex items-center gap-4 w-full bg-surface p-4 text-left transition-colors relative',
|
|
86
|
-
onPress && 'cursor-pointer hover:bg-surface-light',
|
|
101
|
+
onPress && !deleting && 'cursor-pointer hover:bg-surface-light',
|
|
87
102
|
className,
|
|
88
103
|
)}
|
|
89
104
|
style={{
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
import { IcStatusCheckbox, IcStatusSelectedBox } from '../icons/svg-icons';
|
|
4
|
+
|
|
5
|
+
interface MultiSelectOption {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface MultiSelectProps {
|
|
11
|
+
values: string[];
|
|
12
|
+
options: MultiSelectOption[];
|
|
13
|
+
onValuesChange: (values: string[]) => void;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
className?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Custom multi-select dropdown with checkboxes. No native <select>.
|
|
21
|
+
*/
|
|
22
|
+
function MultiSelect({ values, options, onValuesChange, placeholder, className, disabled }: MultiSelectProps) {
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
const selectedLabels = options
|
|
27
|
+
.filter((o) => values.includes(o.value))
|
|
28
|
+
.map((o) => o.label);
|
|
29
|
+
|
|
30
|
+
const displayText = selectedLabels.length > 0
|
|
31
|
+
? selectedLabels.join(', ')
|
|
32
|
+
: placeholder ?? 'Select...';
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!open) return;
|
|
36
|
+
const handler = (e: MouseEvent) => {
|
|
37
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
38
|
+
};
|
|
39
|
+
document.addEventListener('mousedown', handler);
|
|
40
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
41
|
+
}, [open]);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
|
46
|
+
document.addEventListener('keydown', handler);
|
|
47
|
+
return () => document.removeEventListener('keydown', handler);
|
|
48
|
+
}, [open]);
|
|
49
|
+
|
|
50
|
+
const toggleValue = useCallback((v: string) => {
|
|
51
|
+
if (values.includes(v)) {
|
|
52
|
+
onValuesChange(values.filter((x) => x !== v));
|
|
53
|
+
} else {
|
|
54
|
+
onValuesChange([...values, v]);
|
|
55
|
+
}
|
|
56
|
+
}, [values, onValuesChange]);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div ref={ref} className={cn('relative', className)} style={{ minWidth: 0 }}>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
onClick={() => !disabled && setOpen(!open)}
|
|
64
|
+
className={cn(
|
|
65
|
+
'h-9 w-full bg-input-bg text-text rounded-[6px] pl-3 pr-8 text-[13px] tracking-[-0.13px] text-left cursor-pointer border-none flex items-center',
|
|
66
|
+
'transition-colors hover:bg-surface-light',
|
|
67
|
+
disabled && 'opacity-50 cursor-default',
|
|
68
|
+
selectedLabels.length === 0 && 'text-text-dim',
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
<span className="truncate flex-1">{displayText}</span>
|
|
72
|
+
<svg
|
|
73
|
+
className="absolute right-3 top-1/2 text-text-dim shrink-0"
|
|
74
|
+
width="10"
|
|
75
|
+
height="10"
|
|
76
|
+
viewBox="0 0 10 10"
|
|
77
|
+
fill="none"
|
|
78
|
+
style={{ transform: open ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%)', transition: 'transform 150ms ease' }}
|
|
79
|
+
>
|
|
80
|
+
<path d="M2.5 3.75L5 6.25L7.5 3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
81
|
+
</svg>
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{open && (
|
|
85
|
+
<div
|
|
86
|
+
className="absolute z-50 top-full left-0 right-0 mt-1 bg-surface rounded-[6px] border border-border overflow-hidden"
|
|
87
|
+
style={{ maxHeight: 200, overflowY: 'auto', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
|
88
|
+
>
|
|
89
|
+
{options.map((o) => {
|
|
90
|
+
const checked = values.includes(o.value);
|
|
91
|
+
return (
|
|
92
|
+
<button
|
|
93
|
+
key={o.value}
|
|
94
|
+
type="button"
|
|
95
|
+
className={cn(
|
|
96
|
+
'w-full text-left px-3 py-2 text-[13px] tracking-[-0.13px] cursor-pointer border-none transition-colors font-normal flex items-center gap-2',
|
|
97
|
+
checked ? 'bg-accent/5' : 'bg-transparent hover:bg-surface-light',
|
|
98
|
+
)}
|
|
99
|
+
onClick={() => toggleValue(o.value)}
|
|
100
|
+
>
|
|
101
|
+
{checked
|
|
102
|
+
? <IcStatusSelectedBox width={18} height={18} className="shrink-0" />
|
|
103
|
+
: <IcStatusCheckbox width={18} height={18} className="shrink-0 text-text-dim" />
|
|
104
|
+
}
|
|
105
|
+
<span className="text-text">{o.label}</span>
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
MultiSelect.displayName = 'MultiSelect';
|
|
116
|
+
|
|
117
|
+
export { MultiSelect };
|
|
118
|
+
export type { MultiSelectProps, MultiSelectOption };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
2
2
|
import { cn } from '../utils/cn';
|
|
3
3
|
|
|
4
4
|
interface SelectOption {
|
|
@@ -6,30 +6,100 @@ interface SelectOption {
|
|
|
6
6
|
label: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
interface SelectProps
|
|
9
|
+
interface SelectProps {
|
|
10
|
+
value?: string;
|
|
10
11
|
options: SelectOption[];
|
|
11
12
|
onValueChange?: (value: string) => void;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
dropdownPosition?: 'top' | 'bottom';
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Custom dropdown — no native <select>. Fully styled, no browser arrow issues.
|
|
21
|
+
*/
|
|
22
|
+
function Select({ value, options, onValueChange, placeholder, className, disabled, dropdownPosition = 'bottom' }: SelectProps) {
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
const selected = options.find((o) => o.value === value);
|
|
27
|
+
const label = selected?.label ?? placeholder ?? 'Select...';
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return;
|
|
31
|
+
const handler = (e: MouseEvent) => {
|
|
32
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
33
|
+
};
|
|
34
|
+
document.addEventListener('mousedown', handler);
|
|
35
|
+
return () => document.removeEventListener('mousedown', handler);
|
|
36
|
+
}, [open]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
|
41
|
+
document.addEventListener('keydown', handler);
|
|
42
|
+
return () => document.removeEventListener('keydown', handler);
|
|
43
|
+
}, [open]);
|
|
44
|
+
|
|
45
|
+
const handleSelect = useCallback((v: string) => {
|
|
46
|
+
onValueChange?.(v);
|
|
47
|
+
setOpen(false);
|
|
48
|
+
}, [onValueChange]);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div ref={ref} className={cn('relative', className)} style={{ minWidth: 0 }}>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
onClick={() => !disabled && setOpen(!open)}
|
|
56
|
+
className={cn(
|
|
57
|
+
'h-9 w-full bg-input-bg text-text rounded-[6px] pl-3 pr-8 text-[13px] tracking-[-0.13px] text-left cursor-pointer border-none flex items-center',
|
|
58
|
+
'transition-colors hover:bg-surface-light',
|
|
59
|
+
disabled && 'opacity-50 cursor-default',
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
<span className="truncate flex-1">{label}</span>
|
|
63
|
+
<svg
|
|
64
|
+
className="absolute right-3 top-1/2 text-text-dim shrink-0"
|
|
65
|
+
width="10"
|
|
66
|
+
height="10"
|
|
67
|
+
viewBox="0 0 10 10"
|
|
68
|
+
fill="none"
|
|
69
|
+
style={{ transform: open ? 'translateY(-50%) rotate(180deg)' : 'translateY(-50%)', transition: 'transform 150ms ease' }}
|
|
70
|
+
>
|
|
71
|
+
<path d="M2.5 3.75L5 6.25L7.5 3.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
72
|
+
</svg>
|
|
73
|
+
</button>
|
|
74
|
+
|
|
75
|
+
{open && (
|
|
76
|
+
<div
|
|
77
|
+
className={cn(
|
|
78
|
+
'absolute z-50 left-0 right-0 bg-surface rounded-[6px] border border-border overflow-hidden',
|
|
79
|
+
dropdownPosition === 'top' ? 'bottom-full mb-1' : 'top-full mt-1',
|
|
80
|
+
)}
|
|
81
|
+
style={{ maxHeight: 200, overflowY: 'auto', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
|
82
|
+
>
|
|
83
|
+
{options.map((o) => (
|
|
84
|
+
<button
|
|
85
|
+
key={o.value}
|
|
86
|
+
type="button"
|
|
87
|
+
className={cn(
|
|
88
|
+
'w-full text-left px-3 py-2 text-[13px] tracking-[-0.13px] cursor-pointer border-none transition-colors font-normal',
|
|
89
|
+
o.value === value
|
|
90
|
+
? 'bg-accent text-text-highlight'
|
|
91
|
+
: 'bg-transparent text-text hover:bg-surface-light',
|
|
92
|
+
)}
|
|
93
|
+
onClick={() => handleSelect(o.value)}
|
|
94
|
+
>
|
|
95
|
+
{o.label}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
21
99
|
)}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
{options.map((o) => (
|
|
26
|
-
<option key={o.value} value={o.value}>
|
|
27
|
-
{o.label}
|
|
28
|
-
</option>
|
|
29
|
-
))}
|
|
30
|
-
</select>
|
|
31
|
-
),
|
|
32
|
-
);
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
33
103
|
|
|
34
104
|
Select.displayName = 'Select';
|
|
35
105
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../utils/cn';
|
|
3
|
+
|
|
4
|
+
/* ── Types ── */
|
|
5
|
+
|
|
6
|
+
interface SideDrawerItem {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
section?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SideDrawerProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
onNavigate: (id: string) => void;
|
|
17
|
+
activeId: string;
|
|
18
|
+
items: SideDrawerItem[];
|
|
19
|
+
bottomItems?: SideDrawerItem[];
|
|
20
|
+
title?: string;
|
|
21
|
+
footer?: ReactNode;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
width?: number;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* ── Constants ── */
|
|
28
|
+
|
|
29
|
+
const DEFAULT_WIDTH = 280;
|
|
30
|
+
const SHIFT_EXTRA = 20;
|
|
31
|
+
const MAIN_SCALE = 0.985;
|
|
32
|
+
const TRANSITION = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1), border-radius 300ms ease';
|
|
33
|
+
const SIDEBAR_TRANSITION = 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1), opacity 300ms ease';
|
|
34
|
+
|
|
35
|
+
/* ── Shared item renderer ── */
|
|
36
|
+
|
|
37
|
+
function renderItemSection(
|
|
38
|
+
sectionName: string,
|
|
39
|
+
sectionItems: SideDrawerItem[],
|
|
40
|
+
activeId: string,
|
|
41
|
+
onNavigate: (id: string) => void,
|
|
42
|
+
) {
|
|
43
|
+
return (
|
|
44
|
+
<div key={sectionName || '__default'}>
|
|
45
|
+
{sectionName && (
|
|
46
|
+
<div className="px-3 pt-4 pb-1.5">
|
|
47
|
+
<span className="text-[11px] tracking-[-0.11px] text-text-dim font-normal uppercase">
|
|
48
|
+
{sectionName}
|
|
49
|
+
</span>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
{sectionItems.map((item) => {
|
|
53
|
+
const isActive = activeId === item.id || (item.id !== '/' && activeId.startsWith(item.id));
|
|
54
|
+
return (
|
|
55
|
+
<button
|
|
56
|
+
key={item.id}
|
|
57
|
+
onClick={() => onNavigate(item.id)}
|
|
58
|
+
className={cn(
|
|
59
|
+
'w-full flex items-center gap-3 px-3 h-11 rounded-[6px] mb-0.5 text-left cursor-pointer border-none',
|
|
60
|
+
'transition-colors',
|
|
61
|
+
isActive
|
|
62
|
+
? 'bg-surface-light text-accent'
|
|
63
|
+
: 'text-text hover:bg-surface-light/50',
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{item.icon && <span className="shrink-0 w-5 flex items-center justify-center">{item.icon}</span>}
|
|
67
|
+
<span className="text-[15px] tracking-[-0.15px] font-normal">{item.label}</span>
|
|
68
|
+
</button>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function groupBySection(items: SideDrawerItem[]): Map<string, SideDrawerItem[]> {
|
|
76
|
+
const sections = new Map<string, SideDrawerItem[]>();
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
const key = item.section ?? '';
|
|
79
|
+
if (!sections.has(key)) sections.set(key, []);
|
|
80
|
+
sections.get(key)!.push(item);
|
|
81
|
+
}
|
|
82
|
+
return sections;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* ── SideDrawer ── */
|
|
86
|
+
|
|
87
|
+
function SideDrawer({
|
|
88
|
+
open,
|
|
89
|
+
onClose,
|
|
90
|
+
onNavigate,
|
|
91
|
+
activeId,
|
|
92
|
+
items,
|
|
93
|
+
bottomItems,
|
|
94
|
+
title,
|
|
95
|
+
footer,
|
|
96
|
+
children,
|
|
97
|
+
width = DEFAULT_WIDTH,
|
|
98
|
+
className,
|
|
99
|
+
}: SideDrawerProps) {
|
|
100
|
+
// Close on Escape key
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!open) return;
|
|
103
|
+
const handler = (e: KeyboardEvent) => {
|
|
104
|
+
if (e.key === 'Escape') onClose();
|
|
105
|
+
};
|
|
106
|
+
document.addEventListener('keydown', handler);
|
|
107
|
+
return () => document.removeEventListener('keydown', handler);
|
|
108
|
+
}, [open, onClose]);
|
|
109
|
+
|
|
110
|
+
const shift = width + SHIFT_EXTRA;
|
|
111
|
+
const sections = groupBySection(items);
|
|
112
|
+
const bottomSections = bottomItems ? groupBySection(bottomItems) : null;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className={cn('relative h-dvh w-full overflow-hidden bg-bg', className)}>
|
|
116
|
+
{/* Sidebar panel */}
|
|
117
|
+
<div
|
|
118
|
+
className="absolute top-0 left-0 bottom-0 flex flex-col bg-bg z-0"
|
|
119
|
+
style={{
|
|
120
|
+
width: `${width}px`,
|
|
121
|
+
transform: open ? 'translateX(0)' : `translateX(-${width * 0.3}px)`,
|
|
122
|
+
opacity: open ? 1 : 0,
|
|
123
|
+
transition: SIDEBAR_TRANSITION,
|
|
124
|
+
pointerEvents: open ? 'auto' : 'none',
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{/* Header */}
|
|
128
|
+
{title && (
|
|
129
|
+
<div className="shrink-0 px-4 pt-4 pb-3">
|
|
130
|
+
<span className="text-[20px] tracking-[-0.6px] font-normal text-text">{title}</span>
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Menu items */}
|
|
135
|
+
<div className="flex-1 overflow-y-auto px-2 py-1">
|
|
136
|
+
{[...sections.entries()].map(([name, items]) =>
|
|
137
|
+
renderItemSection(name, items, activeId, onNavigate),
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Bottom items */}
|
|
142
|
+
{bottomSections && (
|
|
143
|
+
<div className="shrink-0 border-t border-border px-2 py-1">
|
|
144
|
+
{[...bottomSections.entries()].map(([name, items]) =>
|
|
145
|
+
renderItemSection(name, items, activeId, onNavigate),
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Footer */}
|
|
151
|
+
{footer && (
|
|
152
|
+
<div className={cn('shrink-0 px-2 py-2', !bottomSections && 'border-t border-border')}>
|
|
153
|
+
{footer}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Main content */}
|
|
159
|
+
<div
|
|
160
|
+
className="relative h-full w-full z-[1] bg-bg overflow-hidden"
|
|
161
|
+
style={{
|
|
162
|
+
transform: open
|
|
163
|
+
? `translateX(${shift}px) scale(${MAIN_SCALE})`
|
|
164
|
+
: 'translateX(0) scale(1)',
|
|
165
|
+
borderRadius: open ? '16px' : '0px',
|
|
166
|
+
transition: TRANSITION,
|
|
167
|
+
transformOrigin: 'left center',
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{children}
|
|
171
|
+
|
|
172
|
+
{/* Dark overlay */}
|
|
173
|
+
<div
|
|
174
|
+
onClick={onClose}
|
|
175
|
+
className="absolute inset-0 bg-black cursor-pointer z-[2]"
|
|
176
|
+
style={{
|
|
177
|
+
opacity: open ? 0.45 : 0,
|
|
178
|
+
pointerEvents: open ? 'auto' : 'none',
|
|
179
|
+
transition: 'opacity 300ms ease',
|
|
180
|
+
}}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ── DrawerTrigger (hamburger button) ── */
|
|
188
|
+
|
|
189
|
+
function DrawerTrigger({ onClick, className }: { onClick: () => void; className?: string }) {
|
|
190
|
+
return (
|
|
191
|
+
<button
|
|
192
|
+
onClick={onClick}
|
|
193
|
+
className={cn(
|
|
194
|
+
'flex items-center justify-center w-9 h-9 rounded-[6px] cursor-pointer border-none bg-transparent',
|
|
195
|
+
'text-text hover:bg-surface-light transition-colors',
|
|
196
|
+
className,
|
|
197
|
+
)}
|
|
198
|
+
aria-label="Open menu"
|
|
199
|
+
>
|
|
200
|
+
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
201
|
+
<path d="M1 1h16M1 7h16M1 13h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
202
|
+
</svg>
|
|
203
|
+
</button>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ── Drawer Header Context ── */
|
|
208
|
+
|
|
209
|
+
interface DrawerHeaderConfig {
|
|
210
|
+
title?: string;
|
|
211
|
+
left?: ReactNode;
|
|
212
|
+
right?: ReactNode;
|
|
213
|
+
below?: ReactNode;
|
|
214
|
+
footer?: ReactNode;
|
|
215
|
+
backTo?: string;
|
|
216
|
+
hidden?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
interface DrawerHeaderContextValue {
|
|
220
|
+
setHeader: (config: DrawerHeaderConfig) => void;
|
|
221
|
+
resetHeader: () => void;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const DrawerHeaderContext = createContext<DrawerHeaderContextValue | null>(null);
|
|
225
|
+
|
|
226
|
+
function useDrawerHeader(config: DrawerHeaderConfig): void {
|
|
227
|
+
const ctx = useContext(DrawerHeaderContext);
|
|
228
|
+
const configRef = useMemo(() => config, [
|
|
229
|
+
config.title,
|
|
230
|
+
config.left,
|
|
231
|
+
config.right,
|
|
232
|
+
config.below,
|
|
233
|
+
config.footer,
|
|
234
|
+
config.backTo,
|
|
235
|
+
config.hidden,
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
useLayoutEffect(() => {
|
|
239
|
+
if (!ctx) return;
|
|
240
|
+
ctx.setHeader(configRef);
|
|
241
|
+
return () => ctx.resetHeader();
|
|
242
|
+
}, [ctx, configRef]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export { SideDrawer, DrawerTrigger, DrawerHeaderContext, useDrawerHeader };
|
|
246
|
+
export type { SideDrawerProps, SideDrawerItem, DrawerHeaderConfig, DrawerHeaderContextValue };
|
package/web/icons/svg-icons.tsx
CHANGED
|
@@ -486,7 +486,7 @@ export const IcFeatMessage: SvgIcon = (props) => (
|
|
|
486
486
|
export const IcFeatNavigate: SvgIcon = (props) => (
|
|
487
487
|
<svg viewBox="0 0 32 32" fill="none" {...props}>
|
|
488
488
|
<g clipPath="url(#clip0_10001_76324)">
|
|
489
|
-
<path d="M6 30H4V12H6V30ZM22 20H20V18H22V20ZM24 18H22V16H24V18ZM26 16H24V14H26V16ZM28 14H26V12H28V14ZM26 12H6V10H26V12ZM30 12H28V10H30V12ZM28 10H26V8H28V10ZM26 8H24V6H26V8ZM24 6H22V4H24V6ZM22 2V4H20V2H22Z" fill="
|
|
489
|
+
<path d="M6 30H4V12H6V30ZM22 20H20V18H22V20ZM24 18H22V16H24V18ZM26 16H24V14H26V16ZM28 14H26V12H28V14ZM26 12H6V10H26V12ZM30 12H28V10H30V12ZM28 10H26V8H28V10ZM26 8H24V6H26V8ZM24 6H22V4H24V6ZM22 2V4H20V2H22Z" fill="currentColor"/>
|
|
490
490
|
</g>
|
|
491
491
|
<defs>
|
|
492
492
|
<clipPath id="clip0_10001_76324">
|
|
@@ -2163,4 +2163,3 @@ export const IcShare = IcEditShare;
|
|
|
2163
2163
|
export const IcCopy = IcEditCopy;
|
|
2164
2164
|
export const IcCheck = IcStatusCheckmark;
|
|
2165
2165
|
export const IcMore = IcStatusMore;
|
|
2166
|
-
|
package/web/index.ts
CHANGED
|
@@ -40,6 +40,9 @@ export type { PillProps } from './components/pill';
|
|
|
40
40
|
export { Toggle } from './components/toggle';
|
|
41
41
|
export type { ToggleProps } from './components/toggle';
|
|
42
42
|
|
|
43
|
+
export { MultiSelect } from './components/multi-select';
|
|
44
|
+
export type { MultiSelectProps, MultiSelectOption } from './components/multi-select';
|
|
45
|
+
|
|
43
46
|
export { SegmentedControl } from './components/segmented-control';
|
|
44
47
|
export type { SegmentedControlProps, SegmentedControlOption } from './components/segmented-control';
|
|
45
48
|
|
|
@@ -167,6 +170,12 @@ export type { AudioPlayerProps } from './components/audio-player';
|
|
|
167
170
|
export { AppShell } from './components/app-shell';
|
|
168
171
|
export type { AppShellProps } from './components/app-shell';
|
|
169
172
|
|
|
173
|
+
export { SideDrawer, DrawerTrigger, DrawerHeaderContext, useDrawerHeader } from './components/side-drawer';
|
|
174
|
+
export type { SideDrawerProps, SideDrawerItem, DrawerHeaderConfig, DrawerHeaderContextValue } from './components/side-drawer';
|
|
175
|
+
|
|
176
|
+
export { DrawerShell } from './components/drawer-shell';
|
|
177
|
+
export type { DrawerShellProps } from './components/drawer-shell';
|
|
178
|
+
|
|
170
179
|
// Scroll Picker
|
|
171
180
|
export { ScrollPicker, DatePicker, TimePicker, SelectionPicker } from './components/scroll-picker';
|
|
172
181
|
export type { ScrollPickerProps, ScrollColumn, DatePickerProps, TimePickerProps, SelectionPickerProps } from './components/scroll-picker';
|
package/web/theme/utilities.css
CHANGED
|
@@ -23,6 +23,17 @@
|
|
|
23
23
|
transition: stroke-dashoffset 0.5s ease;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/* Force hide native select arrow on all browsers */
|
|
27
|
+
select {
|
|
28
|
+
-webkit-appearance: none !important;
|
|
29
|
+
-moz-appearance: none !important;
|
|
30
|
+
appearance: none !important;
|
|
31
|
+
background-image: none !important;
|
|
32
|
+
}
|
|
33
|
+
select::-ms-expand {
|
|
34
|
+
display: none;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
/* Bottom sheet slide-up animation */
|
|
27
38
|
@keyframes slideUp {
|
|
28
39
|
from { transform: translateY(100%); }
|