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

@@ -13,8 +13,11 @@
},
"./styles/addresses.css": "./src/styles/addresses.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-selection-list-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.9",
"lucide-react": "^1.23.0",
"react": "^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,
LoadingFrame,
PasswordField,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
@@ -1791,10 +1794,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
function renderContactRow(contact: Contact) {
const selected = selectedContactId === contact.id;
return (
<button
type="button"
<SelectionListItem
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}
onClick={() => setSelectedContactId(contact.id)}
onDragStart={(event) => handleContactDragStart(event, contact)}>
@@ -1805,7 +1808,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<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>)}
</span>
</button>
</SelectionListItem>
);
}
@@ -1981,10 +1984,12 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div>
<div className="address-contact-list" role="list">
<div className="address-contact-list">
{visibleContacts.length === 0 ?
<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>
</section>
@@ -2267,14 +2272,18 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>
}
{cardDavDiscovery.length > 0 &&
<div className="address-sync-result-list">
<SelectionList label="Discovered CardDAV address books" className="address-sync-result-list">
{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>
<small>{item.collection_url}</small>
</button>
</SelectionListItem>
))}
</div>
</SelectionList>
}
</form>
</Dialog>
@@ -2450,15 +2459,20 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<strong>{row.field}</strong>
<span>{row.local}</span>
<span>{row.remote}</span>
<span className="address-conflict-choice">
<div>
{canMergeConflict(conflictDialog) ?
<>
<button type="button" className={conflictMergeChoices[row.field] !== "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "local")}>Local</button>
<button type="button" className={conflictMergeChoices[row.field] === "remote" ? "is-selected" : ""} onClick={() => setConflictMergeChoice(row.field, "remote")}>Remote</button>
</> :
<SegmentedControl<ConflictMergeChoice>
className="address-conflict-choice"
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>

View File

@@ -150,16 +150,19 @@
width: min(960px, calc(100vw - 36px));
}
.address-sync-record-list,
.address-sync-plan-grid {
display: grid;
}
.address-sync-result-list,
.address-sync-record-list,
.address-sync-plan-grid {
border: var(--border-line);
display: grid;
max-height: 260px;
overflow: auto;
}
.address-sync-result-row,
.address-sync-record-row,
.address-sync-plan-row {
align-items: center;
@@ -175,13 +178,11 @@
}
.address-sync-result-row {
cursor: pointer;
}
.address-sync-result-row:hover,
.address-sync-result-row:focus-visible {
background: var(--line);
outline: none;
align-items: center;
border-bottom: var(--border-line);
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1fr) auto;
}
.address-sync-result-row strong,
@@ -255,37 +256,6 @@
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-detail-panel {
display: flex;
@@ -338,34 +308,15 @@
align-content: start;
}
.address-contact-selection-list {
gap: 2px;
}
.address-contact-row {
align-items: center;
background: transparent;
border: 0;
border-radius: 6px;
color: var(--text);
cursor: pointer;
display: grid;
gap: 10px;
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 {