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.
Files changed (76) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +53 -5
  3. package/dist/glasses/action-bar.d.ts +3 -3
  4. package/dist/glasses/action-bar.js +7 -7
  5. package/dist/glasses/action-bar.js.map +1 -1
  6. package/dist/glasses/action-map.js +3 -3
  7. package/dist/glasses/action-map.js.map +1 -1
  8. package/dist/glasses/gestures.d.ts +4 -0
  9. package/dist/glasses/gestures.d.ts.map +1 -1
  10. package/dist/glasses/gestures.js +44 -0
  11. package/dist/glasses/gestures.js.map +1 -1
  12. package/dist/glasses/glass-chat-display.d.ts +43 -0
  13. package/dist/glasses/glass-chat-display.d.ts.map +1 -0
  14. package/dist/glasses/glass-chat-display.js +102 -0
  15. package/dist/glasses/glass-chat-display.js.map +1 -0
  16. package/dist/glasses/glass-format.d.ts +50 -0
  17. package/dist/glasses/glass-format.d.ts.map +1 -0
  18. package/dist/glasses/glass-format.js +65 -0
  19. package/dist/glasses/glass-format.js.map +1 -0
  20. package/dist/glasses/index.d.ts +2 -0
  21. package/dist/glasses/index.d.ts.map +1 -1
  22. package/dist/glasses/index.js +2 -0
  23. package/dist/glasses/index.js.map +1 -1
  24. package/dist/glasses/useGlasses.js +1 -1
  25. package/dist/glasses/useGlasses.js.map +1 -1
  26. package/dist/stt/providers/deepgram.d.ts +1 -0
  27. package/dist/stt/providers/deepgram.d.ts.map +1 -1
  28. package/dist/stt/providers/deepgram.js +25 -8
  29. package/dist/stt/providers/deepgram.js.map +1 -1
  30. package/dist/web/components/dialog.d.ts.map +1 -1
  31. package/dist/web/components/dialog.js +16 -1
  32. package/dist/web/components/dialog.js.map +1 -1
  33. package/dist/web/components/drawer-shell.d.ts +19 -0
  34. package/dist/web/components/drawer-shell.d.ts.map +1 -0
  35. package/dist/web/components/drawer-shell.js +59 -0
  36. package/dist/web/components/drawer-shell.js.map +1 -0
  37. package/dist/web/components/list-item.d.ts +1 -1
  38. package/dist/web/components/list-item.d.ts.map +1 -1
  39. package/dist/web/components/list-item.js +20 -5
  40. package/dist/web/components/list-item.js.map +1 -1
  41. package/dist/web/components/multi-select.d.ts +22 -0
  42. package/dist/web/components/multi-select.d.ts.map +1 -0
  43. package/dist/web/components/multi-select.js +52 -0
  44. package/dist/web/components/multi-select.js.map +1 -0
  45. package/dist/web/components/select.d.ts +13 -3
  46. package/dist/web/components/select.d.ts.map +1 -1
  47. package/dist/web/components/select.js +36 -3
  48. package/dist/web/components/select.js.map +1 -1
  49. package/dist/web/components/side-drawer.d.ts +43 -0
  50. package/dist/web/components/side-drawer.d.ts.map +1 -0
  51. package/dist/web/components/side-drawer.js +88 -0
  52. package/dist/web/components/side-drawer.js.map +1 -0
  53. package/dist/web/icons/svg-icons.js +1 -1
  54. package/dist/web/icons/svg-icons.js.map +1 -1
  55. package/dist/web/index.d.ts +6 -0
  56. package/dist/web/index.d.ts.map +1 -1
  57. package/dist/web/index.js +3 -0
  58. package/dist/web/index.js.map +1 -1
  59. package/glasses/action-bar.ts +7 -7
  60. package/glasses/action-map.ts +3 -3
  61. package/glasses/gestures.ts +50 -0
  62. package/glasses/glass-chat-display.ts +152 -0
  63. package/glasses/glass-format.ts +75 -0
  64. package/glasses/index.ts +2 -0
  65. package/glasses/useGlasses.ts +1 -1
  66. package/package.json +10 -1
  67. package/stt/providers/deepgram.ts +23 -7
  68. package/web/components/dialog.tsx +20 -1
  69. package/web/components/drawer-shell.tsx +145 -0
  70. package/web/components/list-item.tsx +25 -10
  71. package/web/components/multi-select.tsx +118 -0
  72. package/web/components/select.tsx +90 -20
  73. package/web/components/side-drawer.tsx +246 -0
  74. package/web/icons/svg-icons.tsx +1 -2
  75. package/web/index.ts +9 -0
  76. 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={onDelete}
72
- className="absolute right-0 top-0 bottom-0 flex items-center justify-center bg-negative text-text-highlight cursor-pointer"
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 * as React from 'react';
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 extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange'> {
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
- const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
15
- ({ className, options, onValueChange, ...props }, ref) => (
16
- <select
17
- ref={ref}
18
- className={cn(
19
- 'h-9 w-full bg-input-bg text-text rounded-[6px] pl-4 pr-10 text-[17px] tracking-[-0.17px] outline-none transition-colors cursor-pointer',
20
- className,
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
- onChange={(e) => onValueChange?.(e.target.value)}
23
- {...props}
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 };
@@ -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="#232323"/>
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';
@@ -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%); }