refactor(webui): centralize address selections
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
21
webui/scripts/test-selection-list-structure.mjs
Normal file
21
webui/scripts/test-selection-list-structure.mjs
Normal 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.");
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user