Fix compose focus steal; add contact picker to To field; v1.1.17
- Focus effect now only fires when compose/reply first opens, not on every keystroke — typing in the To field no longer jumps focus to the body - 👥 button next to To field opens a searchable contact picker dropdown; selecting a contact fills in email and name instantly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+100
@@ -9765,6 +9765,106 @@
|
|||||||
border-color: rgba(201, 168, 76, 0.45);
|
border-color: rgba(201, 168, 76, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.em-to-field-wrap {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-to-field-wrap .em-compose-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-contact-pick-btn {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(201,168,76,0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #c8b898;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.em-contact-pick-btn:hover { background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
.em-contact-picker {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 36px;
|
||||||
|
background: #1e1c16;
|
||||||
|
border: 1px solid rgba(201,168,76,0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
||||||
|
z-index: 50;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-contact-picker-search {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid rgba(201,168,76,0.15);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #f0ead8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-contact-picker-list {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-contact-picker-empty {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #6a6050;
|
||||||
|
font-size: 0.83rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-contact-picker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.em-contact-picker-item:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
|
.em-contact-picker-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #e8e2d5;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.em-contact-picker-email {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #c8860a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.em-compose-select {
|
.em-compose-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #14130f;
|
background: #14130f;
|
||||||
|
|||||||
+58
-4
@@ -130,6 +130,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||||
const [settingsSaved, setSettingsSaved] = useState(false)
|
const [settingsSaved, setSettingsSaved] = useState(false)
|
||||||
const [actionMsg, setActionMsg] = useState('')
|
const [actionMsg, setActionMsg] = useState('')
|
||||||
|
const [showContactPicker, setShowContactPicker] = useState(false)
|
||||||
|
const [contactPickerSearch, setContactPickerSearch] = useState('')
|
||||||
const composeRef = useRef<HTMLTextAreaElement>(null)
|
const composeRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const listRef = useRef<HTMLElement>(null)
|
const listRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
@@ -233,9 +235,14 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
el?.scrollIntoView({ block: 'nearest' })
|
el?.scrollIntoView({ block: 'nearest' })
|
||||||
}, [selectedId])
|
}, [selectedId])
|
||||||
|
|
||||||
// Focus compose textarea when reply opens
|
// Focus compose textarea only when reply/compose first opens (not on every keystroke)
|
||||||
|
const hadDraftRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (replyDraft) setTimeout(() => composeRef.current?.focus(), 50)
|
const hasDraft = Boolean(replyDraft)
|
||||||
|
if (hasDraft && !hadDraftRef.current) {
|
||||||
|
setTimeout(() => composeRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
hadDraftRef.current = hasDraft
|
||||||
}, [replyDraft])
|
}, [replyDraft])
|
||||||
|
|
||||||
// ── Keyboard nav ──
|
// ── Keyboard nav ──
|
||||||
@@ -504,13 +511,60 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
<div className="em-compose-fields">
|
<div className="em-compose-fields">
|
||||||
<div className="em-compose-row">
|
<div className="em-compose-row">
|
||||||
<label className="em-compose-label">To</label>
|
<label className="em-compose-label">To</label>
|
||||||
|
<div className="em-to-field-wrap">
|
||||||
<input
|
<input
|
||||||
className="em-compose-input"
|
className="em-compose-input"
|
||||||
type="email"
|
type="text"
|
||||||
placeholder="recipient@example.com"
|
placeholder="recipient@example.com"
|
||||||
value={replyDraft.recipientEmail}
|
value={replyDraft.recipientEmail}
|
||||||
onChange={e => setReplyDraft({ ...replyDraft, recipientEmail: e.target.value })}
|
onChange={e => setReplyDraft({ ...replyDraft, recipientEmail: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="em-contact-pick-btn"
|
||||||
|
title="Pick from contacts"
|
||||||
|
onClick={() => { setShowContactPicker(p => !p); setContactPickerSearch('') }}
|
||||||
|
>
|
||||||
|
👥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showContactPicker && (() => {
|
||||||
|
const q = contactPickerSearch.toLowerCase()
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const opts = submissions
|
||||||
|
.filter(s => s.email && !seen.has(s.email) && seen.add(s.email) as unknown as boolean)
|
||||||
|
.filter(s => !q || s.email.toLowerCase().includes(q) || (s.name || '').toLowerCase().includes(q))
|
||||||
|
.slice(0, 40)
|
||||||
|
return (
|
||||||
|
<div className="em-contact-picker">
|
||||||
|
<input
|
||||||
|
className="em-contact-picker-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search contacts…"
|
||||||
|
autoFocus
|
||||||
|
value={contactPickerSearch}
|
||||||
|
onChange={e => setContactPickerSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="em-contact-picker-list">
|
||||||
|
{opts.length === 0 && <div className="em-contact-picker-empty">No contacts found</div>}
|
||||||
|
{opts.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className="em-contact-picker-item"
|
||||||
|
onClick={() => {
|
||||||
|
setReplyDraft({ ...replyDraft, recipientEmail: s.email, recipientName: s.name || replyDraft.recipientName })
|
||||||
|
setShowContactPicker(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="em-contact-picker-name">{s.name || <em>No name</em>}</span>
|
||||||
|
<span className="em-contact-picker-email">{s.email}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="em-compose-row">
|
<div className="em-compose-row">
|
||||||
<label className="em-compose-label">Name</label>
|
<label className="em-compose-label">Name</label>
|
||||||
@@ -566,7 +620,7 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
|||||||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||||||
{replySending ? 'Sending…' : 'Send'}
|
{replySending ? 'Sending…' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="em-btn em-btn--ghost" onClick={() => setReplyDraft(null)}>Discard</button>
|
<button type="button" className="em-btn em-btn--ghost" onClick={() => { setReplyDraft(null); setShowContactPicker(false) }}>Discard</button>
|
||||||
</div>
|
</div>
|
||||||
{replyMsg && <p className="em-status-msg">{replyMsg}</p>}
|
{replyMsg && <p className="em-status-msg">{replyMsg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user