@novacraft-engineering/mailbox 0.1.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 (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,241 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import styles from './page.module.css'
5
+
6
+ export type PreviewItem = { filename: string; url: string; size?: number; contentType?: string }
7
+
8
+ export type AttachmentKind = 'image' | 'pdf' | 'video' | 'audio' | 'text' | 'other'
9
+
10
+ /**
11
+ * The extension decides, and the declared type answers when it cannot: a file saved as
12
+ * `scan` or `Document (1)` still previews if the sender said what it was.
13
+ */
14
+ export function attachmentKind(filename: string, contentType?: string): AttachmentKind {
15
+ const ext = filename.split('.').pop()?.toLowerCase() ?? ''
16
+ if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg', 'ico', 'tif', 'tiff'].includes(ext)) return 'image'
17
+ if (ext === 'pdf') return 'pdf'
18
+ if (['mp4', 'webm', 'mov', 'm4v', 'ogv', 'mkv', '3gp'].includes(ext)) return 'video'
19
+ if (['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'opus', 'oga'].includes(ext)) return 'audio'
20
+ if (['txt', 'csv', 'tsv', 'json', 'xml', 'md', 'log', 'yml', 'yaml', 'ics', 'ini', 'conf', 'sql', 'srt', 'vtt'].includes(ext)) {
21
+ return 'text'
22
+ }
23
+
24
+ const type = (contentType ?? '').toLowerCase()
25
+ if (type.startsWith('image/')) return 'image'
26
+ if (type === 'application/pdf') return 'pdf'
27
+ if (type.startsWith('video/')) return 'video'
28
+ if (type.startsWith('audio/')) return 'audio'
29
+ if (type.startsWith('text/') || type === 'application/json' || type === 'application/xml') return 'text'
30
+ return 'other'
31
+ }
32
+
33
+ /**
34
+ * The same URL serves the preview and the download button, and it defaults to telling the
35
+ * browser to save. Anything rendered here asks for the inline form instead, or a PDF opens
36
+ * the save dialog the moment the viewer appears.
37
+ */
38
+ function inlineUrl(url: string): string {
39
+ if (!url.startsWith('/api/mail/')) return url
40
+ return `${url}${url.includes('?') ? '&' : '?'}inline=1`
41
+ }
42
+
43
+ export function formatSize(bytes?: number): string {
44
+ if (!bytes) return ''
45
+ if (bytes < 1024) return `${bytes} B`
46
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
47
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
48
+ }
49
+
50
+ const ZOOM_STEP = 0.25
51
+ const ZOOM_MIN = 0.25
52
+ const ZOOM_MAX = 6
53
+
54
+ export default function AttachmentLightbox({
55
+ items,
56
+ index,
57
+ onIndex,
58
+ onClose,
59
+ }: {
60
+ items: PreviewItem[]
61
+ index: number
62
+ onIndex: (next: number) => void
63
+ onClose: () => void
64
+ }) {
65
+ const item = items[index]
66
+ const kind = item ? attachmentKind(item.filename, item.contentType) : 'other'
67
+ const [zoom, setZoom] = useState(1)
68
+ const [rotation, setRotation] = useState(0)
69
+ const [offset, setOffset] = useState({ x: 0, y: 0 })
70
+ const [textBody, setTextBody] = useState<string | null>(null)
71
+ const shellRef = useRef<HTMLDivElement>(null)
72
+ const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null)
73
+
74
+ const reset = useCallback(() => {
75
+ setZoom(1)
76
+ setRotation(0)
77
+ setOffset({ x: 0, y: 0 })
78
+ }, [])
79
+
80
+ // Adjusting state during render when the prop changes, rather than in an effect,
81
+ // so the old zoom and text never paint against the new item.
82
+ const [seenItem, setSeenItem] = useState(item)
83
+ if (item !== seenItem) {
84
+ setSeenItem(item)
85
+ reset()
86
+ setTextBody(null)
87
+ }
88
+
89
+ useEffect(() => {
90
+ if (!item || attachmentKind(item.filename) !== 'text') return
91
+ let stale = false
92
+ fetch(item.url)
93
+ .then(response => response.text())
94
+ .then(body => {
95
+ if (!stale) setTextBody(body.slice(0, 200_000))
96
+ })
97
+ .catch(() => {
98
+ if (!stale) setTextBody('(could not load preview)')
99
+ })
100
+ return () => {
101
+ stale = true
102
+ }
103
+ }, [item])
104
+
105
+ const step = useCallback((delta: number) => setZoom(current => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, current + delta))), [])
106
+
107
+ useEffect(() => {
108
+ const onKey = (event: KeyboardEvent) => {
109
+ if (event.key === 'Escape') onClose()
110
+ else if (event.key === 'ArrowRight' && items.length > 1) onIndex((index + 1) % items.length)
111
+ else if (event.key === 'ArrowLeft' && items.length > 1) onIndex((index - 1 + items.length) % items.length)
112
+ else if (event.key === '+' || event.key === '=') step(ZOOM_STEP)
113
+ else if (event.key === '-') step(-ZOOM_STEP)
114
+ else if (event.key === '0') reset()
115
+ else if (event.key.toLowerCase() === 'r') setRotation(current => (current + 90) % 360)
116
+ else if (event.key.toLowerCase() === 'f') {
117
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
118
+ else shellRef.current?.requestFullscreen?.().catch(() => {})
119
+ }
120
+ }
121
+ window.addEventListener('keydown', onKey)
122
+ return () => window.removeEventListener('keydown', onKey)
123
+ }, [index, items.length, onClose, onIndex, step, reset])
124
+
125
+ if (!item) return null
126
+
127
+ const onPointerDown = (event: React.PointerEvent) => {
128
+ if (kind !== 'image' || zoom <= 1) return
129
+ dragRef.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y }
130
+ ;(event.target as HTMLElement).setPointerCapture?.(event.pointerId)
131
+ }
132
+ const onPointerMove = (event: React.PointerEvent) => {
133
+ const drag = dragRef.current
134
+ if (!drag) return
135
+ setOffset({ x: drag.ox + (event.clientX - drag.x), y: drag.oy + (event.clientY - drag.y) })
136
+ }
137
+ const onPointerUp = () => {
138
+ dragRef.current = null
139
+ }
140
+
141
+ const body = (() => {
142
+ if (kind === 'image') {
143
+ return (
144
+ // eslint-disable-next-line @next/next/no-img-element
145
+ <img
146
+ className={styles.lbImage}
147
+ src={item.url}
148
+ alt={item.filename}
149
+ draggable={false}
150
+ style={{
151
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
152
+ cursor: zoom > 1 ? 'grab' : 'default',
153
+ }}
154
+ />
155
+ )
156
+ }
157
+ if (kind === 'pdf') return <iframe className={styles.lbFrame} src={inlineUrl(item.url)} title={item.filename} />
158
+ if (kind === 'video') return <video className={styles.lbMedia} src={inlineUrl(item.url)} controls autoPlay={false} />
159
+ if (kind === 'audio') return <audio className={styles.lbAudio} src={inlineUrl(item.url)} controls />
160
+ if (kind === 'text') return <pre className={styles.lbText}>{textBody ?? 'Loading…'}</pre>
161
+ // Nothing here can render a spreadsheet or a Word file, but the browser may be able
162
+ // to, and opening it is a different action from saving it.
163
+ return (
164
+ <div className={styles.lbFallback}>
165
+ <p>This file can&apos;t be shown here.</p>
166
+ <a className={styles.lbDownload} href={inlineUrl(item.url)} target="_blank" rel="noopener noreferrer">
167
+ Open in a new tab
168
+ </a>
169
+ <a className={styles.lbDownloadAlt} href={item.url} target="_blank" rel="noopener noreferrer">
170
+ Download {item.filename}
171
+ </a>
172
+ </div>
173
+ )
174
+ })()
175
+
176
+ return (
177
+ <div className={styles.lbScrim} onClick={onClose} role="dialog" aria-modal="true" aria-label={item.filename}>
178
+ <div className={styles.lbShell} ref={shellRef} onClick={event => event.stopPropagation()}>
179
+ <div className={styles.lbBar}>
180
+ <div className={styles.lbTitle}>
181
+ <span className={styles.lbName} title={item.filename}>{item.filename}</span>
182
+ {item.size ? <span className={styles.lbSize}>{formatSize(item.size)}</span> : null}
183
+ {items.length > 1 && <span className={styles.lbCount}>{index + 1} / {items.length}</span>}
184
+ </div>
185
+ <div className={styles.lbTools}>
186
+ {kind === 'image' && (
187
+ <>
188
+ <button onClick={() => step(-ZOOM_STEP)} title="Zoom out (−)" aria-label="Zoom out">−</button>
189
+ <button onClick={() => step(ZOOM_STEP)} title="Zoom in (+)" aria-label="Zoom in">+</button>
190
+ <button onClick={reset} title="Reset (0)" aria-label="Reset zoom">{Math.round(zoom * 100)}%</button>
191
+ <button onClick={() => setRotation(current => (current + 90) % 360)} title="Rotate (R)" aria-label="Rotate">⟳</button>
192
+ </>
193
+ )}
194
+ <button
195
+ onClick={() => {
196
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
197
+ else shellRef.current?.requestFullscreen?.().catch(() => {})
198
+ }}
199
+ title="Fullscreen (F)"
200
+ aria-label="Fullscreen"
201
+ >
202
+ ⛶
203
+ </button>
204
+ <a href={item.url} target="_blank" rel="noopener noreferrer" title="Download" aria-label="Download">↓</a>
205
+ <button onClick={onClose} title="Close (Esc)" aria-label="Close">✕</button>
206
+ </div>
207
+ </div>
208
+ <div
209
+ className={`${styles.lbStage} ${kind === 'image' ? styles.lbStagePan : ''}`}
210
+ onPointerDown={onPointerDown}
211
+ onPointerMove={onPointerMove}
212
+ onPointerUp={onPointerUp}
213
+ onPointerCancel={onPointerUp}
214
+ onWheel={event => {
215
+ if (kind === 'image' && (event.ctrlKey || event.metaKey)) step(event.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP)
216
+ }}
217
+ >
218
+ {items.length > 1 && (
219
+ <button
220
+ className={`${styles.lbNav} ${styles.lbPrev}`}
221
+ onClick={() => onIndex((index - 1 + items.length) % items.length)}
222
+ aria-label="Previous attachment"
223
+ >
224
+ ‹
225
+ </button>
226
+ )}
227
+ {body}
228
+ {items.length > 1 && (
229
+ <button
230
+ className={`${styles.lbNav} ${styles.lbNext}`}
231
+ onClick={() => onIndex((index + 1) % items.length)}
232
+ aria-label="Next attachment"
233
+ >
234
+ ›
235
+ </button>
236
+ )}
237
+ </div>
238
+ </div>
239
+ </div>
240
+ )
241
+ }
@@ -0,0 +1,88 @@
1
+ 'use client'
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
4
+ import styles from './page.module.css'
5
+
6
+ export type ConfirmRequest = {
7
+ title: string
8
+ body?: ReactNode
9
+ /** Defaults to "Continue". */
10
+ confirmLabel?: string
11
+ cancelLabel?: string
12
+ /** Paints the confirm button as destructive. */
13
+ danger?: boolean
14
+ }
15
+
16
+ type Pending = ConfirmRequest & { resolve: (value: boolean) => void }
17
+
18
+ const ConfirmContext = createContext<(request: ConfirmRequest) => Promise<boolean>>(async () => false)
19
+
20
+ /** Replaces window.confirm, which cannot be styled and looks like the browser, not the app. */
21
+ export function useConfirm() {
22
+ return useContext(ConfirmContext)
23
+ }
24
+
25
+ export function ConfirmProvider({ children }: { children: ReactNode }) {
26
+ const [pending, setPending] = useState<Pending | null>(null)
27
+ const confirmRef = useRef<HTMLButtonElement>(null)
28
+
29
+ const confirm = useCallback(
30
+ (request: ConfirmRequest) => new Promise<boolean>(resolve => setPending({ ...request, resolve })),
31
+ [],
32
+ )
33
+
34
+ const settle = useCallback(
35
+ (value: boolean) => {
36
+ setPending(current => {
37
+ current?.resolve(value)
38
+ return null
39
+ })
40
+ },
41
+ [],
42
+ )
43
+
44
+ // Escape cancels and Enter confirms, matching what the native dialog did.
45
+ useEffect(() => {
46
+ if (!pending) return
47
+ confirmRef.current?.focus()
48
+ const onKey = (event: KeyboardEvent) => {
49
+ if (event.key === 'Escape') { event.preventDefault(); settle(false) }
50
+ if (event.key === 'Enter') { event.preventDefault(); settle(true) }
51
+ }
52
+ window.addEventListener('keydown', onKey)
53
+ return () => window.removeEventListener('keydown', onKey)
54
+ }, [pending, settle])
55
+
56
+ return (
57
+ <ConfirmContext.Provider value={confirm}>
58
+ {children}
59
+ {pending && (
60
+ <div className={styles.confirmScrim} onClick={() => settle(false)}>
61
+ <div
62
+ className={styles.confirmCard}
63
+ role="alertdialog"
64
+ aria-modal="true"
65
+ aria-label={pending.title}
66
+ onClick={event => event.stopPropagation()}
67
+ >
68
+ <p className={styles.confirmTitle}>{pending.title}</p>
69
+ {pending.body && <div className={styles.confirmBody}>{pending.body}</div>}
70
+ <div className={styles.confirmActions}>
71
+ <button type="button" className={styles.confirmCancel} onClick={() => settle(false)}>
72
+ {pending.cancelLabel ?? 'Cancel'}
73
+ </button>
74
+ <button
75
+ ref={confirmRef}
76
+ type="button"
77
+ className={`${styles.confirmGo} ${pending.danger ? styles.confirmDanger : ''}`}
78
+ onClick={() => settle(true)}
79
+ >
80
+ {pending.confirmLabel ?? 'Continue'}
81
+ </button>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ )}
86
+ </ConfirmContext.Provider>
87
+ )
88
+ }
@@ -0,0 +1,138 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import styles from './page.module.css'
5
+
6
+ export function anchoredMenuStyle(anchor: HTMLElement): React.CSSProperties {
7
+ const rect = anchor.getBoundingClientRect()
8
+ const base: React.CSSProperties = { position: 'fixed', left: rect.left, minWidth: rect.width }
9
+ const openUpward = rect.bottom > window.innerHeight - 260
10
+ return openUpward
11
+ ? { ...base, bottom: window.innerHeight - rect.top + 6 }
12
+ : { ...base, top: rect.bottom + 6 }
13
+ }
14
+
15
+ export const CHEVRON = (
16
+ <svg className={styles.selectChevron} viewBox="0 0 12 8" fill="none" aria-hidden>
17
+ <path d="M1 1l5 5 5-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
18
+ </svg>
19
+ )
20
+
21
+ export default function MailSelect({
22
+ value,
23
+ options,
24
+ onChange,
25
+ ariaLabel,
26
+ buttonClassName,
27
+ placeholder = 'Select',
28
+ editable = false,
29
+ optionStyle,
30
+ }: {
31
+ value: string
32
+ options: Array<{ value: string; label: string }>
33
+ onChange: (value: string) => void
34
+ ariaLabel: string
35
+ buttonClassName?: string
36
+ placeholder?: string
37
+ /** Typed values are accepted too: committed on Enter or when the field loses focus. */
38
+ editable?: boolean
39
+ optionStyle?: (value: string) => React.CSSProperties
40
+ }) {
41
+ const [open, setOpen] = useState(false)
42
+ const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
43
+ const [typed, setTyped] = useState<string | null>(null)
44
+ const wrapRef = useRef<HTMLSpanElement>(null)
45
+ const anchorRef = useRef<HTMLElement>(null)
46
+
47
+ useEffect(() => {
48
+ if (!open) return
49
+ const onDown = (event: MouseEvent) => {
50
+ if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) setOpen(false)
51
+ }
52
+ const onKey = (event: KeyboardEvent) => {
53
+ if (event.key === 'Escape') {
54
+ event.stopPropagation()
55
+ setOpen(false)
56
+ }
57
+ }
58
+ document.addEventListener('mousedown', onDown)
59
+ document.addEventListener('keydown', onKey)
60
+ return () => {
61
+ document.removeEventListener('mousedown', onDown)
62
+ document.removeEventListener('keydown', onKey)
63
+ }
64
+ }, [open])
65
+
66
+ const current = options.find(option => option.value === value)
67
+ const toggle = () => {
68
+ if (!open && anchorRef.current) setMenuStyle(anchoredMenuStyle(anchorRef.current))
69
+ setOpen(prev => !prev)
70
+ }
71
+ const commitTyped = () => {
72
+ if (typed !== null && typed !== (current?.label ?? value)) onChange(typed.trim())
73
+ setTyped(null)
74
+ }
75
+
76
+ return (
77
+ <span className={styles.select} ref={wrapRef}>
78
+ {editable ? (
79
+ <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`}>
80
+ <input
81
+ className={styles.selectInput}
82
+ aria-label={ariaLabel}
83
+ value={typed ?? current?.label ?? value}
84
+ placeholder={placeholder}
85
+ onFocus={() => { if (!open) toggle() }}
86
+ onChange={event => setTyped(event.target.value)}
87
+ onBlur={commitTyped}
88
+ onKeyDown={event => {
89
+ if (event.key === 'Enter') {
90
+ event.preventDefault()
91
+ commitTyped()
92
+ setOpen(false)
93
+ }
94
+ }}
95
+ />
96
+ <button type="button" className={styles.selectChevronBtn} aria-haspopup="listbox" aria-expanded={open} aria-label={`${ariaLabel} options`} onMouseDown={event => event.preventDefault()} onClick={toggle}>
97
+ {CHEVRON}
98
+ </button>
99
+ </span>
100
+ ) : (
101
+ <button
102
+ ref={anchorRef as React.RefObject<HTMLButtonElement>}
103
+ type="button"
104
+ className={`${styles.selectBtn} ${buttonClassName ?? ''}`}
105
+ aria-haspopup="listbox"
106
+ aria-expanded={open}
107
+ aria-label={ariaLabel}
108
+ onClick={toggle}
109
+ >
110
+ <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</span>
111
+ {CHEVRON}
112
+ </button>
113
+ )}
114
+ {open && (
115
+ <div className={styles.selectMenu} style={menuStyle} role="listbox">
116
+ {options.map(option => (
117
+ <button
118
+ key={option.value}
119
+ type="button"
120
+ role="option"
121
+ aria-selected={option.value === value}
122
+ className={`${styles.selectOption} ${option.value === value ? styles.selectOptionOn : ''}`}
123
+ style={optionStyle ? optionStyle(option.value) : undefined}
124
+ onMouseDown={event => event.preventDefault()}
125
+ onClick={() => {
126
+ setTyped(null)
127
+ onChange(option.value)
128
+ setOpen(false)
129
+ }}
130
+ >
131
+ {option.label}
132
+ </button>
133
+ ))}
134
+ </div>
135
+ )}
136
+ </span>
137
+ )
138
+ }