refactor(webui): centralize address selections

This commit is contained in:
2026-07-21 13:34:51 +02:00
parent 5ff154bc64
commit 7237679a85
5 changed files with 70 additions and 81 deletions

View File

@@ -18,7 +18,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -13,8 +13,11 @@
}, },
"./styles/addresses.css": "./src/styles/addresses.css" "./styles/addresses.css": "./src/styles/addresses.css"
}, },
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/addressbook/AddressBookPage.tsx", import.meta.url));
const stylesPath = fileURLToPath(new URL("../src/styles/addresses.css", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const styles = readFileSync(stylesPath, "utf8");
assert.match(page, /SegmentedControl,[\s\S]*SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /<SelectionList label="Contacts" className="address-contact-selection-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\}[\s\S]*className=\{`address-contact-row/);
assert.match(page, /draggable=\{!contact\.deleted_at && !saving\}/);
assert.match(page, /<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">/);
assert.match(page, /selected=\{cardDavForm\.collection_url === item\.collection_url\}/);
assert.match(page, /<SegmentedControl<ConflictMergeChoice>[\s\S]*role="group"[\s\S]*value=\{conflictMergeChoices\[row\.field\] \?\? "local"\}/);
assert.doesNotMatch(page, /<button[\s\S]{0,160}(?:address-contact-row|address-sync-result-row)/);
assert.doesNotMatch(styles, /\.address-conflict-choice button/);
assert.doesNotMatch(styles, /\.address-contact-row:(?:hover|focus-visible)/);
console.log("Address-book flat selections use central components.");

View File

@@ -11,6 +11,9 @@ import {
FormField, FormField,
LoadingFrame, LoadingFrame,
PasswordField, PasswordField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
@@ -1791,10 +1794,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderContactRow(contact: Contact) { function renderContactRow(contact: Contact) {
const selected = selectedContactId === contact.id; const selected = selectedContactId === contact.id;
return ( return (
<button <SelectionListItem
type="button"
key={contact.id} key={contact.id}
className={`address-contact-row ${selected ? "is-selected" : ""} ${contact.deleted_at ? "is-archived-row" : ""}`} selected={selected}
className={`address-contact-row ${contact.deleted_at ? "is-archived-row" : ""}`}
draggable={!contact.deleted_at && !saving} draggable={!contact.deleted_at && !saving}
onClick={() => setSelectedContactId(contact.id)} onClick={() => setSelectedContactId(contact.id)}
onDragStart={(event) => handleContactDragStart(event, contact)}> onDragStart={(event) => handleContactDragStart(event, contact)}>
@@ -1805,7 +1808,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<span className="address-contact-row-meta"> <span className="address-contact-row-meta">
{contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)} {contact.deleted_at ? <StatusBadge status="archived" /> : contact.tags.slice(0, 2).map((tag) => <span key={tag} className="address-tag">{tag}</span>)}
</span> </span>
</button> </SelectionListItem>
); );
} }
@@ -1981,10 +1984,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedBook?.read_only && <StatusBadge status="read-only" />} {selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />} {selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div> </div>
<div className="address-contact-list" role="list"> <div className="address-contact-list">
{visibleContacts.length === 0 ? {visibleContacts.length === 0 ?
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> : <div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
visibleContacts.map(renderContactRow) <SelectionList label="Contacts" className="address-contact-selection-list">
{visibleContacts.map(renderContactRow)}
</SelectionList>
} }
</div> </div>
</section> </section>
@@ -2267,14 +2272,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField> </FormField>
} }
{cardDavDiscovery.length > 0 && {cardDavDiscovery.length > 0 &&
<div className="address-sync-result-list"> <SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
{cardDavDiscovery.map((item) => ( {cardDavDiscovery.map((item) => (
<button type="button" className="address-sync-result-row" key={item.collection_url} onClick={() => useDiscoveredCardDavBook(item)}> <SelectionListItem
selected={cardDavForm.collection_url === item.collection_url}
className="address-sync-result-row"
key={item.collection_url}
onClick={() => useDiscoveredCardDavBook(item)}>
<strong>{item.display_name || item.collection_url}</strong> <strong>{item.display_name || item.collection_url}</strong>
<small>{item.collection_url}</small> <small>{item.collection_url}</small>
</button> </SelectionListItem>
))} ))}
</div> </SelectionList>
} }
</form> </form>
</Dialog> </Dialog>
@@ -2450,15 +2459,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<strong>{row.field}</strong> <strong>{row.field}</strong>
<span>{row.local}</span> <span>{row.local}</span>
<span>{row.remote}</span> <span>{row.remote}</span>
<span className="address-conflict-choice"> <div>
{canMergeConflict(conflictDialog) ? {canMergeConflict(conflictDialog) ?
<> <SegmentedControl<ConflictMergeChoice>
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button> className="address-conflict-choice"
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button> role="group"
</> : size="equal"
ariaLabel={`Source for ${row.field}`}
options={[{ id: "local", label: "Local" }, { id: "remote", label: "Remote" }]}
value={conflictMergeChoices[row.field] ?? "local"}
onChange={(choice) => setConflictMergeChoice(row.field, choice)}
/> :
"—" "—"
} }
</span> </div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -150,16 +150,19 @@
width: min(960px, calc(100vw - 36px)); width: min(960px, calc(100vw - 36px));
} }
.address-sync-record-list,
.address-sync-plan-grid {
display: grid;
}
.address-sync-result-list, .address-sync-result-list,
.address-sync-record-list, .address-sync-record-list,
.address-sync-plan-grid { .address-sync-plan-grid {
border: var(--border-line); border: var(--border-line);
display: grid;
max-height: 260px; max-height: 260px;
overflow: auto; overflow: auto;
} }
.address-sync-result-row,
.address-sync-record-row, .address-sync-record-row,
.address-sync-plan-row { .address-sync-plan-row {
align-items: center; align-items: center;
@@ -175,13 +178,11 @@
} }
.address-sync-result-row { .address-sync-result-row {
cursor: pointer; align-items: center;
} border-bottom: var(--border-line);
display: grid;
.address-sync-result-row:hover, gap: 10px;
.address-sync-result-row:focus-visible { grid-template-columns: minmax(0, 1fr) auto;
background: var(--line);
outline: none;
} }
.address-sync-result-row strong, .address-sync-result-row strong,
@@ -255,37 +256,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.address-conflict-choice {
align-items: center;
display: inline-flex;
gap: 0;
white-space: nowrap;
}
.address-conflict-choice button {
background: var(--panel);
border: var(--border-line);
color: var(--text);
cursor: pointer;
font: inherit;
min-height: 28px;
padding: 3px 8px;
}
.address-conflict-choice button:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.address-conflict-choice button:last-child {
border-left: 0;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.address-conflict-choice button.is-selected {
background: var(--line);
box-shadow: inset 0 1px 2px rgba(var(--shadow-color-rgb), .12);
}
.address-list-panel, .address-list-panel,
.address-detail-panel { .address-detail-panel {
display: flex; display: flex;
@@ -338,34 +308,15 @@
align-content: start; align-content: start;
} }
.address-contact-selection-list {
gap: 2px;
}
.address-contact-row { .address-contact-row {
align-items: center; align-items: center;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
min-width: 0;
padding: 10px;
text-align: left;
}
.address-contact-row[draggable="true"] {
cursor: grab;
}
.address-contact-row[draggable="true"]:active {
cursor: grabbing;
}
.address-contact-row:hover,
.address-contact-row:focus-visible,
.address-contact-row.is-selected {
background: var(--line);
outline: none;
} }
.address-contact-row-main { .address-contact-row-main {