From e3a6c4a4b5d7195c2057c8e21d9946299d978734 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 10:18:09 +1100 Subject: [PATCH 01/21] created widget for searching people --- package-lock.json | 35 +++ package.json | 1 + src/widgets/index.js | 1 + src/widgets/peopleSearch.ts | 534 ++++++++++++++++++++++++++++++++++++ 4 files changed, 571 insertions(+) create mode 100644 src/widgets/peopleSearch.ts diff --git a/package-lock.json b/package-lock.json index a931682ec..0b9198884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@solid-data-modules/contacts-rdflib": "^0.7.1", "escape-html": "^1.0.3", "mime-types": "^3.0.2", "pane-registry": "^3.0.2", @@ -4344,6 +4345,30 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@solid-data-modules/contacts-rdflib": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@solid-data-modules/contacts-rdflib/-/contacts-rdflib-0.7.1.tgz", + "integrity": "sha512-jjSVCyXjOdMlPEdTysboLg1Tc8E3jDFlbEIv7mjnNkFK61UdI/BfnNPT5XnNSUSiZYBZklUwsniJhclFhoZmBw==", + "license": "MIT", + "dependencies": { + "@solid-data-modules/rdflib-utils": "^0.2.0" + }, + "peerDependencies": { + "rdflib": "2.x" + } + }, + "node_modules/@solid-data-modules/rdflib-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@solid-data-modules/rdflib-utils/-/rdflib-utils-0.2.0.tgz", + "integrity": "sha512-WXpyiMmgmeeTHUz/jFGGBy02GxClxT2uew3eUWh/XOQQQeOxlzYFRO0tOa3Nv9/3Y1qcAyS7tSaW5x42Q8WPLQ==", + "license": "MIT", + "dependencies": { + "short-unique-id": "^5.2.0" + }, + "peerDependencies": { + "rdflib": "2.x" + } + }, "node_modules/@storybook/addon-actions": { "version": "8.6.15", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.15.tgz", @@ -15666,6 +15691,16 @@ "node": ">=8" } }, + "node_modules/short-unique-id": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.3.2.tgz", + "integrity": "sha512-KRT/hufMSxXKEDSQujfVE0Faa/kZ51ihUcZQAcmP04t00DvPj7Ox5anHke1sJYUtzSuiT/Y5uyzg/W7bBEGhCg==", + "license": "Apache-2.0", + "bin": { + "short-unique-id": "bin/short-unique-id", + "suid": "bin/short-unique-id" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", diff --git a/package.json b/package.json index b412ec9eb..03c85ba30 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@solid-data-modules/contacts-rdflib": "^0.7.1", "escape-html": "^1.0.3", "mime-types": "^3.0.2", "pane-registry": "^3.0.2", diff --git a/src/widgets/index.js b/src/widgets/index.js index 3b6f8e51d..5469c2615 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -18,6 +18,7 @@ // export widgets with the same name) export * from './peoplePicker' +export * from './peopleSearch' export * from './dragAndDrop' export * from './buttons' export * from './buttons/iconLinks' diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts new file mode 100644 index 000000000..4195a110b --- /dev/null +++ b/src/widgets/peopleSearch.ts @@ -0,0 +1,534 @@ +/** + * + * People Search Widget + * + * This widget offers a mechanism for selecting a set of individuals to take some action on. + * It discovers people from the user's FOAF profile (predicate: foaf:knows (friends)) and + * linked profiles, as well as from their address books, and allows searching through them by name. + * It currently traverses the FOAF graph up to 3 degrees of separation (friends of friends of friends) + * to find people, and also loads contacts from any linked address books. The search is performed + * client-side on the discovered set of people, allowing for fast filtering as the user types. + * Configurable options include a click handler for when a person is selected, otherwise it + * opens their profile in the same window. + * + * Assumptions + * - Assumes that the user has a type index entry for vcard:AddressBook. @@ bad assuption + * + */ +import { NamedNode } from 'rdflib' +import ContactsModuleRdfLib, { type AddressBook } from '@solid-data-modules/contacts-rdflib' +import * as debug from '../debug' +import { ns } from '..' + +const PEOPLE_SEARCH_CONCURRENCY = 6 +const CONTACT_CARD_CONCURRENCY = 8 +const MAX_FOAF_DISTANCE = 3 +const addressBookListCache = new Map>() +const addressBookCache = new Map>() +const contactWebIdCache = new Map>() + +type PersonEntry = { + name: string, + webId: string, + relationshipLabel: 'Friend' | 'People' | 'Contact' +} + +export const createPeopleSearch = function (dom, kb, me: NamedNode | null, onClickHandler?: (person: PersonEntry) => void) { + const contactsModule = new ContactsModuleRdfLib({ + store: kb, + fetcher: kb.fetcher, + updater: kb.updater + }) + + // Add responsive styles for people search + const styleId = 'people-search-styles' + if (!document.getElementById(styleId)) { + const style = document.createElement('style') + style.id = styleId + style.textContent = ` + .people-search-input { + padding: 10px; + font-size: 16px; + box-sizing: border-box; + width: max(28%, 280px); + max-width: 80%; + } + .people-search-dropdown { + width: max(28%, 280px); + max-width: 80%; + } + @media (max-width: 600px) { + .people-search-input, + .people-search-dropdown { + width: 80%; + } + } + ` + document.head.appendChild(style) + } + + const searchForm = dom.createElement('form') + const searchInput = searchForm.appendChild(dom.createElement('input')) + searchInput.type = 'text' + searchInput.placeholder = 'Search for people...' + searchInput.className = 'people-search-input' + + const searchDiv = searchForm.appendChild(dom.createElement('div')) + searchDiv.className = 'people-search-dropdown' + searchDiv.style.display = 'none' + searchDiv.style.border = '1px solid #ccc' + searchDiv.style.marginTop = '5px' + searchDiv.style.padding = '5px' + searchDiv.style.boxSizing = 'border-box' + searchDiv.style.maxHeight = '15em' + searchDiv.style.overflowY = 'auto' + + const warmupHint = searchForm.appendChild(dom.createElement('div')) + warmupHint.style.display = 'none' + warmupHint.style.marginTop = '5px' + warmupHint.style.fontSize = '0.85em' + warmupHint.style.color = '#666' + warmupHint.textContent = 'Warming up contacts…' + + const discoveredPeople = new Map() + const personRows = new Map() + const status = searchDiv.appendChild(dom.createElement('p')) + status.style.margin = '5px 0' + status.style.color = '#666' + + const addPersonRow = function (person: PersonEntry) { + const existingRow = personRows.get(person.webId) + if (existingRow) { + const nameElement = existingRow.firstChild as HTMLDivElement | null + const labelElement = existingRow.lastChild as HTMLDivElement | null + if (nameElement) { + nameElement.textContent = person.name + } + if (labelElement) { + labelElement.textContent = person.relationshipLabel + } + existingRow.title = person.webId + return existingRow + } + + const personElement = dom.createElement('div') + const nameElement = personElement.appendChild(dom.createElement('div')) + const labelElement = personElement.appendChild(dom.createElement('div')) + + nameElement.textContent = person.name + labelElement.textContent = person.relationshipLabel + + personElement.title = person.webId + personElement.style.cursor = 'pointer' + personElement.style.margin = '5px 0' + personElement.style.padding = '2px 4px' + labelElement.style.fontSize = '0.75em' + labelElement.style.color = '#666' + + personElement.addEventListener('click', function () { + if (onClickHandler) { + onClickHandler(person) + } else { + window.open(person.webId, '_blank') + } + searchDiv.style.display = 'none' + }) + personElement.addEventListener('mouseover', function () { + personElement.style.backgroundColor = '#f0f0f0' + }) + personElement.addEventListener('mouseout', function () { + personElement.style.backgroundColor = 'white' + }) + searchDiv.appendChild(personElement) + personRows.set(person.webId, personElement) + return personElement + } + + const sortVisibleRows = function () { + const visiblePeople = Array.from(discoveredPeople.values()) + .filter(person => { + const row = personRows.get(person.webId) + return row && row.style.display !== 'none' + }) + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) + + visiblePeople.forEach(person => { + const row = personRows.get(person.webId) + if (row) { + searchDiv.appendChild(row) + } + }) + } + + let sortQueued = false + const scheduleSortVisibleRows = function () { + if (sortQueued) return + sortQueued = true + + const flushSort = function () { + sortQueued = false + sortVisibleRows() + } + + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(flushSort) + return + } + + setTimeout(flushSort, 0) + } + + const updateVisibleRows = function (query: string): number { + let visibleCount = 0 + for (const [webId, person] of discoveredPeople.entries()) { + const row = personRows.get(webId) || addPersonRow(person) + const isVisible = matchesNameWords(person.name, query) + row.style.display = isVisible ? 'block' : 'none' + if (isVisible) { + visibleCount += 1 + } + } + sortVisibleRows() + return visibleCount + } + + const updateRowVisibility = function (person: PersonEntry, query: string): boolean { + const row = personRows.get(person.webId) || addPersonRow(person) + const isVisible = matchesNameWords(person.name, query) + row.style.display = isVisible ? 'block' : 'none' + scheduleSortVisibleRows() + return isVisible + } + + const tokenize = function (query: string): string[] { + return query + .toLowerCase() + .trim() + .split(/\s+/) + .filter(Boolean) + } + + const matchesNameWords = function (name: string, query: string): boolean { + const q = tokenize(query) + if (q.length === 0) return true + const nameWords = tokenize(name) + return q.every(word => nameWords.some(nameWord => nameWord.includes(word))) + } + + const nameFor = function (person: NamedNode): string | null { + const nameNode: { value: string } | null | undefined = + kb.any(person, ns.foaf('name')) || kb.any(person, ns.vcard('fn')) + return nameNode?.value || null + } + + const bestLabel = function ( + current: PersonEntry['relationshipLabel'] | undefined, + incoming: PersonEntry['relationshipLabel'] + ): PersonEntry['relationshipLabel'] { + if (current === 'Contact' || incoming === 'Contact') return 'Contact' + if (current === 'Friend' || incoming === 'Friend') return 'Friend' + return 'People' + } + + const mergePerson = function (person: PersonEntry) { + const existing = discoveredPeople.get(person.webId) + if (existing) { + discoveredPeople.set(person.webId, { + ...existing, + name: existing.name || person.name, + relationshipLabel: bestLabel(existing.relationshipLabel, person.relationshipLabel) + }) + return discoveredPeople.get(person.webId)! + } + discoveredPeople.set(person.webId, person) + return person + } + + const discoverPeople = async function (onPerson: (person: PersonEntry) => void | Promise) { + if (!me || !kb) return + + const visited = new Set() + const emitted = new Set() + const loadedDocs = new Set() + let queue: Array<{ person: NamedNode, depth: number }> = [{ person: me, depth: 0 }] + visited.add(me.value) + + const processPerson = async function ( + currentEntry: { person: NamedNode, depth: number } + ): Promise> { + const { person: current, depth } = currentEntry + const currentDoc = current.doc().value + if (!loadedDocs.has(currentDoc)) { + loadedDocs.add(currentDoc) + try { + await kb.fetcher.load(current.doc()) + } catch (_e) { /* skip inaccessible profiles */ } + } + + if (current.value !== me.value) { + const personName = nameFor(current) + if (personName && !emitted.has(current.value)) { + emitted.add(current.value) + const person: PersonEntry = { + name: personName, + webId: current.value, + relationshipLabel: depth === 1 ? 'Friend' : 'People' + } + await onPerson(person) + } + } + + const nextContacts: Array<{ person: NamedNode, depth: number }> = [] + if (depth >= MAX_FOAF_DISTANCE) { + return nextContacts + } + + const contacts = kb.each(current, ns.foaf('knows')) as NamedNode[] + for (const contact of contacts) { + const contactName = nameFor(contact) + if (contact.value !== me.value && contactName && !emitted.has(contact.value)) { + emitted.add(contact.value) + await onPerson({ + name: contactName, + webId: contact.value, + relationshipLabel: depth === 0 ? 'Friend' : 'People' + }) + } + + if (contact instanceof NamedNode && !visited.has(contact.value)) { + visited.add(contact.value) + nextContacts.push({ person: contact, depth: depth + 1 }) + } + } + + return nextContacts + } + + while (queue.length > 0) { + const nextQueue: Array<{ person: NamedNode, depth: number }> = [] + + for (let index = 0; index < queue.length; index += PEOPLE_SEARCH_CONCURRENCY) { + const batch = queue.slice(index, index + PEOPLE_SEARCH_CONCURRENCY) + const batchContacts = await Promise.all(batch.map(processPerson)) + for (const contacts of batchContacts) { + nextQueue.push(...contacts) + } + } + + queue = nextQueue + } + } + + const loadAddressBooks = async function (): Promise { + if (!me || !kb) return [] + + const cachedAddressBooks = addressBookListCache.get(me.value) + if (cachedAddressBooks) { + return cachedAddressBooks + } + + const addressBooksPromise = contactsModule.listAddressBooks(me.value) + .then(addressBooks => [...addressBooks.publicUris, ...addressBooks.privateUris]) + .catch(error => { + addressBookListCache.delete(me.value) + throw error + }) + + addressBookListCache.set(me.value, addressBooksPromise) + return addressBooksPromise + } + + const webIdForAddressBookContact = async function (contactUri: string): Promise { + const cachedWebId = contactWebIdCache.get(contactUri) + if (cachedWebId) { + return cachedWebId + } + + const contactNode = new NamedNode(contactUri) + const webIdPromise = kb.fetcher.load(contactNode.doc()) + .then(function () { + const webIdNode = kb.any(contactNode, ns.vcard('url'), undefined, contactNode.doc()) + if (!webIdNode) return null + + return kb.anyValue(webIdNode, ns.vcard('value'), undefined, contactNode.doc()) || null + }) + .catch(function () { + return null + }) + + contactWebIdCache.set(contactUri, webIdPromise) + return webIdPromise + } + + const readAddressBookCached = async function (addressBookUri: string): Promise { + const cachedAddressBook = addressBookCache.get(addressBookUri) + if (cachedAddressBook) { + return cachedAddressBook + } + + const addressBookPromise = contactsModule.readAddressBook(addressBookUri) + .catch(error => { + addressBookCache.delete(addressBookUri) + throw error + }) + + addressBookCache.set(addressBookUri, addressBookPromise) + return addressBookPromise + } + + const discoverAddressBookContacts = async function ( + onPerson: (person: PersonEntry) => void | Promise + ) { + if (!me || !kb) return + + const addressBooks = await loadAddressBooks() + + for (const book of addressBooks) { + let addressBook: AddressBook + + try { + addressBook = await readAddressBookCached(book) + } catch (_e) { + continue + } + + for (let index = 0; index < addressBook.contacts.length; index += CONTACT_CARD_CONCURRENCY) { + const batch = addressBook.contacts.slice(index, index + CONTACT_CARD_CONCURRENCY) + const people = await Promise.all(batch.map(async function (contact) { + const contactWebId = await webIdForAddressBookContact(contact.uri) + if (!contactWebId) { + return null + } + + return { + name: contact.name, + webId: contactWebId, + relationshipLabel: 'Contact' as const + } + })) + + for (const person of people) { + if (!person) continue + await onPerson(person) + } + } + } + } + + let activeSearchId = 0 + let discoveryStarted = false + let discoveryPromise: Promise | null = null + + const ensureDiscovery = function () { + if (discoveryPromise) { + return discoveryPromise + } + + discoveryStarted = true + status.textContent = 'Searching...' + warmupHint.style.display = 'block' + + discoveryPromise = (async function () { + const renderPerson = function (person: PersonEntry) { + try { + const merged = mergePerson(person) + addPersonRow(merged) + updateRowVisibility(merged, searchInput.value.trim()) + } catch (error) { + debug.error('[FOAF] Error rendering person:', error, person) + } + } + + const contactsPromise = discoverAddressBookContacts(function (person) { + try { + renderPerson(person) + } catch (error) { + debug.error('[Discovery] Error in contacts callback:', error) + } + }) + + const peoplePromise = discoverPeople(function (person) { + try { + renderPerson(person) + } catch (error) { + debug.error('[Discovery] Error in people callback:', error) + } + }) + + const results = await Promise.allSettled([contactsPromise, peoplePromise]) + if (results.every(result => result.status === 'rejected')) { + throw new Error('Unable to load contacts.') + } + })() + .catch(() => { + status.textContent = 'Unable to load contacts.' + }) + .finally(() => { + discoveryStarted = false + warmupHint.style.display = 'none' + if (discoveredPeople.size === 0) { + status.textContent = me ? 'No contacts found.' : 'Sign in to search contacts.' + } else { + status.textContent = '' + } + }) + + return discoveryPromise + } + + const runSearch = async function (query: string) { + const searchId = ++activeSearchId + searchDiv.style.display = 'block' + + const visibleCount = updateVisibleRows(query.trim()) + if (!me) { + status.textContent = 'Sign in to search contacts.' + return + } + + if (!discoveryPromise) { + void ensureDiscovery() + } + + if (searchId !== activeSearchId) return + + if (visibleCount > 0) { + status.textContent = discoveryStarted ? 'Searching...' : '' + return + } + + status.textContent = discoveryStarted + ? 'Searching...' + : 'No contacts match that name.' + } + + const onInputHandler = function () { + void runSearch(searchInput.value) + } + + const onFocusHandler = function () { + void runSearch(searchInput.value) + } + + const onBlurHandler = function () { + setTimeout(() => { + searchDiv.style.display = 'none' + }, 200) + } + + searchInput.addEventListener('input', onInputHandler) + searchInput.addEventListener('focus', onFocusHandler) + searchInput.addEventListener('blur', onBlurHandler) + + searchForm.addEventListener('submit', function (event) { + event.preventDefault() + void runSearch(searchInput.value) + }) + + if (me) { + void ensureDiscovery() + } + + return searchForm +} + From 90700fc02994ee5c09f8e6e5e45c95c481dc39d1 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 10:19:52 +1100 Subject: [PATCH 02/21] improved comments --- src/widgets/peopleSearch.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 4195a110b..1c2bc7981 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -8,6 +8,9 @@ * It currently traverses the FOAF graph up to 3 degrees of separation (friends of friends of friends) * to find people, and also loads contacts from any linked address books. The search is performed * client-side on the discovered set of people, allowing for fast filtering as the user types. + * Below the name of each person, a label indicates whether they are a direct friend, a contact + * from an address book, or just a person discovered through the FOAF graph. Contacts take precedence + * over friends, and friends take precedence over people when determining the label. * Configurable options include a click handler for when a person is selected, otherwise it * opens their profile in the same window. * From fda97ebcb68ea6fd01bf7ac79db8a6277cb11cab Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 10:35:38 +1100 Subject: [PATCH 03/21] fixed test errors --- __mocks__/contacts-rdflib.ts | 32 ++++++++++++++++++++++++++++++++ jest.config.mjs | 5 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 __mocks__/contacts-rdflib.ts diff --git a/__mocks__/contacts-rdflib.ts b/__mocks__/contacts-rdflib.ts new file mode 100644 index 000000000..7bc3274bd --- /dev/null +++ b/__mocks__/contacts-rdflib.ts @@ -0,0 +1,32 @@ +type AddressBookContact = { + uri: string + name: string +} + +type AddressBook = { + contacts: AddressBookContact[] +} + +type AddressBookList = { + publicUris: string[] + privateUris: string[] +} + +export default class ContactsModuleRdfLib { + constructor (_options: unknown) {} + + async listAddressBooks (_webId: string): Promise { + return { + publicUris: [], + privateUris: [] + } + } + + async readAddressBook (_addressBookUri: string): Promise { + return { + contacts: [] + } + } +} + +export type { AddressBook } diff --git a/jest.config.mjs b/jest.config.mjs index 9c38c0198..99182a807 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -11,8 +11,11 @@ export default { '^.+\\.(mjs|[tj]sx?)$': ['babel-jest', { configFile: './babel.config.mjs' }], }, transformIgnorePatterns: [ - '/node_modules/(?!(lit-html|@noble/curves|@noble/hashes|@exodus/bytes|uuid|jsdom|parse5|@asamuzakjp/css-color|@csstools)/)', + '/node_modules/(?!(lit-html|@noble/curves|@noble/hashes|@exodus/bytes|uuid|jsdom|parse5|@asamuzakjp/css-color|@csstools|@solid-data-modules/contacts-rdflib)/)', ], + moduleNameMapper: { + '^@solid-data-modules/contacts-rdflib$': '/__mocks__/contacts-rdflib.ts', + }, setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], roots: ['/src', '/test', '/__mocks__'], From 0830c26ca4cdbeed9b6142eccf5ba96de8daec0c Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 10:37:23 +1100 Subject: [PATCH 04/21] added tests for peopleSearch --- test/unit/widgets/peopleSearch.test.ts | 165 +++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 test/unit/widgets/peopleSearch.test.ts diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts new file mode 100644 index 000000000..85da5b92b --- /dev/null +++ b/test/unit/widgets/peopleSearch.test.ts @@ -0,0 +1,165 @@ +import { NamedNode } from 'rdflib' +import { silenceDebugMessages } from '../helpers/debugger' +import { createPeopleSearch } from '../../../src/widgets/peopleSearch' + +const mockListAddressBooks = jest.fn() +const mockReadAddressBook = jest.fn() + +jest.mock('@solid-data-modules/contacts-rdflib', () => ({ + __esModule: true, + default: class ContactsModuleRdfLib { + listAddressBooks = mockListAddressBooks + readAddressBook = mockReadAddressBook + } +})) + +silenceDebugMessages() + +const flushAsyncWork = async function () { + await Promise.resolve() + await new Promise(resolve => setTimeout(resolve, 0)) + await Promise.resolve() +} + +const makeKb = function () { + return { + fetcher: { + load: jest.fn().mockResolvedValue(undefined) + }, + updater: {}, + any: jest.fn((subject, predicate) => { + if ( + subject?.value?.includes('/contacts/1#this') && + predicate?.value?.includes('url') + ) { + return new NamedNode('https://pod.example/contacts/1#url') + } + return null + }), + anyValue: jest.fn((subject, predicate) => { + if ( + subject?.value?.includes('/contacts/1#url') && + predicate?.value?.includes('value') + ) { + return 'https://alice.example/profile/card#me' + } + return null + }), + each: jest.fn().mockReturnValue([]) + } +} + +describe('createPeopleSearch', () => { + beforeEach(() => { + document.body.innerHTML = '' + jest.clearAllMocks() + + mockListAddressBooks.mockResolvedValue({ + publicUris: ['https://pod.example/address-book-1.ttl'], + privateUris: [] + }) + + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + } + ] + }) + }) + + it('renders a search input and hidden dropdown', () => { + const kb = makeKb() + const me = new NamedNode('https://user-1.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const input = form.querySelector('input') as HTMLInputElement | null + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement | null + + expect(input).not.toBeNull() + expect(input?.placeholder).toBe('Search for people...') + expect(dropdown).not.toBeNull() + expect(dropdown?.style.display).toBe('none') + }) + + it('uses onClickHandler when provided and hides dropdown', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-2.example/profile/card#me') + const onClickHandler = jest.fn() + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null) + + const form = createPeopleSearch(document, kb as any, me, onClickHandler) + document.body.appendChild(form) + + await flushAsyncWork() + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + input.dispatchEvent(new Event('focus')) + await flushAsyncWork() + + const personRow = form.querySelector('div[title="https://alice.example/profile/card#me"]') as HTMLDivElement + expect(personRow).not.toBeNull() + + personRow.dispatchEvent(new Event('click')) + + expect(onClickHandler).toHaveBeenCalledTimes(1) + expect(onClickHandler).toHaveBeenCalledWith({ + name: 'Alice Example', + webId: 'https://alice.example/profile/card#me', + relationshipLabel: 'Contact' + }) + expect(openSpy).not.toHaveBeenCalled() + expect(dropdown.style.display).toBe('none') + + openSpy.mockRestore() + }) + + it('falls back to opening webId when onClickHandler is not provided', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-3.example/profile/card#me') + const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null) + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await flushAsyncWork() + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + input.dispatchEvent(new Event('focus')) + await flushAsyncWork() + + const personRow = form.querySelector('div[title="https://alice.example/profile/card#me"]') as HTMLDivElement + expect(personRow).not.toBeNull() + + personRow.dispatchEvent(new Event('click')) + + expect(openSpy).toHaveBeenCalledTimes(1) + expect(openSpy).toHaveBeenCalledWith('https://alice.example/profile/card#me', '_blank') + expect(dropdown.style.display).toBe('none') + + openSpy.mockRestore() + }) + + it('shows sign-in message when me is null', async () => { + const kb = makeKb() + + const form = createPeopleSearch(document, kb as any, null) + document.body.appendChild(form) + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + input.dispatchEvent(new Event('focus')) + await flushAsyncWork() + + expect(dropdown.style.display).toBe('block') + expect(dropdown.textContent).toContain('Sign in to search contacts.') + }) +}) From 65ebae794f10773273a0729e9ea05d81f7342bbb Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:40:06 +1100 Subject: [PATCH 05/21] more testing --- test/unit/widgets/peopleSearch.test.ts | 238 ++++++++++++++++++++++--- 1 file changed, 209 insertions(+), 29 deletions(-) diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts index 85da5b92b..619229b55 100644 --- a/test/unit/widgets/peopleSearch.test.ts +++ b/test/unit/widgets/peopleSearch.test.ts @@ -4,6 +4,7 @@ import { createPeopleSearch } from '../../../src/widgets/peopleSearch' const mockListAddressBooks = jest.fn() const mockReadAddressBook = jest.fn() +let bookCounter = 0 jest.mock('@solid-data-modules/contacts-rdflib', () => ({ __esModule: true, @@ -21,41 +22,113 @@ const flushAsyncWork = async function () { await Promise.resolve() } -const makeKb = function () { +const flushDiscovery = async function () { + await flushAsyncWork() + await flushAsyncWork() + await flushAsyncWork() +} + +type KbOptions = { + namesByWebId?: Record + contactWebIdsByCardUri?: Record + knowsByWebId?: Record> +} + +const makeKb = function (options: KbOptions = {}) { + const namesByWebId = options.namesByWebId || {} + const contactWebIdsByCardUri = options.contactWebIdsByCardUri || { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me' + } + const knowsByWebId = options.knowsByWebId || {} + return { fetcher: { load: jest.fn().mockResolvedValue(undefined) }, updater: {}, any: jest.fn((subject, predicate) => { - if ( - subject?.value?.includes('/contacts/1#this') && - predicate?.value?.includes('url') - ) { - return new NamedNode('https://pod.example/contacts/1#url') + const subjectValue = subject?.value + const predicateValue = predicate?.value || '' + + if (!subjectValue) { + return null + } + + if (predicateValue.includes('foaf/0.1/name') || predicateValue.endsWith('#name')) { + const personName = namesByWebId[subjectValue] + return personName ? { value: personName } : null + } + + if (predicateValue.includes('/2006/vcard/ns#fn')) { + const personName = namesByWebId[subjectValue] + return personName ? { value: personName } : null } + + if (predicateValue.includes('/2006/vcard/ns#url') && subjectValue in contactWebIdsByCardUri) { + return new NamedNode(subjectValue + '-url') + } + return null }), anyValue: jest.fn((subject, predicate) => { - if ( - subject?.value?.includes('/contacts/1#url') && - predicate?.value?.includes('value') - ) { - return 'https://alice.example/profile/card#me' + const subjectValue = subject?.value + const predicateValue = predicate?.value || '' + + if (!subjectValue || !predicateValue.includes('/2006/vcard/ns#value')) { + return null } - return null + + if (!subjectValue.endsWith('-url')) { + return null + } + + const cardUri = subjectValue.slice(0, -4) + return contactWebIdsByCardUri[cardUri] || null }), - each: jest.fn().mockReturnValue([]) + each: jest.fn((subject, predicate) => { + const subjectValue = subject?.value + const predicateValue = predicate?.value || '' + + if (!subjectValue || !predicateValue.includes('foaf/0.1/knows')) { + return [] + } + + return knowsByWebId[subjectValue] || [] + }) } } +const openDropdown = async function (form: HTMLFormElement) { + const input = form.querySelector('input') as HTMLInputElement + input.dispatchEvent(new Event('focus')) + await flushDiscovery() +} + +const setSearchQuery = async function (form: HTMLFormElement, query: string) { + const input = form.querySelector('input') as HTMLInputElement + input.value = query + input.dispatchEvent(new Event('input')) + await flushDiscovery() +} + +const rowFor = function (form: HTMLFormElement, webId: string) { + return form.querySelector(`div[title="${webId}"]`) as HTMLDivElement | null +} + +const rowLabel = function (row: HTMLDivElement | null) { + if (!row) return null + return (row.lastElementChild as HTMLDivElement | null)?.textContent || null +} + describe('createPeopleSearch', () => { beforeEach(() => { document.body.innerHTML = '' jest.clearAllMocks() + bookCounter += 1 + const defaultBookUri = `https://pod.example/address-book-${bookCounter}.ttl` mockListAddressBooks.mockResolvedValue({ - publicUris: ['https://pod.example/address-book-1.ttl'], + publicUris: [defaultBookUri], privateUris: [] }) @@ -94,18 +167,16 @@ describe('createPeopleSearch', () => { const form = createPeopleSearch(document, kb as any, me, onClickHandler) document.body.appendChild(form) - await flushAsyncWork() + await flushDiscovery() - const input = form.querySelector('input') as HTMLInputElement const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - input.dispatchEvent(new Event('focus')) - await flushAsyncWork() + await openDropdown(form) - const personRow = form.querySelector('div[title="https://alice.example/profile/card#me"]') as HTMLDivElement + const personRow = rowFor(form, 'https://alice.example/profile/card#me') expect(personRow).not.toBeNull() - personRow.dispatchEvent(new Event('click')) + personRow?.dispatchEvent(new Event('click')) expect(onClickHandler).toHaveBeenCalledTimes(1) expect(onClickHandler).toHaveBeenCalledWith({ @@ -127,18 +198,16 @@ describe('createPeopleSearch', () => { const form = createPeopleSearch(document, kb as any, me) document.body.appendChild(form) - await flushAsyncWork() + await flushDiscovery() - const input = form.querySelector('input') as HTMLInputElement const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - input.dispatchEvent(new Event('focus')) - await flushAsyncWork() + await openDropdown(form) - const personRow = form.querySelector('div[title="https://alice.example/profile/card#me"]') as HTMLDivElement + const personRow = rowFor(form, 'https://alice.example/profile/card#me') expect(personRow).not.toBeNull() - personRow.dispatchEvent(new Event('click')) + personRow?.dispatchEvent(new Event('click')) expect(openSpy).toHaveBeenCalledTimes(1) expect(openSpy).toHaveBeenCalledWith('https://alice.example/profile/card#me', '_blank') @@ -153,13 +222,124 @@ describe('createPeopleSearch', () => { const form = createPeopleSearch(document, kb as any, null) document.body.appendChild(form) - const input = form.querySelector('input') as HTMLInputElement const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - input.dispatchEvent(new Event('focus')) - await flushAsyncWork() + await openDropdown(form) expect(dropdown.style.display).toBe('block') expect(dropdown.textContent).toContain('Sign in to search contacts.') }) + + it('matches names by tokenized, case-insensitive words', async () => { + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + }, + { + uri: 'https://pod.example/contacts/2#this', + name: 'Bob Stone' + } + ] + }) + + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', + 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' + } + }) + const me = new NamedNode('https://user-4.example/profile/card#me') + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + await setSearchQuery(form, 'EXA ali') + + const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') + const bobRow = rowFor(form, 'https://bob.example/profile/card#me') + + expect(aliceRow).not.toBeNull() + expect(aliceRow?.style.display).toBe('block') + expect(bobRow).not.toBeNull() + expect(bobRow?.style.display).toBe('none') + }) + + it('skips non-NamedNode foaf:knows values during traversal', async () => { + mockListAddressBooks.mockResolvedValue({ publicUris: [], privateUris: [] }) + + const me = new NamedNode('https://user-5.example/profile/card#me') + const friend = new NamedNode('https://friend.example/profile/card#me') + const kb = makeKb({ + namesByWebId: { + [friend.value]: 'Frank Friend' + }, + knowsByWebId: { + [me.value]: [{ value: 'https://not-a-named-node.example/#it' }, friend] + } + }) + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const friendRow = rowFor(form, friend.value) + const bogusRow = rowFor(form, 'https://not-a-named-node.example/#it') + + expect(friendRow).not.toBeNull() + expect(rowLabel(friendRow)).toBe('Friend') + expect(bogusRow).toBeNull() + }) + + it('merges duplicate people and prefers Contact label over Friend', async () => { + const sharedWebId = 'https://alice.example/profile/card#me' + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/shared#this', + name: 'Alice Contact' + } + ] + }) + + const me = new NamedNode('https://user-6.example/profile/card#me') + const friend = new NamedNode(sharedWebId) + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/shared#this': sharedWebId + }, + namesByWebId: { + [sharedWebId]: 'Alice Friend' + }, + knowsByWebId: { + [me.value]: [friend] + } + }) + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const mergedRow = rowFor(form, sharedWebId) + expect(mergedRow).not.toBeNull() + expect(rowLabel(mergedRow)).toBe('Contact') + }) + + it('shows no-match status after discovery when query has no results', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-7.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + await openDropdown(form) + await setSearchQuery(form, 'thiswillnotmatch') + + expect(dropdown.textContent).toContain('No contacts match that name.') + }) }) From b91bc77c5dba541fb1149e6824abd4af169b6f8f Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:41:01 +1100 Subject: [PATCH 06/21] Update src/widgets/peopleSearch.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/widgets/peopleSearch.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 1c2bc7981..b9a58ab96 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -132,7 +132,10 @@ export const createPeopleSearch = function (dom, kb, me: NamedNode | null, onCli if (onClickHandler) { onClickHandler(person) } else { - window.open(person.webId, '_blank') + const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer') + if (newWindow) { + newWindow.opener = null + } } searchDiv.style.display = 'none' }) From a31de361761959870a97d2532f7a28e09560c799 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:41:32 +1100 Subject: [PATCH 07/21] Update src/widgets/peopleSearch.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/widgets/peopleSearch.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index b9a58ab96..26078d746 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -11,8 +11,8 @@ * Below the name of each person, a label indicates whether they are a direct friend, a contact * from an address book, or just a person discovered through the FOAF graph. Contacts take precedence * over friends, and friends take precedence over people when determining the label. - * Configurable options include a click handler for when a person is selected, otherwise it - * opens their profile in the same window. + * Configurable options include a click handler for when a person is selected; otherwise, it + * opens their profile in a new tab or window. * * Assumptions * - Assumes that the user has a type index entry for vcard:AddressBook. @@ bad assuption From 318788553057bd610088b0ab0f47ca2d3a28f61c Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:44:40 +1100 Subject: [PATCH 08/21] added types --- src/widgets/peopleSearch.ts | 11 ++++++++--- test/unit/widgets/peopleSearch.test.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 26078d746..e3e7990f7 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -18,7 +18,7 @@ * - Assumes that the user has a type index entry for vcard:AddressBook. @@ bad assuption * */ -import { NamedNode } from 'rdflib' +import { NamedNode, type LiveStore } from 'rdflib' import ContactsModuleRdfLib, { type AddressBook } from '@solid-data-modules/contacts-rdflib' import * as debug from '../debug' import { ns } from '..' @@ -36,7 +36,12 @@ type PersonEntry = { relationshipLabel: 'Friend' | 'People' | 'Contact' } -export const createPeopleSearch = function (dom, kb, me: NamedNode | null, onClickHandler?: (person: PersonEntry) => void) { +export const createPeopleSearch = function ( + dom: HTMLDocument, + kb: LiveStore, + me: NamedNode | null, + onClickHandler?: (person: PersonEntry) => void +): HTMLFormElement { const contactsModule = new ContactsModuleRdfLib({ store: kb, fetcher: kb.fetcher, @@ -353,7 +358,7 @@ export const createPeopleSearch = function (dom, kb, me: NamedNode | null, onCli const contactNode = new NamedNode(contactUri) const webIdPromise = kb.fetcher.load(contactNode.doc()) .then(function () { - const webIdNode = kb.any(contactNode, ns.vcard('url'), undefined, contactNode.doc()) + const webIdNode = kb.any(contactNode, ns.vcard('url'), undefined, contactNode.doc()) as NamedNode | null if (!webIdNode) return null return kb.anyValue(webIdNode, ns.vcard('value'), undefined, contactNode.doc()) || null diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts index 619229b55..a50415850 100644 --- a/test/unit/widgets/peopleSearch.test.ts +++ b/test/unit/widgets/peopleSearch.test.ts @@ -210,7 +210,7 @@ describe('createPeopleSearch', () => { personRow?.dispatchEvent(new Event('click')) expect(openSpy).toHaveBeenCalledTimes(1) - expect(openSpy).toHaveBeenCalledWith('https://alice.example/profile/card#me', '_blank') + expect(openSpy).toHaveBeenCalledWith('https://alice.example/profile/card#me', '_blank', 'noopener,noreferrer') expect(dropdown.style.display).toBe('none') openSpy.mockRestore() From ed726ade3a4d25635da8a0c6f41405a878ec4e12 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:45:17 +1100 Subject: [PATCH 09/21] Update src/widgets/peopleSearch.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/widgets/peopleSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 26078d746..17ad5d8d9 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -15,7 +15,7 @@ * opens their profile in a new tab or window. * * Assumptions - * - Assumes that the user has a type index entry for vcard:AddressBook. @@ bad assuption + * - Assumes that the user has a type index entry for vcard:AddressBook. If this assumption is not met, no address book contacts will be discovered. * */ import { NamedNode } from 'rdflib' From ffca4494cf58acba1e95c70efa24d18fef373765 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:45:54 +1100 Subject: [PATCH 10/21] Update src/widgets/peopleSearch.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/widgets/peopleSearch.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 17ad5d8d9..967218eb7 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -289,21 +289,25 @@ export const createPeopleSearch = function (dom, kb, me: NamedNode | null, onCli return nextContacts } - const contacts = kb.each(current, ns.foaf('knows')) as NamedNode[] + const contacts = kb.each(current, ns.foaf('knows')) for (const contact of contacts) { - const contactName = nameFor(contact) - if (contact.value !== me.value && contactName && !emitted.has(contact.value)) { - emitted.add(contact.value) + if (contact.termType !== 'NamedNode') { + continue + } + const namedContact = contact as NamedNode + const contactName = nameFor(namedContact) + if (namedContact.value !== me.value && contactName && !emitted.has(namedContact.value)) { + emitted.add(namedContact.value) await onPerson({ name: contactName, - webId: contact.value, + webId: namedContact.value, relationshipLabel: depth === 0 ? 'Friend' : 'People' }) } - if (contact instanceof NamedNode && !visited.has(contact.value)) { - visited.add(contact.value) - nextContacts.push({ person: contact, depth: depth + 1 }) + if (!visited.has(namedContact.value)) { + visited.add(namedContact.value) + nextContacts.push({ person: namedContact, depth: depth + 1 }) } } From adab92e957b734a35c4d947b7a0c98787afe9a63 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:47:50 +1100 Subject: [PATCH 11/21] styles use dom --- src/widgets/peopleSearch.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 9e6fa4ce0..036611f9a 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -50,8 +50,8 @@ export const createPeopleSearch = function ( // Add responsive styles for people search const styleId = 'people-search-styles' - if (!document.getElementById(styleId)) { - const style = document.createElement('style') + if (!dom.getElementById(styleId)) { + const style = dom.createElement('style') style.id = styleId style.textContent = ` .people-search-input { @@ -72,7 +72,8 @@ export const createPeopleSearch = function ( } } ` - document.head.appendChild(style) + const styleContainer = dom.head || dom.documentElement || dom.body + styleContainer?.appendChild(style) } const searchForm = dom.createElement('form') From cb153b6d163e28e199ea825604e0453ee91f143d Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 11:54:44 +1100 Subject: [PATCH 12/21] accessibility --- src/widgets/peopleSearch.ts | 148 ++++++++++++++++++++++--- test/unit/widgets/peopleSearch.test.ts | 85 ++++++++++++++ 2 files changed, 220 insertions(+), 13 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index 036611f9a..bee2bad0f 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -26,6 +26,7 @@ import { ns } from '..' const PEOPLE_SEARCH_CONCURRENCY = 6 const CONTACT_CARD_CONCURRENCY = 8 const MAX_FOAF_DISTANCE = 3 +let peopleSearchInstanceCounter = 0 const addressBookListCache = new Map>() const addressBookCache = new Map>() const contactWebIdCache = new Map>() @@ -42,6 +43,12 @@ export const createPeopleSearch = function ( me: NamedNode | null, onClickHandler?: (person: PersonEntry) => void ): HTMLFormElement { + peopleSearchInstanceCounter += 1 + const instanceId = `people-search-${peopleSearchInstanceCounter}` + const inputId = `${instanceId}-input` + const labelId = `${instanceId}-label` + const listboxId = `${instanceId}-listbox` + const contactsModule = new ContactsModuleRdfLib({ store: kb, fetcher: kb.fetcher, @@ -65,6 +72,17 @@ export const createPeopleSearch = function ( width: max(28%, 280px); max-width: 80%; } + .people-search-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } @media (max-width: 600px) { .people-search-input, .people-search-dropdown { @@ -77,13 +95,29 @@ export const createPeopleSearch = function ( } const searchForm = dom.createElement('form') + const searchLabel = searchForm.appendChild(dom.createElement('label')) + searchLabel.id = labelId + searchLabel.htmlFor = inputId + searchLabel.className = 'people-search-sr-only' + searchLabel.textContent = 'Search for people' + const searchInput = searchForm.appendChild(dom.createElement('input')) + searchInput.id = inputId searchInput.type = 'text' searchInput.placeholder = 'Search for people...' searchInput.className = 'people-search-input' + searchInput.setAttribute('role', 'combobox') + searchInput.setAttribute('aria-autocomplete', 'list') + searchInput.setAttribute('aria-haspopup', 'listbox') + searchInput.setAttribute('aria-expanded', 'false') + searchInput.setAttribute('aria-labelledby', labelId) + searchInput.setAttribute('aria-controls', listboxId) const searchDiv = searchForm.appendChild(dom.createElement('div')) + searchDiv.id = listboxId searchDiv.className = 'people-search-dropdown' + searchDiv.setAttribute('role', 'listbox') + searchDiv.setAttribute('aria-label', 'People search results') searchDiv.style.display = 'none' searchDiv.style.border = '1px solid #ccc' searchDiv.style.marginTop = '5px' @@ -104,6 +138,58 @@ export const createPeopleSearch = function ( const status = searchDiv.appendChild(dom.createElement('p')) status.style.margin = '5px 0' status.style.color = '#666' + status.setAttribute('role', 'status') + status.setAttribute('aria-live', 'polite') + + let activeRow: HTMLDivElement | null = null + + const setDropdownOpen = function (isOpen: boolean) { + searchDiv.style.display = isOpen ? 'block' : 'none' + searchInput.setAttribute('aria-expanded', isOpen ? 'true' : 'false') + } + + const getVisibleRows = function (): HTMLDivElement[] { + return Array.from(personRows.values()).filter(row => row.style.display !== 'none') + } + + const setActiveRow = function (row: HTMLDivElement | null) { + if (activeRow) { + activeRow.style.backgroundColor = 'white' + activeRow.setAttribute('aria-selected', 'false') + } + + activeRow = row + + if (activeRow) { + activeRow.style.backgroundColor = '#f0f0f0' + activeRow.setAttribute('aria-selected', 'true') + if (activeRow.id) { + searchInput.setAttribute('aria-activedescendant', activeRow.id) + } + } else { + searchInput.removeAttribute('aria-activedescendant') + } + } + + const ensureActiveRowIsVisible = function () { + if (!activeRow) return + if (activeRow.style.display === 'none') { + setActiveRow(null) + } + } + + const selectPerson = function (person: PersonEntry) { + if (onClickHandler) { + onClickHandler(person) + } else { + const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer') + if (newWindow) { + newWindow.opener = null + } + } + setActiveRow(null) + setDropdownOpen(false) + } const addPersonRow = function (person: PersonEntry) { const existingRow = personRows.get(person.webId) @@ -121,6 +207,7 @@ export const createPeopleSearch = function ( } const personElement = dom.createElement('div') + const optionIdSafeWebId = person.webId.replace(/[^a-zA-Z0-9_-]/g, '_') const nameElement = personElement.appendChild(dom.createElement('div')) const labelElement = personElement.appendChild(dom.createElement('div')) @@ -128,6 +215,9 @@ export const createPeopleSearch = function ( labelElement.textContent = person.relationshipLabel personElement.title = person.webId + personElement.id = `${instanceId}-option-${optionIdSafeWebId}` + personElement.setAttribute('role', 'option') + personElement.setAttribute('aria-selected', 'false') personElement.style.cursor = 'pointer' personElement.style.margin = '5px 0' personElement.style.padding = '2px 4px' @@ -135,21 +225,15 @@ export const createPeopleSearch = function ( labelElement.style.color = '#666' personElement.addEventListener('click', function () { - if (onClickHandler) { - onClickHandler(person) - } else { - const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer') - if (newWindow) { - newWindow.opener = null - } - } - searchDiv.style.display = 'none' + selectPerson(person) }) personElement.addEventListener('mouseover', function () { - personElement.style.backgroundColor = '#f0f0f0' + setActiveRow(personElement) }) personElement.addEventListener('mouseout', function () { - personElement.style.backgroundColor = 'white' + if (activeRow !== personElement) { + personElement.style.backgroundColor = 'white' + } }) searchDiv.appendChild(personElement) personRows.set(person.webId, personElement) @@ -201,6 +285,7 @@ export const createPeopleSearch = function ( } } sortVisibleRows() + ensureActiveRowIsVisible() return visibleCount } @@ -209,6 +294,7 @@ export const createPeopleSearch = function ( const isVisible = matchesNameWords(person.name, query) row.style.display = isVisible ? 'block' : 'none' scheduleSortVisibleRows() + ensureActiveRowIsVisible() return isVisible } @@ -494,7 +580,7 @@ export const createPeopleSearch = function ( const runSearch = async function (query: string) { const searchId = ++activeSearchId - searchDiv.style.display = 'block' + setDropdownOpen(true) const visibleCount = updateVisibleRows(query.trim()) if (!me) { @@ -528,13 +614,49 @@ export const createPeopleSearch = function ( const onBlurHandler = function () { setTimeout(() => { - searchDiv.style.display = 'none' + setActiveRow(null) + setDropdownOpen(false) }, 200) } + const onKeyDownHandler = function (event: KeyboardEvent) { + const visibleRows = getVisibleRows() + + if (event.key === 'Escape') { + setActiveRow(null) + setDropdownOpen(false) + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + if (searchDiv.style.display === 'none') { + setDropdownOpen(true) + } + if (visibleRows.length === 0) { + return + } + const currentIndex = activeRow ? visibleRows.indexOf(activeRow) : -1 + const nextIndex = event.key === 'ArrowDown' + ? Math.min(currentIndex + 1, visibleRows.length - 1) + : Math.max(currentIndex - 1, 0) + setActiveRow(visibleRows[nextIndex]) + return + } + + if (event.key === 'Enter' && activeRow) { + event.preventDefault() + const selectedPerson = discoveredPeople.get(activeRow.title) + if (selectedPerson) { + selectPerson(selectedPerson) + } + } + } + searchInput.addEventListener('input', onInputHandler) searchInput.addEventListener('focus', onFocusHandler) searchInput.addEventListener('blur', onBlurHandler) + searchInput.addEventListener('keydown', onKeyDownHandler) searchForm.addEventListener('submit', function (event) { event.preventDefault() diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts index a50415850..0c601940c 100644 --- a/test/unit/widgets/peopleSearch.test.ts +++ b/test/unit/widgets/peopleSearch.test.ts @@ -111,6 +111,10 @@ const setSearchQuery = async function (form: HTMLFormElement, query: string) { await flushDiscovery() } +const keyDown = function (element: HTMLElement, key: string) { + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) +} + const rowFor = function (form: HTMLFormElement, webId: string) { return form.querySelector(`div[title="${webId}"]`) as HTMLDivElement | null } @@ -230,6 +234,87 @@ describe('createPeopleSearch', () => { expect(dropdown.textContent).toContain('Sign in to search contacts.') }) + it('applies combobox/listbox accessibility attributes', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-8.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const input = form.querySelector('input') as HTMLInputElement + const label = form.querySelector('label') as HTMLLabelElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + + expect(label).not.toBeNull() + expect(label.textContent).toBe('Search for people') + expect(input.getAttribute('role')).toBe('combobox') + expect(input.getAttribute('aria-autocomplete')).toBe('list') + expect(input.getAttribute('aria-haspopup')).toBe('listbox') + expect(input.getAttribute('aria-labelledby')).toBe(label.id) + expect(input.getAttribute('aria-controls')).toBe(dropdown.id) + expect(input.getAttribute('aria-expanded')).toBe('false') + + await openDropdown(form) + + const personRow = rowFor(form, 'https://alice.example/profile/card#me') + expect(dropdown.getAttribute('role')).toBe('listbox') + expect(input.getAttribute('aria-expanded')).toBe('true') + expect(personRow?.getAttribute('role')).toBe('option') + expect(personRow?.id).toContain('-option-') + }) + + it('supports keyboard navigation and selection from the input', async () => { + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + }, + { + uri: 'https://pod.example/contacts/2#this', + name: 'Bob Stone' + } + ] + }) + + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', + 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' + } + }) + const me = new NamedNode('https://user-9.example/profile/card#me') + const onClickHandler = jest.fn() + + const form = createPeopleSearch(document, kb as any, me, onClickHandler) + document.body.appendChild(form) + + await openDropdown(form) + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement + + keyDown(input, 'ArrowDown') + expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) + expect(aliceRow.getAttribute('aria-selected')).toBe('true') + + keyDown(input, 'Enter') + expect(onClickHandler).toHaveBeenCalledTimes(1) + expect(onClickHandler).toHaveBeenCalledWith({ + name: 'Alice Example', + webId: 'https://alice.example/profile/card#me', + relationshipLabel: 'Contact' + }) + expect(dropdown.style.display).toBe('none') + expect(input.getAttribute('aria-expanded')).toBe('false') + + await openDropdown(form) + keyDown(input, 'Escape') + expect(dropdown.style.display).toBe('none') + expect(input.getAttribute('aria-expanded')).toBe('false') + }) + it('matches names by tokenized, case-insensitive words', async () => { mockReadAddressBook.mockResolvedValue({ contacts: [ From a5ee9b5de3d0d46f5f3359c989536af90776a5c8 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 12:00:29 +1100 Subject: [PATCH 13/21] more accessibility --- src/widgets/peopleSearch.ts | 54 +++++++++++++++---- test/unit/widgets/peopleSearch.test.ts | 72 +++++++++++++++++++++++++- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index bee2bad0f..df1f85933 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -133,13 +133,21 @@ export const createPeopleSearch = function ( warmupHint.style.color = '#666' warmupHint.textContent = 'Warming up contacts…' + const liveStatus = searchForm.appendChild(dom.createElement('div')) + liveStatus.className = 'people-search-sr-only' + liveStatus.setAttribute('role', 'status') + liveStatus.setAttribute('aria-live', 'polite') + const discoveredPeople = new Map() const personRows = new Map() const status = searchDiv.appendChild(dom.createElement('p')) status.style.margin = '5px 0' status.style.color = '#666' - status.setAttribute('role', 'status') - status.setAttribute('aria-live', 'polite') + + const setStatusText = function (text: string) { + status.textContent = text + liveStatus.textContent = text + } let activeRow: HTMLDivElement | null = null @@ -163,6 +171,9 @@ export const createPeopleSearch = function ( if (activeRow) { activeRow.style.backgroundColor = '#f0f0f0' activeRow.setAttribute('aria-selected', 'true') + if (typeof activeRow.scrollIntoView === 'function') { + activeRow.scrollIntoView({ block: 'nearest' }) + } if (activeRow.id) { searchInput.setAttribute('aria-activedescendant', activeRow.id) } @@ -527,7 +538,8 @@ export const createPeopleSearch = function ( } discoveryStarted = true - status.textContent = 'Searching...' + searchDiv.setAttribute('aria-busy', 'true') + setStatusText('Searching...') warmupHint.style.display = 'block' discoveryPromise = (async function () { @@ -563,15 +575,16 @@ export const createPeopleSearch = function ( } })() .catch(() => { - status.textContent = 'Unable to load contacts.' + setStatusText('Unable to load contacts.') }) .finally(() => { discoveryStarted = false + searchDiv.setAttribute('aria-busy', 'false') warmupHint.style.display = 'none' if (discoveredPeople.size === 0) { - status.textContent = me ? 'No contacts found.' : 'Sign in to search contacts.' + setStatusText(me ? 'No contacts found.' : 'Sign in to search contacts.') } else { - status.textContent = '' + setStatusText('') } }) @@ -584,7 +597,7 @@ export const createPeopleSearch = function ( const visibleCount = updateVisibleRows(query.trim()) if (!me) { - status.textContent = 'Sign in to search contacts.' + setStatusText('Sign in to search contacts.') return } @@ -595,13 +608,13 @@ export const createPeopleSearch = function ( if (searchId !== activeSearchId) return if (visibleCount > 0) { - status.textContent = discoveryStarted ? 'Searching...' : '' + setStatusText(discoveryStarted ? 'Searching...' : '') return } - status.textContent = discoveryStarted + setStatusText(discoveryStarted ? 'Searching...' - : 'No contacts match that name.' + : 'No contacts match that name.') } const onInputHandler = function () { @@ -622,12 +635,31 @@ export const createPeopleSearch = function ( const onKeyDownHandler = function (event: KeyboardEvent) { const visibleRows = getVisibleRows() + if (event.key === 'Tab') { + setActiveRow(null) + setDropdownOpen(false) + return + } + if (event.key === 'Escape') { setActiveRow(null) setDropdownOpen(false) return } + if (event.key === 'Home' || event.key === 'End') { + if (visibleRows.length === 0) { + return + } + event.preventDefault() + if (searchDiv.style.display === 'none') { + setDropdownOpen(true) + } + const targetIndex = event.key === 'Home' ? 0 : visibleRows.length - 1 + setActiveRow(visibleRows[targetIndex]) + return + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault() if (searchDiv.style.display === 'none') { @@ -639,7 +671,7 @@ export const createPeopleSearch = function ( const currentIndex = activeRow ? visibleRows.indexOf(activeRow) : -1 const nextIndex = event.key === 'ArrowDown' ? Math.min(currentIndex + 1, visibleRows.length - 1) - : Math.max(currentIndex - 1, 0) + : (currentIndex <= 0 ? visibleRows.length - 1 : currentIndex - 1) setActiveRow(visibleRows[nextIndex]) return } diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts index 0c601940c..0dfc333e2 100644 --- a/test/unit/widgets/peopleSearch.test.ts +++ b/test/unit/widgets/peopleSearch.test.ts @@ -244,6 +244,7 @@ describe('createPeopleSearch', () => { const input = form.querySelector('input') as HTMLInputElement const label = form.querySelector('label') as HTMLLabelElement const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement expect(label).not.toBeNull() expect(label.textContent).toBe('Search for people') @@ -253,11 +254,14 @@ describe('createPeopleSearch', () => { expect(input.getAttribute('aria-labelledby')).toBe(label.id) expect(input.getAttribute('aria-controls')).toBe(dropdown.id) expect(input.getAttribute('aria-expanded')).toBe('false') + expect(liveRegion).not.toBeNull() + expect(typeof liveRegion.textContent).toBe('string') await openDropdown(form) const personRow = rowFor(form, 'https://alice.example/profile/card#me') expect(dropdown.getAttribute('role')).toBe('listbox') + expect(dropdown.getAttribute('aria-busy')).toBe('false') expect(input.getAttribute('aria-expanded')).toBe('true') expect(personRow?.getAttribute('role')).toBe('option') expect(personRow?.id).toContain('-option-') @@ -294,16 +298,21 @@ describe('createPeopleSearch', () => { const input = form.querySelector('input') as HTMLInputElement const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement + const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement keyDown(input, 'ArrowDown') expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) expect(aliceRow.getAttribute('aria-selected')).toBe('true') + keyDown(input, 'ArrowUp') + expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id) + expect(bobRow.getAttribute('aria-selected')).toBe('true') + keyDown(input, 'Enter') expect(onClickHandler).toHaveBeenCalledTimes(1) expect(onClickHandler).toHaveBeenCalledWith({ - name: 'Alice Example', - webId: 'https://alice.example/profile/card#me', + name: 'Bob Stone', + webId: 'https://bob.example/profile/card#me', relationshipLabel: 'Contact' }) expect(dropdown.style.display).toBe('none') @@ -315,6 +324,50 @@ describe('createPeopleSearch', () => { expect(input.getAttribute('aria-expanded')).toBe('false') }) + it('supports Home/End navigation and closes on Tab', async () => { + mockReadAddressBook.mockResolvedValue({ + contacts: [ + { + uri: 'https://pod.example/contacts/1#this', + name: 'Alice Example' + }, + { + uri: 'https://pod.example/contacts/2#this', + name: 'Bob Stone' + } + ] + }) + + const kb = makeKb({ + contactWebIdsByCardUri: { + 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', + 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' + } + }) + const me = new NamedNode('https://user-11.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const input = form.querySelector('input') as HTMLInputElement + const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement + const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement + const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement + + keyDown(input, 'End') + expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id) + + keyDown(input, 'Home') + expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) + + keyDown(input, 'Tab') + expect(dropdown.style.display).toBe('none') + expect(input.getAttribute('aria-expanded')).toBe('false') + expect(input.getAttribute('aria-activedescendant')).toBeNull() + }) + it('matches names by tokenized, case-insensitive words', async () => { mockReadAddressBook.mockResolvedValue({ contacts: [ @@ -427,4 +480,19 @@ describe('createPeopleSearch', () => { expect(dropdown.textContent).toContain('No contacts match that name.') }) + + it('updates hidden live status text for no-match state', async () => { + const kb = makeKb() + const me = new NamedNode('https://user-10.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement + + await openDropdown(form) + await setSearchQuery(form, 'no-person-will-match-this') + + expect(liveRegion.textContent).toContain('No contacts match that name.') + }) }) From ff9a434837718c239c403d96aa39b25925ace8e6 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 24 Mar 2026 12:04:42 +1100 Subject: [PATCH 14/21] improve a11y, keyboard nav, and input responsiveness --- src/widgets/peopleSearch.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index df1f85933..c23ba9629 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -295,7 +295,7 @@ export const createPeopleSearch = function ( visibleCount += 1 } } - sortVisibleRows() + scheduleSortVisibleRows() ensureActiveRowIsVisible() return visibleCount } @@ -617,8 +617,18 @@ export const createPeopleSearch = function ( : 'No contacts match that name.') } + let inputSearchQueued = false const onInputHandler = function () { - void runSearch(searchInput.value) + if (inputSearchQueued) { + return + } + inputSearchQueued = true + + const flushInputSearch = function () { + inputSearchQueued = false + void runSearch(searchInput.value) + } + setTimeout(flushInputSearch, 0) } const onFocusHandler = function () { From a0774549c47ec9509e84162a4be4fde61bde2130 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 14:02:31 +1100 Subject: [PATCH 15/21] find people from catalog --- src/widgets/peopleSearch.ts | 85 +++++++++++++++++++++++++- test/unit/widgets/peopleSearch.test.ts | 36 +++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts index c23ba9629..cb3a47bba 100644 --- a/src/widgets/peopleSearch.ts +++ b/src/widgets/peopleSearch.ts @@ -18,7 +18,7 @@ * - Assumes that the user has a type index entry for vcard:AddressBook. If this assumption is not met, no address book contacts will be discovered. * */ -import { NamedNode, type LiveStore } from 'rdflib' +import { NamedNode, graph, parse, type LiveStore } from 'rdflib' import ContactsModuleRdfLib, { type AddressBook } from '@solid-data-modules/contacts-rdflib' import * as debug from '../debug' import { ns } from '..' @@ -26,6 +26,8 @@ import { ns } from '..' const PEOPLE_SEARCH_CONCURRENCY = 6 const CONTACT_CARD_CONCURRENCY = 8 const MAX_FOAF_DISTANCE = 3 +const CATALOG_URL = 'https://raw.githubusercontent.com/solid/catalog/refs/heads/main/catalog-data.ttl' +const CATALOG_VOCAB = 'http://example.org#' let peopleSearchInstanceCounter = 0 const addressBookListCache = new Map>() const addressBookCache = new Map>() @@ -37,6 +39,68 @@ type PersonEntry = { relationshipLabel: 'Friend' | 'People' | 'Contact' } +const catalogTerm = function (localName: string): NamedNode { + return new NamedNode(`${CATALOG_VOCAB}${localName}`) +} + +const fetchCatalogPeople = async function (): Promise { + if (typeof fetch !== 'function') { + return [] + } + + try { + const response = await fetch(CATALOG_URL, { + headers: { + accept: 'text/turtle' + } + }) + + if (!response.ok) { + debug.warn(`[Catalog] Failed to fetch ${CATALOG_URL}: ${response.status}`) + return [] + } + + const turtle = await response.text() + const store = graph() + parse(turtle, store, CATALOG_URL, 'text/turtle') + + const personType = catalogTerm('Person') + const webIdPredicate = catalogTerm('webid') + const namePredicate = catalogTerm('name') + const catalogPeople = new Map() + + const personStatements = store.statementsMatching(undefined, ns.rdf('type'), personType) + for (const statement of personStatements) { + const subject = statement.subject + const webIdNode = store.any(subject, webIdPredicate) + if (!webIdNode || webIdNode.termType !== 'NamedNode') { + continue + } + + const webId = webIdNode.value + if (!webId) { + continue + } + + const name = store.anyValue(subject, namePredicate) + if (!name) { + continue + } + + catalogPeople.set(webId, { + name, + webId, + relationshipLabel: 'People' + }) + } + + return Array.from(catalogPeople.values()) + } catch (error) { + debug.warn('[Catalog] Error fetching people from catalog:', error) + return [] + } +} + export const createPeopleSearch = function ( dom: HTMLDocument, kb: LiveStore, @@ -528,6 +592,15 @@ export const createPeopleSearch = function ( } } + const discoverCatalogPeople = async function ( + onPerson: (person: PersonEntry) => void | Promise + ) { + const catalogPeople = await fetchCatalogPeople() + for (const person of catalogPeople) { + await onPerson(person) + } + } + let activeSearchId = 0 let discoveryStarted = false let discoveryPromise: Promise | null = null @@ -569,7 +642,15 @@ export const createPeopleSearch = function ( } }) - const results = await Promise.allSettled([contactsPromise, peoplePromise]) + const catalogPromise = discoverCatalogPeople(function (person) { + try { + renderPerson(person) + } catch (error) { + debug.error('[Discovery] Error in catalog callback:', error) + } + }) + + const results = await Promise.allSettled([contactsPromise, peoplePromise, catalogPromise]) if (results.every(result => result.status === 'rejected')) { throw new Error('Unable to load contacts.') } diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts index 0dfc333e2..890133601 100644 --- a/test/unit/widgets/peopleSearch.test.ts +++ b/test/unit/widgets/peopleSearch.test.ts @@ -5,6 +5,7 @@ import { createPeopleSearch } from '../../../src/widgets/peopleSearch' const mockListAddressBooks = jest.fn() const mockReadAddressBook = jest.fn() let bookCounter = 0 +let fetchMock: jest.Mock jest.mock('@solid-data-modules/contacts-rdflib', () => ({ __esModule: true, @@ -128,6 +129,12 @@ describe('createPeopleSearch', () => { beforeEach(() => { document.body.innerHTML = '' jest.clearAllMocks() + fetchMock = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue('') + }) + ;(globalThis as any).fetch = fetchMock bookCounter += 1 const defaultBookUri = `https://pod.example/address-book-${bookCounter}.ttl` @@ -495,4 +502,33 @@ describe('createPeopleSearch', () => { expect(liveRegion.textContent).toContain('No contacts match that name.') }) + + it('includes people discovered from the Solid catalog', async () => { + mockListAddressBooks.mockResolvedValue({ publicUris: [], privateUris: [] }) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(` + @prefix ex: . + @prefix xsd: . + + a ex:Person ; + ex:name "Catalog Person" ; + ex:webid ; + ex:modified "2025-10-12T16:39:56.789Z"^^xsd:dateTime . + `) + }) + + const kb = makeKb() + const me = new NamedNode('https://user-12.example/profile/card#me') + + const form = createPeopleSearch(document, kb as any, me) + document.body.appendChild(form) + + await openDropdown(form) + + const catalogRow = rowFor(form, 'https://catalog-person.example/profile/card#me') + expect(catalogRow).not.toBeNull() + expect(rowLabel(catalogRow)).toBe('People') + }) }) From d214c72e8bbc68e30ce66cba458fe4c61dfd66af Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Thu, 26 Mar 2026 14:02:45 +1100 Subject: [PATCH 16/21] created story --- src/stories/PeopleSearch.stories.js | 190 ++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 src/stories/PeopleSearch.stories.js diff --git a/src/stories/PeopleSearch.stories.js b/src/stories/PeopleSearch.stories.js new file mode 100644 index 000000000..f700a073b --- /dev/null +++ b/src/stories/PeopleSearch.stories.js @@ -0,0 +1,190 @@ +import * as UI from '../../src/index' +import ContactsModuleRdfLib from '@solid-data-modules/contacts-rdflib' + +const CATALOG_URL = + 'https://raw.githubusercontent.com/solid/catalog/refs/heads/main/catalog-data.ttl' + +function makeMockKb () { + const meWebId = 'https://demo.example/profile/card#me' + const friendWebId = 'https://friend.example/profile/card#me' + const personFromKnowsWebId = 'https://person.example/profile/card#me' + const contactCardUri = 'https://demo.example/contacts/alice#this' + + const namesByWebId = { + [friendWebId]: 'Frank Friend', + [personFromKnowsWebId]: 'Pat Person' + } + + return { + fetcher: { + load: async function () { + return undefined + } + }, + updater: {}, + any: function (subject, predicate) { + const subjectValue = subject && subject.value + const predicateValue = predicate && predicate.value + + if (!subjectValue || !predicateValue) { + return null + } + + if ( + predicateValue.includes('foaf/0.1/name') || + predicateValue.endsWith('#name') || + predicateValue.includes('/2006/vcard/ns#fn') + ) { + const personName = namesByWebId[subjectValue] + return personName ? { value: personName } : null + } + + if ( + predicateValue.includes('/2006/vcard/ns#url') && + subjectValue === contactCardUri + ) { + return $rdf.namedNode(`${contactCardUri}-url`) + } + + return null + }, + anyValue: function (subject, predicate) { + const subjectValue = subject && subject.value + const predicateValue = predicate && predicate.value + + if (!subjectValue || !predicateValue) { + return null + } + + if ( + predicateValue.includes('/2006/vcard/ns#value') && + subjectValue === `${contactCardUri}-url` + ) { + return 'https://alice.example/profile/card#me' + } + + return null + }, + each: function (subject, predicate) { + const subjectValue = subject && subject.value + const predicateValue = predicate && predicate.value + + if (!subjectValue || !predicateValue) { + return [] + } + + if (!predicateValue.includes('foaf/0.1/knows')) { + return [] + } + + if (subjectValue === meWebId) { + return [ + $rdf.namedNode(friendWebId), + $rdf.namedNode(personFromKnowsWebId) + ] + } + + return [] + } + } +} + +function installPeopleSearchMocks () { + const originalListAddressBooks = + ContactsModuleRdfLib.prototype.listAddressBooks + const originalReadAddressBook = + ContactsModuleRdfLib.prototype.readAddressBook + const originalFetch = globalThis.fetch + + ContactsModuleRdfLib.prototype.listAddressBooks = async function () { + return { + publicUris: ['https://demo.example/address-book#this'], + privateUris: [] + } + } + + ContactsModuleRdfLib.prototype.readAddressBook = async function () { + return { + contacts: [ + { + uri: 'https://demo.example/contacts/alice#this', + name: 'Alice Contact' + } + ] + } + } + + globalThis.fetch = async function (input, init) { + const url = typeof input === 'string' ? input : input && input.url + + if (url === CATALOG_URL) { + return { + ok: true, + status: 200, + text: async function () { + return ` + @prefix ex: . + + a ex:Person ; + ex:name "Catalog Person" ; + ex:webid . + ` + } + } + } + + if (typeof originalFetch === 'function') { + return originalFetch(input, init) + } + + return { + ok: false, + status: 404, + text: async function () { + return '' + } + } + } + + return function restoreMocks () { + ContactsModuleRdfLib.prototype.listAddressBooks = originalListAddressBooks + ContactsModuleRdfLib.prototype.readAddressBook = originalReadAddressBook + globalThis.fetch = originalFetch + } +} + +export default { + title: 'Widgets/PeopleSearch' +} + +export const SignedOut = { + render: () => { + return UI.widgets.createPeopleSearch(document, makeMockKb(), null) + }, + name: 'signed out' +} + +export const WithMockData = { + render: () => { + const restoreMocks = installPeopleSearchMocks() + + const wrapper = document.createElement('div') + const info = document.createElement('p') + info.textContent = + 'Mocked sources: address book, foaf:knows, and Solid catalog. Type to filter.' + wrapper.appendChild(info) + + const me = $rdf.namedNode('https://demo.example/profile/card#me') + const picker = UI.widgets.createPeopleSearch(document, makeMockKb(), me) + wrapper.appendChild(picker) + + const cleanup = function () { + restoreMocks() + wrapper.removeEventListener('DOMNodeRemovedFromDocument', cleanup) + } + wrapper.addEventListener('DOMNodeRemovedFromDocument', cleanup) + + return wrapper + }, + name: 'with mock data' +} From 98e769e707f14794917a9aff420ff03cea3397f3 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Mon, 4 May 2026 13:57:25 +1000 Subject: [PATCH 17/21] moved peoplesearch to web component --- README.md | 21 + package.json | 10 + scripts/component-manifest.mjs | 5 + src/stories/PeopleSearch.stories.js | 19 +- src/v2/components/forms/combobox/README.md | 1 + .../forms/peopleSearch/PeopleSearch.test.ts | 145 ++++ .../forms/peopleSearch/PeopleSearch.ts | 335 ++++++++ .../components/forms/peopleSearch/README.md | 102 +++ src/v2/components/forms/peopleSearch/index.ts | 23 + .../forms/peopleSearch/peopleSearchHelpers.ts | 347 ++++++++ .../components/forms/shared/listboxStyles.ts | 22 +- .../forms/shared/listboxTemplate.ts | 7 +- src/v2/components/forms/shared/optionTypes.ts | 1 + src/widgets/index.js | 1 - src/widgets/peopleSearch.ts | 795 ------------------ test/unit/widgets/peopleSearch.test.ts | 534 ------------ 16 files changed, 1032 insertions(+), 1336 deletions(-) create mode 100644 src/v2/components/forms/peopleSearch/PeopleSearch.test.ts create mode 100644 src/v2/components/forms/peopleSearch/PeopleSearch.ts create mode 100644 src/v2/components/forms/peopleSearch/README.md create mode 100644 src/v2/components/forms/peopleSearch/index.ts create mode 100644 src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts delete mode 100644 src/widgets/peopleSearch.ts delete mode 100644 test/unit/widgets/peopleSearch.test.ts diff --git a/README.md b/README.md index cbef9bf3f..2f7017ce1 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,27 @@ import { SignupButton } from 'solid-ui/components/signup-button' ``` +### solid-ui-people-search + +A standalone people-search component built on top of ``. It discovers people from the authenticated user's FOAF graph, linked address books, and the Solid catalog, then emits `person-select` with the selected person's details. + +**Subpath exports:** `solid-ui/components/forms/people-search`, `solid-ui/components/people-search` + +```typescript +import { PeopleSearch } from 'solid-ui/components/forms/people-search' +``` + +```html + +``` + +```typescript +const peopleSearch = document.querySelector('solid-ui-people-search') as PeopleSearch +peopleSearch.addEventListener('person-select', (event: CustomEvent) => { + console.log(event.detail.person) +}) +``` + ### Component build pipeline Web components use a two-stage build to produce a clean public runtime layout while keeping internal TypeScript artifacts separate: diff --git a/package.json b/package.json index 172d4f53b..ae3375062 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,16 @@ "types": "./dist/components/combobox/index.d.ts", "import": "./dist/components/combobox/index.esm.js", "require": "./dist/components/combobox/index.js" + }, + "./components/forms/people-search": { + "types": "./dist/components/peopleSearch/index.d.ts", + "import": "./dist/components/peopleSearch/index.esm.js", + "require": "./dist/components/peopleSearch/index.js" + }, + "./components/people-search": { + "types": "./dist/components/peopleSearch/index.d.ts", + "import": "./dist/components/peopleSearch/index.esm.js", + "require": "./dist/components/peopleSearch/index.js" } }, "files": [ diff --git a/scripts/component-manifest.mjs b/scripts/component-manifest.mjs index 7cee7d316..dac0b4bb6 100644 --- a/scripts/component-manifest.mjs +++ b/scripts/component-manifest.mjs @@ -38,6 +38,11 @@ export const v2Components = [ sourceDir: 'combobox', sourcePath: 'forms/combobox', exportNames: ['forms/combobox', 'combobox'] + }, + { + sourceDir: 'peopleSearch', + sourcePath: 'forms/peopleSearch', + exportNames: ['forms/people-search', 'people-search'] } ] diff --git a/src/stories/PeopleSearch.stories.js b/src/stories/PeopleSearch.stories.js index f700a073b..838d4dc95 100644 --- a/src/stories/PeopleSearch.stories.js +++ b/src/stories/PeopleSearch.stories.js @@ -1,4 +1,4 @@ -import * as UI from '../../src/index' +import '../v2/components/forms/peopleSearch/index' import ContactsModuleRdfLib from '@solid-data-modules/contacts-rdflib' const CATALOG_URL = @@ -154,12 +154,14 @@ function installPeopleSearchMocks () { } export default { - title: 'Widgets/PeopleSearch' + title: 'Forms/PeopleSearch' } export const SignedOut = { render: () => { - return UI.widgets.createPeopleSearch(document, makeMockKb(), null) + const element = document.createElement('solid-ui-people-search') + element.store = makeMockKb() + return element }, name: 'signed out' } @@ -174,12 +176,21 @@ export const WithMockData = { 'Mocked sources: address book, foaf:knows, and Solid catalog. Type to filter.' wrapper.appendChild(info) + const originalCurrentUser = window.SolidLogic?.authn?.currentUser const me = $rdf.namedNode('https://demo.example/profile/card#me') - const picker = UI.widgets.createPeopleSearch(document, makeMockKb(), me) + if (window.SolidLogic?.authn) { + window.SolidLogic.authn.currentUser = () => me + } + const picker = document.createElement('solid-ui-people-search') + picker.store = makeMockKb() + picker.openProfilesOnSelect = false wrapper.appendChild(picker) const cleanup = function () { restoreMocks() + if (window.SolidLogic?.authn) { + window.SolidLogic.authn.currentUser = originalCurrentUser + } wrapper.removeEventListener('DOMNodeRemovedFromDocument', cleanup) } wrapper.addEventListener('DOMNodeRemovedFromDocument', cleanup) diff --git a/src/v2/components/forms/combobox/README.md b/src/v2/components/forms/combobox/README.md index 85659ff79..fae958fb4 100644 --- a/src/v2/components/forms/combobox/README.md +++ b/src/v2/components/forms/combobox/README.md @@ -95,6 +95,7 @@ The component works with suggestion objects shaped like: type ComboboxSuggestion = { label: string value: string + description?: string disabled?: boolean publicId?: string meta?: Record diff --git a/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts b/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts new file mode 100644 index 000000000..a49fae073 --- /dev/null +++ b/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { namedNode } from 'rdflib' +import { authSession, authn } from 'solid-logic' +import { PeopleSearch } from './PeopleSearch' +import './index' +import ns from '../../../../ns' + +jest.mock('@solid-data-modules/contacts-rdflib', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + listAddressBooks: jest.fn(async () => ({ publicUris: [], privateUris: [] })), + readAddressBook: jest.fn(async () => ({ contacts: [] })) + })) +})) + +jest.mock('solid-logic', () => ({ + authSession: { + events: { + on: jest.fn(), + off: jest.fn() + } + }, + authn: { + currentUser: jest.fn() + }, + solidLogicSingleton: { + store: null + } +})) + +const mockCurrentUser = authn.currentUser as jest.Mock +const mockOn = authSession.events.on as jest.Mock +const mockOff = authSession.events.off as jest.Mock + +function getPortalRoot () { + const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null + return portalHost?.shadowRoot ?? null +} + +async function flushUpdates () { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('SolidUIPeopleSearch', () => { + beforeEach(() => { + document.body.innerHTML = '' + mockCurrentUser.mockReset() + mockOn.mockReset() + mockOff.mockReset() + ;(globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch = undefined + }) + + it('is defined as a custom element', () => { + expect(customElements.get('solid-ui-people-search')).toBe(PeopleSearch) + }) + + it('shows a sign-in message when no user is authenticated', async () => { + mockCurrentUser.mockReturnValue(null) + + const peopleSearch = new PeopleSearch() + document.body.appendChild(peopleSearch) + await peopleSearch.updateComplete + + const status = peopleSearch.shadowRoot?.querySelector('.status') as HTMLElement + const combobox = peopleSearch.shadowRoot?.querySelector('solid-ui-combobox') as HTMLElement + + expect(combobox).not.toBeNull() + expect(status.textContent).toContain('Sign in to search contacts.') + expect(mockOn).toHaveBeenCalledWith('login', expect.any(Function)) + expect(mockOn).toHaveBeenCalledWith('logout', expect.any(Function)) + }) + + it('loads FOAF suggestions and emits person-select with relationship details', async () => { + const me = namedNode('https://example.com/profile/card#me') + const friend = namedNode('https://alice.example/profile/card#me') + + mockCurrentUser.mockReturnValue(me) + + const store = { + fetcher: { + load: jest.fn(async () => undefined) + }, + updater: {}, + each: jest.fn((subject: NamedNode, predicate: NamedNode) => { + if (subject.value === me.value && predicate.value === ns.foaf('knows').value) { + return [friend] + } + return [] + }), + any: jest.fn((subject: NamedNode, predicate: NamedNode) => { + if (subject.value === friend.value && predicate.value === ns.foaf('name').value) { + return { value: 'Alice Example' } + } + return null + }), + anyValue: jest.fn(() => null) + } as any + + const openSpy = jest.spyOn(window, 'open').mockReturnValue(null) + const selected = jest.fn() + + const peopleSearch = new PeopleSearch() + peopleSearch.store = store + peopleSearch.openProfilesOnSelect = false + peopleSearch.addEventListener('person-select', (event: Event) => { + selected((event as CustomEvent).detail) + }) + + document.body.appendChild(peopleSearch) + await peopleSearch.updateComplete + await flushUpdates() + await peopleSearch.updateComplete + + const combobox = peopleSearch.shadowRoot?.querySelector('solid-ui-combobox') as any + const input = combobox.shadowRoot?.querySelector('input.text-input') as HTMLInputElement + + input.value = 'Alice' + input.dispatchEvent(new Event('input', { bubbles: true, composed: true })) + await flushUpdates() + await combobox.updateComplete + + const portalRoot = getPortalRoot() + const options = portalRoot?.querySelectorAll('[role="option"]') as NodeListOf + + expect(options).toHaveLength(1) + expect(options[0].textContent).toContain('Alice Example') + expect(options[0].textContent).toContain('Friend') + + options[0].click() + await flushUpdates() + + expect(selected).toHaveBeenCalledWith({ + person: { + name: 'Alice Example', + webId: 'https://alice.example/profile/card#me', + relationshipLabel: 'Friend' + } + }) + expect(openSpy).not.toHaveBeenCalled() + + openSpy.mockRestore() + }) +}) \ No newline at end of file diff --git a/src/v2/components/forms/peopleSearch/PeopleSearch.ts b/src/v2/components/forms/peopleSearch/PeopleSearch.ts new file mode 100644 index 000000000..9bc7b3a34 --- /dev/null +++ b/src/v2/components/forms/peopleSearch/PeopleSearch.ts @@ -0,0 +1,335 @@ +import { LitElement, css, html } from 'lit' +import { authSession, authn, solidLogicSingleton } from 'solid-logic' +import { NamedNode, type LiveStore } from 'rdflib' +import type { Combobox } from '../combobox/Combobox' +import type { ComboboxSuggestion } from '../combobox/comboboxTypes' +import '../combobox/index' +import { + DEFAULT_CATALOG_URL, + discoverPeopleSearchEntries, + matchesPeopleSearchNameWords, + mergePeopleSearchPerson, + sortPeopleSearchPeople, + type PeopleSearchPerson, + type PeopleSearchRelationshipLabel +} from './peopleSearchHelpers' + +export interface PeopleSearchSelectDetail { + person: PeopleSearchPerson +} + +export interface PeopleSearchSuggestion extends ComboboxSuggestion { + description?: string + meta?: { + person: PeopleSearchPerson + } +} + +export class PeopleSearch extends LitElement { + static properties = { + label: { type: String, reflect: true }, + placeholder: { type: String, reflect: true }, + theme: { type: String, reflect: true }, + layout: { type: String, reflect: true }, + catalogUrl: { type: String, attribute: 'catalog-url', reflect: true }, + openProfilesOnSelect: { + type: Boolean, + attribute: 'open-profiles-on-select', + reflect: true + }, + store: { type: Object, attribute: false }, + _user: { state: true }, + _statusMessage: { state: true }, + _warmingUp: { state: true } + } + + static styles = css` + :host { + display: inline-flex; + flex-direction: column; + gap: 0.375rem; + width: min(100%, var(--people-search-width, 22rem)); + --people-search-status-color: var(--color-text-subtle, #667085); + --people-search-status-font-size: 0.85rem; + box-sizing: border-box; + } + + :host([theme='dark']) { + --people-search-status-color: var(--color-text-subtle, #c7ced8); + } + + solid-ui-combobox { + width: 100%; + --input-background: var(--people-search-input-background, var(--color-background, #F8F9FB)); + --input-border: var(--people-search-input-border, var(--color-text, #1A1A1A)); + --input-text: var(--people-search-input-text, var(--color-text, #1A1A1A)); + --popup-background: var(--people-search-popup-background, var(--color-background, #F8F9FB)); + --popup-border: var(--people-search-popup-border, var(--color-border, #E5E7EB)); + --popup-shadow: var(--people-search-popup-shadow, var(--box-shadow-sm, 0 1px 4px rgba(124,77,255,0.12))); + --item-description-text: var(--people-search-description-text, var(--color-text-subtle, #667085)); + } + + .status { + min-height: 1.25rem; + color: var(--people-search-status-color); + font-size: var(--people-search-status-font-size); + line-height: 1.4; + } + + .status[hidden] { + display: none; + } + ` + + declare label: string + declare placeholder: string + declare theme: 'light' | 'dark' + declare layout: 'desktop' | 'mobile' + declare catalogUrl: string + declare openProfilesOnSelect: boolean + declare store: LiveStore | null + declare _user: NamedNode | null + declare _statusMessage: string + declare _warmingUp: boolean + + private readonly _handleAuthChange = () => { + this._user = authn.currentUser() + this._resetDiscoveryState() + if (this._user) { + void this._ensureDiscovery() + } + } + + private readonly _provideSuggestions = async (query: string): Promise => { + this._query = query + + if (!this._user) { + this._updateStatus(query, 0) + return [] + } + + if (!this._discoveryPromise) { + void this._ensureDiscovery() + } + + const suggestions = this._buildSuggestions(query) + this._updateStatus(query, suggestions.length) + return suggestions + } + + private _discoveryPromise: Promise | null = null + private _query = '' + private readonly _discoveredPeople = new Map() + + constructor () { + super() + this.label = 'Search for people' + this.placeholder = 'Search for people...' + this.theme = 'light' + this.layout = 'desktop' + this.catalogUrl = DEFAULT_CATALOG_URL + this.openProfilesOnSelect = true + this.store = null + this._user = null + this._statusMessage = 'Sign in to search contacts.' + this._warmingUp = false + } + + connectedCallback () { + super.connectedCallback() + authSession.events.on('login', this._handleAuthChange) + authSession.events.on('logout', this._handleAuthChange) + this._handleAuthChange() + } + + disconnectedCallback () { + if (typeof authSession.events.off === 'function') { + authSession.events.off('login', this._handleAuthChange) + authSession.events.off('logout', this._handleAuthChange) + } else if (typeof authSession.events.removeListener === 'function') { + authSession.events.removeListener('login', this._handleAuthChange) + authSession.events.removeListener('logout', this._handleAuthChange) + } + super.disconnectedCallback() + } + + protected updated (changedProperties: Map) { + const combobox = this._getCombobox() + if (combobox && combobox.suggestionProvider !== this._provideSuggestions) { + combobox.suggestionProvider = this._provideSuggestions + } + + if (changedProperties.has('store') && !changedProperties.has('_user')) { + this._resetDiscoveryState() + if (this._user) { + void this._ensureDiscovery() + } + } + } + + private _getStore (): LiveStore | null { + return this.store || (solidLogicSingleton.store as LiveStore | null) || null + } + + private _getCombobox () { + return this.shadowRoot?.querySelector('solid-ui-combobox') as Combobox | null + } + + private _resetDiscoveryState () { + this._discoveredPeople.clear() + this._discoveryPromise = null + this._query = '' + this._warmingUp = false + this._updateStatus('', 0) + this._refreshComboboxOptions('') + } + + private _refreshComboboxOptions (query: string) { + const combobox = this._getCombobox() + if (!combobox) { + return + } + + combobox.options = this._buildSuggestions(query) + } + + private _updateStatus (query: string, visibleCount: number) { + if (!this._user) { + this._statusMessage = 'Sign in to search contacts.' + return + } + + if (this._warmingUp) { + this._statusMessage = 'Searching...' + return + } + + if (this._discoveredPeople.size === 0) { + this._statusMessage = 'No contacts found.' + return + } + + if (query.trim() && visibleCount === 0) { + this._statusMessage = 'No contacts match that name.' + return + } + + this._statusMessage = '' + } + + private _buildSuggestions (query: string): PeopleSearchSuggestion[] { + return sortPeopleSearchPeople(this._discoveredPeople.values()) + .filter((person) => matchesPeopleSearchNameWords(person.name, query)) + .map((person) => ({ + label: person.name, + value: person.webId, + publicId: person.webId, + description: person.relationshipLabel, + meta: { person } + })) + } + + private _mergePerson (person: PeopleSearchPerson) { + return mergePeopleSearchPerson(this._discoveredPeople, person) + } + + private async _ensureDiscovery () { + if (this._discoveryPromise) { + return this._discoveryPromise + } + + const store = this._getStore() + if (!this._user || !store) { + this._updateStatus(this._query, 0) + return Promise.resolve() + } + + this._warmingUp = true + this._updateStatus(this._query, 0) + + this._discoveryPromise = (async () => { + const renderPerson = async (person: PeopleSearchPerson) => { + const merged = this._mergePerson(person) + this._refreshComboboxOptions(this._query) + this._updateStatus(this._query, this._buildSuggestions(this._query).length) + return merged + } + + await discoverPeopleSearchEntries({ + store, + me: this._user as NamedNode, + catalogUrl: this.catalogUrl, + onPerson: renderPerson + }) + })() + .catch(() => { + this._statusMessage = 'Unable to load contacts.' + }) + .finally(() => { + this._warmingUp = false + this._refreshComboboxOptions(this._query) + this._updateStatus(this._query, this._buildSuggestions(this._query).length) + }) + + return this._discoveryPromise + } + + private _handleComboboxInput (event: Event) { + const customEvent = event as CustomEvent<{ value?: string }> + const inputTarget = event.target as HTMLInputElement | null + this._query = customEvent.detail?.value ?? inputTarget?.value ?? '' + this._updateStatus(this._query, this._buildSuggestions(this._query).length) + } + + private _handleComboboxFocusIn () { + if (!this._discoveryPromise && this._user) { + void this._ensureDiscovery() + } + this._refreshComboboxOptions(this._query) + this._updateStatus(this._query, this._buildSuggestions(this._query).length) + } + + private _handleComboboxChange (event: Event) { + const detail = (event as CustomEvent<{ value: string, option?: PeopleSearchSuggestion }>).detail + const selectedPerson = detail.option?.meta?.person || this._discoveredPeople.get(detail.value) + + if (!selectedPerson) { + return + } + + const personSelected = new CustomEvent('person-select', { + detail: { person: selectedPerson }, + bubbles: true, + composed: true, + cancelable: true + }) + + const shouldContinue = this.dispatchEvent(personSelected) + if (!shouldContinue || !this.openProfilesOnSelect) { + return + } + + const newWindow = window.open(selectedPerson.webId, '_blank', 'noopener,noreferrer') + if (newWindow) { + newWindow.opener = null + } + } + + render () { + return html` + + +
+ ${this._statusMessage} +
+ ` + } +} \ No newline at end of file diff --git a/src/v2/components/forms/peopleSearch/README.md b/src/v2/components/forms/peopleSearch/README.md new file mode 100644 index 000000000..4df4f84a7 --- /dev/null +++ b/src/v2/components/forms/peopleSearch/README.md @@ -0,0 +1,102 @@ +# solid-ui-people-search component + +A Lit-based custom element for searching people connected to the authenticated Solid user. It wraps `solid-ui-combobox` for the input experience, uses `solid-logic` auth/session state by default, and discovers people from the user's FOAF graph, linked address books, and the Solid catalog. + +This README preserves the behavioral notes from the legacy widget comment so they live with the component documentation rather than inside the TypeScript implementation. + +## Installation + +```bash +npm install solid-ui +``` + +## Usage in a bundled project (webpack, Vite, Rollup, etc.) + +```typescript +import { PeopleSearch } from 'solid-ui/components/forms/people-search' +``` + +The flat import `solid-ui/components/people-search` also works. + +```html + + + +``` + +## What It Searches + +The component offers a mechanism for selecting a set of individuals to take some action on. + +- It discovers people from the authenticated user's FOAF profile via `foaf:knows`. +- It follows the FOAF graph up to 3 degrees of separation. +- It loads contacts from linked address books. +- It also loads people listed in the Solid catalog. +- It performs client-side filtering on the discovered set for fast search-as-you-type behavior. +- It labels each result as `Friend`, `Contact`, or `People`. +- `Contact` takes precedence over `Friend`, and `Friend` takes precedence over `People` when a person is discovered from multiple sources. + +## Assumptions + +- The authenticated user is available through `solid-logic` (`authn.currentUser()`). +- If no `store` property is supplied, the component falls back to `solidLogicSingleton.store`. +- Address book discovery assumes the user has an appropriate type index entry for `vcard:AddressBook`. If not, no address book contacts will be discovered. + +## API + +### Properties / attributes + +| Property | Attribute | Type | Default | Description | +|----------|-----------|------|---------|-------------| +| `label` | `label` | `string` | `Search for people` | Visible label above the combobox. | +| `placeholder` | `placeholder` | `string` | `Search for people...` | Placeholder shown in the combobox input. | +| `theme` | `theme` | `'light' \| 'dark'` | `'light'` | Forwarded to the nested combobox. | +| `layout` | `layout` | `'desktop' \| 'mobile'` | `'desktop'` | Reserved for responsive integration. | +| `catalogUrl` | `catalog-url` | `string` | Solid catalog URL | Override the catalog source for additional people. | +| `openProfilesOnSelect` | `open-profiles-on-select` | `boolean` | `true` | When `true`, selecting a person opens their WebID in a new tab unless the `person-select` event is cancelled. | +| `store` | none | `LiveStore \| null` | `solidLogicSingleton.store` | RDF store used for FOAF traversal and address book lookups. Set as a JS property, not an HTML attribute. | + +### Events + +| Event | Detail | Description | +|-------|--------|-------------| +| `person-select` | `{ person: { name, webId, relationshipLabel } }` | Fired when a person is selected from the combobox. Cancel the event to prevent the default new-tab navigation. | + +### Styling + +The component forwards most search-field styling through the nested combobox. Useful CSS custom properties include: + +| Variable | Description | +|----------|-------------| +| `--people-search-width` | Max width of the component host. | +| `--people-search-status-color` | Status text colour below the search input. | +| `--people-search-status-font-size` | Status text size. | +| `--people-search-input-background` | Search input background. | +| `--people-search-input-border` | Search input border. | +| `--people-search-input-text` | Search input text colour. | +| `--people-search-popup-background` | Popup list background. | +| `--people-search-popup-border` | Popup border colour. | +| `--people-search-popup-shadow` | Popup shadow. | +| `--people-search-description-text` | Secondary result-label text colour (`Friend`, `Contact`, `People`). | + +## Authentication Behavior + +- The component subscribes to `authSession.events` for `login` and `logout`, following the same event-listener pattern used by other v2 components. +- On login, it refreshes the authenticated user and restarts discovery. +- On logout, it clears discovered results and shows the sign-in prompt. + +## Build + +```bash +npm run build +``` + +Webpack emits bundles to `dist/components/peopleSearch/index.*`. \ No newline at end of file diff --git a/src/v2/components/forms/peopleSearch/index.ts b/src/v2/components/forms/peopleSearch/index.ts new file mode 100644 index 000000000..af622ddba --- /dev/null +++ b/src/v2/components/forms/peopleSearch/index.ts @@ -0,0 +1,23 @@ +import { PeopleSearch } from './PeopleSearch' + +export { PeopleSearch } +export type { + PeopleSearchPerson, + PeopleSearchRelationshipLabel, + PeopleSearchSelectDetail, + PeopleSearchSuggestion +} from './PeopleSearch' +export { + DEFAULT_CATALOG_URL, + discoverPeopleSearchEntries, + matchesPeopleSearchNameWords, + mergePeopleSearchPerson, + sortPeopleSearchPeople, + tokenizePeopleSearchQuery +} from './peopleSearchHelpers' + +const PEOPLE_SEARCH_TAG_NAME = 'solid-ui-people-search' + +if (!customElements.get(PEOPLE_SEARCH_TAG_NAME)) { + customElements.define(PEOPLE_SEARCH_TAG_NAME, PeopleSearch) +} \ No newline at end of file diff --git a/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts b/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts new file mode 100644 index 000000000..bdf2294c2 --- /dev/null +++ b/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts @@ -0,0 +1,347 @@ +import ContactsModuleRdfLib, { type AddressBook } from '@solid-data-modules/contacts-rdflib' +import { NamedNode, graph, parse, type LiveStore } from 'rdflib' +import * as debug from '../../../../debug' +import ns from '../../../../ns' + +const PEOPLE_SEARCH_CONCURRENCY = 6 +const CONTACT_CARD_CONCURRENCY = 8 +const MAX_FOAF_DISTANCE = 3 +const CATALOG_VOCAB = 'http://example.org#' + +const addressBookListCache = new Map>() +const addressBookCache = new Map>() +const contactWebIdCache = new Map>() + +export const DEFAULT_CATALOG_URL = 'https://raw.githubusercontent.com/solid/catalog/refs/heads/main/catalog-data.ttl' + +export type PeopleSearchRelationshipLabel = 'Friend' | 'People' | 'Contact' + +export interface PeopleSearchPerson { + name: string + webId: string + relationshipLabel: PeopleSearchRelationshipLabel +} + +type DiscoverPeopleSearchEntriesArgs = { + store: LiveStore + me: NamedNode + catalogUrl: string + onPerson: (person: PeopleSearchPerson) => void | Promise +} + +const catalogTerm = function (localName: string): NamedNode { + return new NamedNode(`${CATALOG_VOCAB}${localName}`) +} + +export function tokenizePeopleSearchQuery (query: string): string[] { + return query + .toLowerCase() + .trim() + .split(/\s+/) + .filter(Boolean) +} + +export function matchesPeopleSearchNameWords (name: string, query: string): boolean { + const q = tokenizePeopleSearchQuery(query) + if (q.length === 0) return true + const nameWords = tokenizePeopleSearchQuery(name) + return q.every((word) => nameWords.some((nameWord) => nameWord.includes(word))) +} + +export function sortPeopleSearchPeople (people: Iterable): PeopleSearchPerson[] { + return Array.from(people) + .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) +} + +export function mergePeopleSearchPerson ( + discoveredPeople: Map, + person: PeopleSearchPerson +): PeopleSearchPerson { + const existing = discoveredPeople.get(person.webId) + if (existing) { + const merged = { + ...existing, + name: existing.name || person.name, + relationshipLabel: bestLabel(existing.relationshipLabel, person.relationshipLabel) + } + discoveredPeople.set(person.webId, merged) + return merged + } + + discoveredPeople.set(person.webId, person) + return person +} + +export async function discoverPeopleSearchEntries (args: DiscoverPeopleSearchEntriesArgs): Promise { + const { store, me, catalogUrl, onPerson } = args + + const contactsPromise = discoverAddressBookContacts(store, me, onPerson) + const peoplePromise = discoverFoafPeople(store, me, onPerson) + const catalogPromise = discoverCatalogPeople(catalogUrl, onPerson) + + const results = await Promise.allSettled([contactsPromise, peoplePromise, catalogPromise]) + if (results.every((result) => result.status === 'rejected')) { + throw new Error('Unable to load contacts.') + } +} + +function bestLabel ( + current: PeopleSearchRelationshipLabel | undefined, + incoming: PeopleSearchRelationshipLabel +): PeopleSearchRelationshipLabel { + if (current === 'Contact' || incoming === 'Contact') return 'Contact' + if (current === 'Friend' || incoming === 'Friend') return 'Friend' + return 'People' +} + +function nameFor (store: LiveStore, person: NamedNode): string | null { + const nameNode: { value: string } | null | undefined = + store.any(person, ns.foaf('name')) || store.any(person, ns.vcard('fn')) + return nameNode?.value || null +} + +async function fetchCatalogPeople (catalogUrl: string): Promise { + if (typeof fetch !== 'function') { + return [] + } + + try { + const response = await fetch(catalogUrl, { + headers: { accept: 'text/turtle' } + }) + + if (!response.ok) { + debug.warn(`[Catalog] Failed to fetch ${catalogUrl}: ${response.status}`) + return [] + } + + const turtle = await response.text() + const store = graph() + parse(turtle, store, catalogUrl, 'text/turtle') + + const personType = catalogTerm('Person') + const webIdPredicate = catalogTerm('webid') + const namePredicate = catalogTerm('name') + const catalogPeople = new Map() + + const personStatements = store.statementsMatching(undefined, ns.rdf('type'), personType) + for (const statement of personStatements) { + const subject = statement.subject + const webIdNode = store.any(subject, webIdPredicate) + if (!webIdNode || webIdNode.termType !== 'NamedNode') { + continue + } + + const webId = webIdNode.value + const name = store.anyValue(subject, namePredicate) + if (!webId || !name) { + continue + } + + catalogPeople.set(webId, { + name, + webId, + relationshipLabel: 'People' + }) + } + + return Array.from(catalogPeople.values()) + } catch (error) { + debug.warn('[Catalog] Error fetching people from catalog:', error) + return [] + } +} + +async function discoverCatalogPeople ( + catalogUrl: string, + onPerson: (person: PeopleSearchPerson) => void | Promise +) { + const catalogPeople = await fetchCatalogPeople(catalogUrl) + for (const person of catalogPeople) { + await onPerson(person) + } +} + +async function loadAddressBooks (store: LiveStore, me: NamedNode): Promise { + const cachedAddressBooks = addressBookListCache.get(me.value) + if (cachedAddressBooks) { + return cachedAddressBooks + } + + const contactsModule = new ContactsModuleRdfLib({ + store, + fetcher: store.fetcher, + updater: store.updater + }) + + const addressBooksPromise = contactsModule.listAddressBooks(me.value) + .then((addressBooks) => [...addressBooks.publicUris, ...addressBooks.privateUris]) + .catch((error) => { + addressBookListCache.delete(me.value) + throw error + }) + + addressBookListCache.set(me.value, addressBooksPromise) + return addressBooksPromise +} + +async function webIdForAddressBookContact (store: LiveStore, contactUri: string): Promise { + const cachedWebId = contactWebIdCache.get(contactUri) + if (cachedWebId) { + return cachedWebId + } + + const contactNode = new NamedNode(contactUri) + const webIdPromise = store.fetcher.load(contactNode.doc()) + .then(() => { + const webIdNode = store.any(contactNode, ns.vcard('url'), undefined, contactNode.doc()) as NamedNode | null + if (!webIdNode) return null + + return store.anyValue(webIdNode, ns.vcard('value'), undefined, contactNode.doc()) || null + }) + .catch(() => null) + + contactWebIdCache.set(contactUri, webIdPromise) + return webIdPromise +} + +async function readAddressBookCached (store: LiveStore, addressBookUri: string): Promise { + const cachedAddressBook = addressBookCache.get(addressBookUri) + if (cachedAddressBook) { + return cachedAddressBook + } + + const contactsModule = new ContactsModuleRdfLib({ + store, + fetcher: store.fetcher, + updater: store.updater + }) + + const addressBookPromise = contactsModule.readAddressBook(addressBookUri) + .catch((error) => { + addressBookCache.delete(addressBookUri) + throw error + }) + + addressBookCache.set(addressBookUri, addressBookPromise) + return addressBookPromise +} + +async function discoverAddressBookContacts ( + store: LiveStore, + me: NamedNode, + onPerson: (person: PeopleSearchPerson) => void | Promise +) { + const addressBooks = await loadAddressBooks(store, me) + + for (const book of addressBooks) { + let addressBook: AddressBook + + try { + addressBook = await readAddressBookCached(store, book) + } catch (_error) { + continue + } + + for (let index = 0; index < addressBook.contacts.length; index += CONTACT_CARD_CONCURRENCY) { + const batch = addressBook.contacts.slice(index, index + CONTACT_CARD_CONCURRENCY) + const people = await Promise.all(batch.map(async (contact) => { + const contactWebId = await webIdForAddressBookContact(store, contact.uri) + if (!contactWebId) { + return null + } + + return { + name: contact.name, + webId: contactWebId, + relationshipLabel: 'Contact' as const + } + })) + + for (const person of people) { + if (!person) continue + await onPerson(person) + } + } + } +} + +async function discoverFoafPeople ( + store: LiveStore, + me: NamedNode, + onPerson: (person: PeopleSearchPerson) => void | Promise +) { + const visited = new Set() + const emitted = new Set() + const loadedDocs = new Set() + let queue: Array<{ person: NamedNode, depth: number }> = [{ person: me, depth: 0 }] + visited.add(me.value) + + const processPerson = async ( + currentEntry: { person: NamedNode, depth: number } + ): Promise> => { + const { person: current, depth } = currentEntry + const currentDoc = current.doc().value + if (!loadedDocs.has(currentDoc)) { + loadedDocs.add(currentDoc) + try { + await store.fetcher.load(current.doc()) + } catch (_error) {} + } + + if (current.value !== me.value) { + const personName = nameFor(store, current) + if (personName && !emitted.has(current.value)) { + emitted.add(current.value) + await onPerson({ + name: personName, + webId: current.value, + relationshipLabel: depth === 1 ? 'Friend' : 'People' + }) + } + } + + const nextContacts: Array<{ person: NamedNode, depth: number }> = [] + if (depth >= MAX_FOAF_DISTANCE) { + return nextContacts + } + + const contacts = store.each(current, ns.foaf('knows')) + for (const contact of contacts) { + if (contact.termType !== 'NamedNode') { + continue + } + const namedContact = contact as NamedNode + const contactName = nameFor(store, namedContact) + if (namedContact.value !== me.value && contactName && !emitted.has(namedContact.value)) { + emitted.add(namedContact.value) + await onPerson({ + name: contactName, + webId: namedContact.value, + relationshipLabel: depth === 0 ? 'Friend' : 'People' + }) + } + + if (!visited.has(namedContact.value)) { + visited.add(namedContact.value) + nextContacts.push({ person: namedContact, depth: depth + 1 }) + } + } + + return nextContacts + } + + while (queue.length > 0) { + const nextQueue: Array<{ person: NamedNode, depth: number }> = [] + + for (let index = 0; index < queue.length; index += PEOPLE_SEARCH_CONCURRENCY) { + const batch = queue.slice(index, index + PEOPLE_SEARCH_CONCURRENCY) + const batchContacts = await Promise.all(batch.map(processPerson)) + for (const contacts of batchContacts) { + nextQueue.push(...contacts) + } + } + + queue = nextQueue + } +} \ No newline at end of file diff --git a/src/v2/components/forms/shared/listboxStyles.ts b/src/v2/components/forms/shared/listboxStyles.ts index 7ed20bd72..376f77863 100644 --- a/src/v2/components/forms/shared/listboxStyles.ts +++ b/src/v2/components/forms/shared/listboxStyles.ts @@ -4,6 +4,7 @@ export const listboxStyles = css` :host { // default theme --input-background: var(--color-background, #F8F9FB); --item-text: var(--color-text, #1A1A1A); + --item-description-text: var(--color-text-subtle, #667085); --item-selected-text: var(--color-primary, #7c4dff); --item-hover-background: var(--lavender-300, #e6dcff); --item-selected-background: var(--lavender-400, #cbb9ff); @@ -13,6 +14,7 @@ export const listboxStyles = css` :host([theme='dark']) { --input-background: var(--color-background, #1A1A1A); --item-text: var(--color-text, #F8F9FB); + --item-description-text: var(--color-text-subtle, #c7ced8); --item-selected-text: var(--color-primary, #7c4dff); --item-hover-background: var(--lavender-300, #e6dcff); --item-selected-background: var(--lavender-400, #cbb9ff); @@ -39,7 +41,7 @@ export const listboxStyles = css` .listbox-item { display: flex; - align-items: center; + align-items: flex-start; width: 100%; min-height: var(--select-trigger-height, var(--min-touch-target, 44px)); padding: var(--spacing-xxs, 0.3125rem) var(--spacing-xs, 0.75rem); @@ -54,6 +56,24 @@ export const listboxStyles = css` box-sizing: border-box; } + .listbox-item-content { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + gap: 0.125rem; + } + + .listbox-item-label { + overflow: hidden; + text-overflow: ellipsis; + } + + .listbox-item-description { + font-size: 0.75rem; + color: var(--item-description-text); + } + .listbox-item:last-child { border-bottom: none; } diff --git a/src/v2/components/forms/shared/listboxTemplate.ts b/src/v2/components/forms/shared/listboxTemplate.ts index 38d2ddfa4..608ee9c7a 100644 --- a/src/v2/components/forms/shared/listboxTemplate.ts +++ b/src/v2/components/forms/shared/listboxTemplate.ts @@ -47,7 +47,12 @@ export function renderListbox (args: RenderListboxArgs) { } }}" > - ${option.label} + + ${option.label} + ${option.description + ? html`${option.description}` + : ''} + ` })} diff --git a/src/v2/components/forms/shared/optionTypes.ts b/src/v2/components/forms/shared/optionTypes.ts index e0c76398a..ecc94dd96 100644 --- a/src/v2/components/forms/shared/optionTypes.ts +++ b/src/v2/components/forms/shared/optionTypes.ts @@ -1,5 +1,6 @@ export interface SelectOption { label: string value: string + description?: string disabled?: boolean } diff --git a/src/widgets/index.js b/src/widgets/index.js index 5469c2615..3b6f8e51d 100644 --- a/src/widgets/index.js +++ b/src/widgets/index.js @@ -18,7 +18,6 @@ // export widgets with the same name) export * from './peoplePicker' -export * from './peopleSearch' export * from './dragAndDrop' export * from './buttons' export * from './buttons/iconLinks' diff --git a/src/widgets/peopleSearch.ts b/src/widgets/peopleSearch.ts deleted file mode 100644 index cb3a47bba..000000000 --- a/src/widgets/peopleSearch.ts +++ /dev/null @@ -1,795 +0,0 @@ -/** - * - * People Search Widget - * - * This widget offers a mechanism for selecting a set of individuals to take some action on. - * It discovers people from the user's FOAF profile (predicate: foaf:knows (friends)) and - * linked profiles, as well as from their address books, and allows searching through them by name. - * It currently traverses the FOAF graph up to 3 degrees of separation (friends of friends of friends) - * to find people, and also loads contacts from any linked address books. The search is performed - * client-side on the discovered set of people, allowing for fast filtering as the user types. - * Below the name of each person, a label indicates whether they are a direct friend, a contact - * from an address book, or just a person discovered through the FOAF graph. Contacts take precedence - * over friends, and friends take precedence over people when determining the label. - * Configurable options include a click handler for when a person is selected; otherwise, it - * opens their profile in a new tab or window. - * - * Assumptions - * - Assumes that the user has a type index entry for vcard:AddressBook. If this assumption is not met, no address book contacts will be discovered. - * - */ -import { NamedNode, graph, parse, type LiveStore } from 'rdflib' -import ContactsModuleRdfLib, { type AddressBook } from '@solid-data-modules/contacts-rdflib' -import * as debug from '../debug' -import { ns } from '..' - -const PEOPLE_SEARCH_CONCURRENCY = 6 -const CONTACT_CARD_CONCURRENCY = 8 -const MAX_FOAF_DISTANCE = 3 -const CATALOG_URL = 'https://raw.githubusercontent.com/solid/catalog/refs/heads/main/catalog-data.ttl' -const CATALOG_VOCAB = 'http://example.org#' -let peopleSearchInstanceCounter = 0 -const addressBookListCache = new Map>() -const addressBookCache = new Map>() -const contactWebIdCache = new Map>() - -type PersonEntry = { - name: string, - webId: string, - relationshipLabel: 'Friend' | 'People' | 'Contact' -} - -const catalogTerm = function (localName: string): NamedNode { - return new NamedNode(`${CATALOG_VOCAB}${localName}`) -} - -const fetchCatalogPeople = async function (): Promise { - if (typeof fetch !== 'function') { - return [] - } - - try { - const response = await fetch(CATALOG_URL, { - headers: { - accept: 'text/turtle' - } - }) - - if (!response.ok) { - debug.warn(`[Catalog] Failed to fetch ${CATALOG_URL}: ${response.status}`) - return [] - } - - const turtle = await response.text() - const store = graph() - parse(turtle, store, CATALOG_URL, 'text/turtle') - - const personType = catalogTerm('Person') - const webIdPredicate = catalogTerm('webid') - const namePredicate = catalogTerm('name') - const catalogPeople = new Map() - - const personStatements = store.statementsMatching(undefined, ns.rdf('type'), personType) - for (const statement of personStatements) { - const subject = statement.subject - const webIdNode = store.any(subject, webIdPredicate) - if (!webIdNode || webIdNode.termType !== 'NamedNode') { - continue - } - - const webId = webIdNode.value - if (!webId) { - continue - } - - const name = store.anyValue(subject, namePredicate) - if (!name) { - continue - } - - catalogPeople.set(webId, { - name, - webId, - relationshipLabel: 'People' - }) - } - - return Array.from(catalogPeople.values()) - } catch (error) { - debug.warn('[Catalog] Error fetching people from catalog:', error) - return [] - } -} - -export const createPeopleSearch = function ( - dom: HTMLDocument, - kb: LiveStore, - me: NamedNode | null, - onClickHandler?: (person: PersonEntry) => void -): HTMLFormElement { - peopleSearchInstanceCounter += 1 - const instanceId = `people-search-${peopleSearchInstanceCounter}` - const inputId = `${instanceId}-input` - const labelId = `${instanceId}-label` - const listboxId = `${instanceId}-listbox` - - const contactsModule = new ContactsModuleRdfLib({ - store: kb, - fetcher: kb.fetcher, - updater: kb.updater - }) - - // Add responsive styles for people search - const styleId = 'people-search-styles' - if (!dom.getElementById(styleId)) { - const style = dom.createElement('style') - style.id = styleId - style.textContent = ` - .people-search-input { - padding: 10px; - font-size: 16px; - box-sizing: border-box; - width: max(28%, 280px); - max-width: 80%; - } - .people-search-dropdown { - width: max(28%, 280px); - max-width: 80%; - } - .people-search-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; - } - @media (max-width: 600px) { - .people-search-input, - .people-search-dropdown { - width: 80%; - } - } - ` - const styleContainer = dom.head || dom.documentElement || dom.body - styleContainer?.appendChild(style) - } - - const searchForm = dom.createElement('form') - const searchLabel = searchForm.appendChild(dom.createElement('label')) - searchLabel.id = labelId - searchLabel.htmlFor = inputId - searchLabel.className = 'people-search-sr-only' - searchLabel.textContent = 'Search for people' - - const searchInput = searchForm.appendChild(dom.createElement('input')) - searchInput.id = inputId - searchInput.type = 'text' - searchInput.placeholder = 'Search for people...' - searchInput.className = 'people-search-input' - searchInput.setAttribute('role', 'combobox') - searchInput.setAttribute('aria-autocomplete', 'list') - searchInput.setAttribute('aria-haspopup', 'listbox') - searchInput.setAttribute('aria-expanded', 'false') - searchInput.setAttribute('aria-labelledby', labelId) - searchInput.setAttribute('aria-controls', listboxId) - - const searchDiv = searchForm.appendChild(dom.createElement('div')) - searchDiv.id = listboxId - searchDiv.className = 'people-search-dropdown' - searchDiv.setAttribute('role', 'listbox') - searchDiv.setAttribute('aria-label', 'People search results') - searchDiv.style.display = 'none' - searchDiv.style.border = '1px solid #ccc' - searchDiv.style.marginTop = '5px' - searchDiv.style.padding = '5px' - searchDiv.style.boxSizing = 'border-box' - searchDiv.style.maxHeight = '15em' - searchDiv.style.overflowY = 'auto' - - const warmupHint = searchForm.appendChild(dom.createElement('div')) - warmupHint.style.display = 'none' - warmupHint.style.marginTop = '5px' - warmupHint.style.fontSize = '0.85em' - warmupHint.style.color = '#666' - warmupHint.textContent = 'Warming up contacts…' - - const liveStatus = searchForm.appendChild(dom.createElement('div')) - liveStatus.className = 'people-search-sr-only' - liveStatus.setAttribute('role', 'status') - liveStatus.setAttribute('aria-live', 'polite') - - const discoveredPeople = new Map() - const personRows = new Map() - const status = searchDiv.appendChild(dom.createElement('p')) - status.style.margin = '5px 0' - status.style.color = '#666' - - const setStatusText = function (text: string) { - status.textContent = text - liveStatus.textContent = text - } - - let activeRow: HTMLDivElement | null = null - - const setDropdownOpen = function (isOpen: boolean) { - searchDiv.style.display = isOpen ? 'block' : 'none' - searchInput.setAttribute('aria-expanded', isOpen ? 'true' : 'false') - } - - const getVisibleRows = function (): HTMLDivElement[] { - return Array.from(personRows.values()).filter(row => row.style.display !== 'none') - } - - const setActiveRow = function (row: HTMLDivElement | null) { - if (activeRow) { - activeRow.style.backgroundColor = 'white' - activeRow.setAttribute('aria-selected', 'false') - } - - activeRow = row - - if (activeRow) { - activeRow.style.backgroundColor = '#f0f0f0' - activeRow.setAttribute('aria-selected', 'true') - if (typeof activeRow.scrollIntoView === 'function') { - activeRow.scrollIntoView({ block: 'nearest' }) - } - if (activeRow.id) { - searchInput.setAttribute('aria-activedescendant', activeRow.id) - } - } else { - searchInput.removeAttribute('aria-activedescendant') - } - } - - const ensureActiveRowIsVisible = function () { - if (!activeRow) return - if (activeRow.style.display === 'none') { - setActiveRow(null) - } - } - - const selectPerson = function (person: PersonEntry) { - if (onClickHandler) { - onClickHandler(person) - } else { - const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer') - if (newWindow) { - newWindow.opener = null - } - } - setActiveRow(null) - setDropdownOpen(false) - } - - const addPersonRow = function (person: PersonEntry) { - const existingRow = personRows.get(person.webId) - if (existingRow) { - const nameElement = existingRow.firstChild as HTMLDivElement | null - const labelElement = existingRow.lastChild as HTMLDivElement | null - if (nameElement) { - nameElement.textContent = person.name - } - if (labelElement) { - labelElement.textContent = person.relationshipLabel - } - existingRow.title = person.webId - return existingRow - } - - const personElement = dom.createElement('div') - const optionIdSafeWebId = person.webId.replace(/[^a-zA-Z0-9_-]/g, '_') - const nameElement = personElement.appendChild(dom.createElement('div')) - const labelElement = personElement.appendChild(dom.createElement('div')) - - nameElement.textContent = person.name - labelElement.textContent = person.relationshipLabel - - personElement.title = person.webId - personElement.id = `${instanceId}-option-${optionIdSafeWebId}` - personElement.setAttribute('role', 'option') - personElement.setAttribute('aria-selected', 'false') - personElement.style.cursor = 'pointer' - personElement.style.margin = '5px 0' - personElement.style.padding = '2px 4px' - labelElement.style.fontSize = '0.75em' - labelElement.style.color = '#666' - - personElement.addEventListener('click', function () { - selectPerson(person) - }) - personElement.addEventListener('mouseover', function () { - setActiveRow(personElement) - }) - personElement.addEventListener('mouseout', function () { - if (activeRow !== personElement) { - personElement.style.backgroundColor = 'white' - } - }) - searchDiv.appendChild(personElement) - personRows.set(person.webId, personElement) - return personElement - } - - const sortVisibleRows = function () { - const visiblePeople = Array.from(discoveredPeople.values()) - .filter(person => { - const row = personRows.get(person.webId) - return row && row.style.display !== 'none' - }) - .sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' })) - - visiblePeople.forEach(person => { - const row = personRows.get(person.webId) - if (row) { - searchDiv.appendChild(row) - } - }) - } - - let sortQueued = false - const scheduleSortVisibleRows = function () { - if (sortQueued) return - sortQueued = true - - const flushSort = function () { - sortQueued = false - sortVisibleRows() - } - - if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { - window.requestAnimationFrame(flushSort) - return - } - - setTimeout(flushSort, 0) - } - - const updateVisibleRows = function (query: string): number { - let visibleCount = 0 - for (const [webId, person] of discoveredPeople.entries()) { - const row = personRows.get(webId) || addPersonRow(person) - const isVisible = matchesNameWords(person.name, query) - row.style.display = isVisible ? 'block' : 'none' - if (isVisible) { - visibleCount += 1 - } - } - scheduleSortVisibleRows() - ensureActiveRowIsVisible() - return visibleCount - } - - const updateRowVisibility = function (person: PersonEntry, query: string): boolean { - const row = personRows.get(person.webId) || addPersonRow(person) - const isVisible = matchesNameWords(person.name, query) - row.style.display = isVisible ? 'block' : 'none' - scheduleSortVisibleRows() - ensureActiveRowIsVisible() - return isVisible - } - - const tokenize = function (query: string): string[] { - return query - .toLowerCase() - .trim() - .split(/\s+/) - .filter(Boolean) - } - - const matchesNameWords = function (name: string, query: string): boolean { - const q = tokenize(query) - if (q.length === 0) return true - const nameWords = tokenize(name) - return q.every(word => nameWords.some(nameWord => nameWord.includes(word))) - } - - const nameFor = function (person: NamedNode): string | null { - const nameNode: { value: string } | null | undefined = - kb.any(person, ns.foaf('name')) || kb.any(person, ns.vcard('fn')) - return nameNode?.value || null - } - - const bestLabel = function ( - current: PersonEntry['relationshipLabel'] | undefined, - incoming: PersonEntry['relationshipLabel'] - ): PersonEntry['relationshipLabel'] { - if (current === 'Contact' || incoming === 'Contact') return 'Contact' - if (current === 'Friend' || incoming === 'Friend') return 'Friend' - return 'People' - } - - const mergePerson = function (person: PersonEntry) { - const existing = discoveredPeople.get(person.webId) - if (existing) { - discoveredPeople.set(person.webId, { - ...existing, - name: existing.name || person.name, - relationshipLabel: bestLabel(existing.relationshipLabel, person.relationshipLabel) - }) - return discoveredPeople.get(person.webId)! - } - discoveredPeople.set(person.webId, person) - return person - } - - const discoverPeople = async function (onPerson: (person: PersonEntry) => void | Promise) { - if (!me || !kb) return - - const visited = new Set() - const emitted = new Set() - const loadedDocs = new Set() - let queue: Array<{ person: NamedNode, depth: number }> = [{ person: me, depth: 0 }] - visited.add(me.value) - - const processPerson = async function ( - currentEntry: { person: NamedNode, depth: number } - ): Promise> { - const { person: current, depth } = currentEntry - const currentDoc = current.doc().value - if (!loadedDocs.has(currentDoc)) { - loadedDocs.add(currentDoc) - try { - await kb.fetcher.load(current.doc()) - } catch (_e) { /* skip inaccessible profiles */ } - } - - if (current.value !== me.value) { - const personName = nameFor(current) - if (personName && !emitted.has(current.value)) { - emitted.add(current.value) - const person: PersonEntry = { - name: personName, - webId: current.value, - relationshipLabel: depth === 1 ? 'Friend' : 'People' - } - await onPerson(person) - } - } - - const nextContacts: Array<{ person: NamedNode, depth: number }> = [] - if (depth >= MAX_FOAF_DISTANCE) { - return nextContacts - } - - const contacts = kb.each(current, ns.foaf('knows')) - for (const contact of contacts) { - if (contact.termType !== 'NamedNode') { - continue - } - const namedContact = contact as NamedNode - const contactName = nameFor(namedContact) - if (namedContact.value !== me.value && contactName && !emitted.has(namedContact.value)) { - emitted.add(namedContact.value) - await onPerson({ - name: contactName, - webId: namedContact.value, - relationshipLabel: depth === 0 ? 'Friend' : 'People' - }) - } - - if (!visited.has(namedContact.value)) { - visited.add(namedContact.value) - nextContacts.push({ person: namedContact, depth: depth + 1 }) - } - } - - return nextContacts - } - - while (queue.length > 0) { - const nextQueue: Array<{ person: NamedNode, depth: number }> = [] - - for (let index = 0; index < queue.length; index += PEOPLE_SEARCH_CONCURRENCY) { - const batch = queue.slice(index, index + PEOPLE_SEARCH_CONCURRENCY) - const batchContacts = await Promise.all(batch.map(processPerson)) - for (const contacts of batchContacts) { - nextQueue.push(...contacts) - } - } - - queue = nextQueue - } - } - - const loadAddressBooks = async function (): Promise { - if (!me || !kb) return [] - - const cachedAddressBooks = addressBookListCache.get(me.value) - if (cachedAddressBooks) { - return cachedAddressBooks - } - - const addressBooksPromise = contactsModule.listAddressBooks(me.value) - .then(addressBooks => [...addressBooks.publicUris, ...addressBooks.privateUris]) - .catch(error => { - addressBookListCache.delete(me.value) - throw error - }) - - addressBookListCache.set(me.value, addressBooksPromise) - return addressBooksPromise - } - - const webIdForAddressBookContact = async function (contactUri: string): Promise { - const cachedWebId = contactWebIdCache.get(contactUri) - if (cachedWebId) { - return cachedWebId - } - - const contactNode = new NamedNode(contactUri) - const webIdPromise = kb.fetcher.load(contactNode.doc()) - .then(function () { - const webIdNode = kb.any(contactNode, ns.vcard('url'), undefined, contactNode.doc()) as NamedNode | null - if (!webIdNode) return null - - return kb.anyValue(webIdNode, ns.vcard('value'), undefined, contactNode.doc()) || null - }) - .catch(function () { - return null - }) - - contactWebIdCache.set(contactUri, webIdPromise) - return webIdPromise - } - - const readAddressBookCached = async function (addressBookUri: string): Promise { - const cachedAddressBook = addressBookCache.get(addressBookUri) - if (cachedAddressBook) { - return cachedAddressBook - } - - const addressBookPromise = contactsModule.readAddressBook(addressBookUri) - .catch(error => { - addressBookCache.delete(addressBookUri) - throw error - }) - - addressBookCache.set(addressBookUri, addressBookPromise) - return addressBookPromise - } - - const discoverAddressBookContacts = async function ( - onPerson: (person: PersonEntry) => void | Promise - ) { - if (!me || !kb) return - - const addressBooks = await loadAddressBooks() - - for (const book of addressBooks) { - let addressBook: AddressBook - - try { - addressBook = await readAddressBookCached(book) - } catch (_e) { - continue - } - - for (let index = 0; index < addressBook.contacts.length; index += CONTACT_CARD_CONCURRENCY) { - const batch = addressBook.contacts.slice(index, index + CONTACT_CARD_CONCURRENCY) - const people = await Promise.all(batch.map(async function (contact) { - const contactWebId = await webIdForAddressBookContact(contact.uri) - if (!contactWebId) { - return null - } - - return { - name: contact.name, - webId: contactWebId, - relationshipLabel: 'Contact' as const - } - })) - - for (const person of people) { - if (!person) continue - await onPerson(person) - } - } - } - } - - const discoverCatalogPeople = async function ( - onPerson: (person: PersonEntry) => void | Promise - ) { - const catalogPeople = await fetchCatalogPeople() - for (const person of catalogPeople) { - await onPerson(person) - } - } - - let activeSearchId = 0 - let discoveryStarted = false - let discoveryPromise: Promise | null = null - - const ensureDiscovery = function () { - if (discoveryPromise) { - return discoveryPromise - } - - discoveryStarted = true - searchDiv.setAttribute('aria-busy', 'true') - setStatusText('Searching...') - warmupHint.style.display = 'block' - - discoveryPromise = (async function () { - const renderPerson = function (person: PersonEntry) { - try { - const merged = mergePerson(person) - addPersonRow(merged) - updateRowVisibility(merged, searchInput.value.trim()) - } catch (error) { - debug.error('[FOAF] Error rendering person:', error, person) - } - } - - const contactsPromise = discoverAddressBookContacts(function (person) { - try { - renderPerson(person) - } catch (error) { - debug.error('[Discovery] Error in contacts callback:', error) - } - }) - - const peoplePromise = discoverPeople(function (person) { - try { - renderPerson(person) - } catch (error) { - debug.error('[Discovery] Error in people callback:', error) - } - }) - - const catalogPromise = discoverCatalogPeople(function (person) { - try { - renderPerson(person) - } catch (error) { - debug.error('[Discovery] Error in catalog callback:', error) - } - }) - - const results = await Promise.allSettled([contactsPromise, peoplePromise, catalogPromise]) - if (results.every(result => result.status === 'rejected')) { - throw new Error('Unable to load contacts.') - } - })() - .catch(() => { - setStatusText('Unable to load contacts.') - }) - .finally(() => { - discoveryStarted = false - searchDiv.setAttribute('aria-busy', 'false') - warmupHint.style.display = 'none' - if (discoveredPeople.size === 0) { - setStatusText(me ? 'No contacts found.' : 'Sign in to search contacts.') - } else { - setStatusText('') - } - }) - - return discoveryPromise - } - - const runSearch = async function (query: string) { - const searchId = ++activeSearchId - setDropdownOpen(true) - - const visibleCount = updateVisibleRows(query.trim()) - if (!me) { - setStatusText('Sign in to search contacts.') - return - } - - if (!discoveryPromise) { - void ensureDiscovery() - } - - if (searchId !== activeSearchId) return - - if (visibleCount > 0) { - setStatusText(discoveryStarted ? 'Searching...' : '') - return - } - - setStatusText(discoveryStarted - ? 'Searching...' - : 'No contacts match that name.') - } - - let inputSearchQueued = false - const onInputHandler = function () { - if (inputSearchQueued) { - return - } - inputSearchQueued = true - - const flushInputSearch = function () { - inputSearchQueued = false - void runSearch(searchInput.value) - } - setTimeout(flushInputSearch, 0) - } - - const onFocusHandler = function () { - void runSearch(searchInput.value) - } - - const onBlurHandler = function () { - setTimeout(() => { - setActiveRow(null) - setDropdownOpen(false) - }, 200) - } - - const onKeyDownHandler = function (event: KeyboardEvent) { - const visibleRows = getVisibleRows() - - if (event.key === 'Tab') { - setActiveRow(null) - setDropdownOpen(false) - return - } - - if (event.key === 'Escape') { - setActiveRow(null) - setDropdownOpen(false) - return - } - - if (event.key === 'Home' || event.key === 'End') { - if (visibleRows.length === 0) { - return - } - event.preventDefault() - if (searchDiv.style.display === 'none') { - setDropdownOpen(true) - } - const targetIndex = event.key === 'Home' ? 0 : visibleRows.length - 1 - setActiveRow(visibleRows[targetIndex]) - return - } - - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault() - if (searchDiv.style.display === 'none') { - setDropdownOpen(true) - } - if (visibleRows.length === 0) { - return - } - const currentIndex = activeRow ? visibleRows.indexOf(activeRow) : -1 - const nextIndex = event.key === 'ArrowDown' - ? Math.min(currentIndex + 1, visibleRows.length - 1) - : (currentIndex <= 0 ? visibleRows.length - 1 : currentIndex - 1) - setActiveRow(visibleRows[nextIndex]) - return - } - - if (event.key === 'Enter' && activeRow) { - event.preventDefault() - const selectedPerson = discoveredPeople.get(activeRow.title) - if (selectedPerson) { - selectPerson(selectedPerson) - } - } - } - - searchInput.addEventListener('input', onInputHandler) - searchInput.addEventListener('focus', onFocusHandler) - searchInput.addEventListener('blur', onBlurHandler) - searchInput.addEventListener('keydown', onKeyDownHandler) - - searchForm.addEventListener('submit', function (event) { - event.preventDefault() - void runSearch(searchInput.value) - }) - - if (me) { - void ensureDiscovery() - } - - return searchForm -} - diff --git a/test/unit/widgets/peopleSearch.test.ts b/test/unit/widgets/peopleSearch.test.ts deleted file mode 100644 index 890133601..000000000 --- a/test/unit/widgets/peopleSearch.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { NamedNode } from 'rdflib' -import { silenceDebugMessages } from '../helpers/debugger' -import { createPeopleSearch } from '../../../src/widgets/peopleSearch' - -const mockListAddressBooks = jest.fn() -const mockReadAddressBook = jest.fn() -let bookCounter = 0 -let fetchMock: jest.Mock - -jest.mock('@solid-data-modules/contacts-rdflib', () => ({ - __esModule: true, - default: class ContactsModuleRdfLib { - listAddressBooks = mockListAddressBooks - readAddressBook = mockReadAddressBook - } -})) - -silenceDebugMessages() - -const flushAsyncWork = async function () { - await Promise.resolve() - await new Promise(resolve => setTimeout(resolve, 0)) - await Promise.resolve() -} - -const flushDiscovery = async function () { - await flushAsyncWork() - await flushAsyncWork() - await flushAsyncWork() -} - -type KbOptions = { - namesByWebId?: Record - contactWebIdsByCardUri?: Record - knowsByWebId?: Record> -} - -const makeKb = function (options: KbOptions = {}) { - const namesByWebId = options.namesByWebId || {} - const contactWebIdsByCardUri = options.contactWebIdsByCardUri || { - 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me' - } - const knowsByWebId = options.knowsByWebId || {} - - return { - fetcher: { - load: jest.fn().mockResolvedValue(undefined) - }, - updater: {}, - any: jest.fn((subject, predicate) => { - const subjectValue = subject?.value - const predicateValue = predicate?.value || '' - - if (!subjectValue) { - return null - } - - if (predicateValue.includes('foaf/0.1/name') || predicateValue.endsWith('#name')) { - const personName = namesByWebId[subjectValue] - return personName ? { value: personName } : null - } - - if (predicateValue.includes('/2006/vcard/ns#fn')) { - const personName = namesByWebId[subjectValue] - return personName ? { value: personName } : null - } - - if (predicateValue.includes('/2006/vcard/ns#url') && subjectValue in contactWebIdsByCardUri) { - return new NamedNode(subjectValue + '-url') - } - - return null - }), - anyValue: jest.fn((subject, predicate) => { - const subjectValue = subject?.value - const predicateValue = predicate?.value || '' - - if (!subjectValue || !predicateValue.includes('/2006/vcard/ns#value')) { - return null - } - - if (!subjectValue.endsWith('-url')) { - return null - } - - const cardUri = subjectValue.slice(0, -4) - return contactWebIdsByCardUri[cardUri] || null - }), - each: jest.fn((subject, predicate) => { - const subjectValue = subject?.value - const predicateValue = predicate?.value || '' - - if (!subjectValue || !predicateValue.includes('foaf/0.1/knows')) { - return [] - } - - return knowsByWebId[subjectValue] || [] - }) - } -} - -const openDropdown = async function (form: HTMLFormElement) { - const input = form.querySelector('input') as HTMLInputElement - input.dispatchEvent(new Event('focus')) - await flushDiscovery() -} - -const setSearchQuery = async function (form: HTMLFormElement, query: string) { - const input = form.querySelector('input') as HTMLInputElement - input.value = query - input.dispatchEvent(new Event('input')) - await flushDiscovery() -} - -const keyDown = function (element: HTMLElement, key: string) { - element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) -} - -const rowFor = function (form: HTMLFormElement, webId: string) { - return form.querySelector(`div[title="${webId}"]`) as HTMLDivElement | null -} - -const rowLabel = function (row: HTMLDivElement | null) { - if (!row) return null - return (row.lastElementChild as HTMLDivElement | null)?.textContent || null -} - -describe('createPeopleSearch', () => { - beforeEach(() => { - document.body.innerHTML = '' - jest.clearAllMocks() - fetchMock = jest.fn().mockResolvedValue({ - ok: false, - status: 404, - text: jest.fn().mockResolvedValue('') - }) - ;(globalThis as any).fetch = fetchMock - bookCounter += 1 - const defaultBookUri = `https://pod.example/address-book-${bookCounter}.ttl` - - mockListAddressBooks.mockResolvedValue({ - publicUris: [defaultBookUri], - privateUris: [] - }) - - mockReadAddressBook.mockResolvedValue({ - contacts: [ - { - uri: 'https://pod.example/contacts/1#this', - name: 'Alice Example' - } - ] - }) - }) - - it('renders a search input and hidden dropdown', () => { - const kb = makeKb() - const me = new NamedNode('https://user-1.example/profile/card#me') - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - const input = form.querySelector('input') as HTMLInputElement | null - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement | null - - expect(input).not.toBeNull() - expect(input?.placeholder).toBe('Search for people...') - expect(dropdown).not.toBeNull() - expect(dropdown?.style.display).toBe('none') - }) - - it('uses onClickHandler when provided and hides dropdown', async () => { - const kb = makeKb() - const me = new NamedNode('https://user-2.example/profile/card#me') - const onClickHandler = jest.fn() - const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null) - - const form = createPeopleSearch(document, kb as any, me, onClickHandler) - document.body.appendChild(form) - - await flushDiscovery() - - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - - await openDropdown(form) - - const personRow = rowFor(form, 'https://alice.example/profile/card#me') - expect(personRow).not.toBeNull() - - personRow?.dispatchEvent(new Event('click')) - - expect(onClickHandler).toHaveBeenCalledTimes(1) - expect(onClickHandler).toHaveBeenCalledWith({ - name: 'Alice Example', - webId: 'https://alice.example/profile/card#me', - relationshipLabel: 'Contact' - }) - expect(openSpy).not.toHaveBeenCalled() - expect(dropdown.style.display).toBe('none') - - openSpy.mockRestore() - }) - - it('falls back to opening webId when onClickHandler is not provided', async () => { - const kb = makeKb() - const me = new NamedNode('https://user-3.example/profile/card#me') - const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null) - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - await flushDiscovery() - - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - - await openDropdown(form) - - const personRow = rowFor(form, 'https://alice.example/profile/card#me') - expect(personRow).not.toBeNull() - - personRow?.dispatchEvent(new Event('click')) - - expect(openSpy).toHaveBeenCalledTimes(1) - expect(openSpy).toHaveBeenCalledWith('https://alice.example/profile/card#me', '_blank', 'noopener,noreferrer') - expect(dropdown.style.display).toBe('none') - - openSpy.mockRestore() - }) - - it('shows sign-in message when me is null', async () => { - const kb = makeKb() - - const form = createPeopleSearch(document, kb as any, null) - document.body.appendChild(form) - - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - - await openDropdown(form) - - expect(dropdown.style.display).toBe('block') - expect(dropdown.textContent).toContain('Sign in to search contacts.') - }) - - it('applies combobox/listbox accessibility attributes', async () => { - const kb = makeKb() - const me = new NamedNode('https://user-8.example/profile/card#me') - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - const input = form.querySelector('input') as HTMLInputElement - const label = form.querySelector('label') as HTMLLabelElement - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement - - expect(label).not.toBeNull() - expect(label.textContent).toBe('Search for people') - expect(input.getAttribute('role')).toBe('combobox') - expect(input.getAttribute('aria-autocomplete')).toBe('list') - expect(input.getAttribute('aria-haspopup')).toBe('listbox') - expect(input.getAttribute('aria-labelledby')).toBe(label.id) - expect(input.getAttribute('aria-controls')).toBe(dropdown.id) - expect(input.getAttribute('aria-expanded')).toBe('false') - expect(liveRegion).not.toBeNull() - expect(typeof liveRegion.textContent).toBe('string') - - await openDropdown(form) - - const personRow = rowFor(form, 'https://alice.example/profile/card#me') - expect(dropdown.getAttribute('role')).toBe('listbox') - expect(dropdown.getAttribute('aria-busy')).toBe('false') - expect(input.getAttribute('aria-expanded')).toBe('true') - expect(personRow?.getAttribute('role')).toBe('option') - expect(personRow?.id).toContain('-option-') - }) - - it('supports keyboard navigation and selection from the input', async () => { - mockReadAddressBook.mockResolvedValue({ - contacts: [ - { - uri: 'https://pod.example/contacts/1#this', - name: 'Alice Example' - }, - { - uri: 'https://pod.example/contacts/2#this', - name: 'Bob Stone' - } - ] - }) - - const kb = makeKb({ - contactWebIdsByCardUri: { - 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', - 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' - } - }) - const me = new NamedNode('https://user-9.example/profile/card#me') - const onClickHandler = jest.fn() - - const form = createPeopleSearch(document, kb as any, me, onClickHandler) - document.body.appendChild(form) - - await openDropdown(form) - - const input = form.querySelector('input') as HTMLInputElement - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement - const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement - - keyDown(input, 'ArrowDown') - expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) - expect(aliceRow.getAttribute('aria-selected')).toBe('true') - - keyDown(input, 'ArrowUp') - expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id) - expect(bobRow.getAttribute('aria-selected')).toBe('true') - - keyDown(input, 'Enter') - expect(onClickHandler).toHaveBeenCalledTimes(1) - expect(onClickHandler).toHaveBeenCalledWith({ - name: 'Bob Stone', - webId: 'https://bob.example/profile/card#me', - relationshipLabel: 'Contact' - }) - expect(dropdown.style.display).toBe('none') - expect(input.getAttribute('aria-expanded')).toBe('false') - - await openDropdown(form) - keyDown(input, 'Escape') - expect(dropdown.style.display).toBe('none') - expect(input.getAttribute('aria-expanded')).toBe('false') - }) - - it('supports Home/End navigation and closes on Tab', async () => { - mockReadAddressBook.mockResolvedValue({ - contacts: [ - { - uri: 'https://pod.example/contacts/1#this', - name: 'Alice Example' - }, - { - uri: 'https://pod.example/contacts/2#this', - name: 'Bob Stone' - } - ] - }) - - const kb = makeKb({ - contactWebIdsByCardUri: { - 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', - 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' - } - }) - const me = new NamedNode('https://user-11.example/profile/card#me') - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - await openDropdown(form) - - const input = form.querySelector('input') as HTMLInputElement - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement - const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement - - keyDown(input, 'End') - expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id) - - keyDown(input, 'Home') - expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id) - - keyDown(input, 'Tab') - expect(dropdown.style.display).toBe('none') - expect(input.getAttribute('aria-expanded')).toBe('false') - expect(input.getAttribute('aria-activedescendant')).toBeNull() - }) - - it('matches names by tokenized, case-insensitive words', async () => { - mockReadAddressBook.mockResolvedValue({ - contacts: [ - { - uri: 'https://pod.example/contacts/1#this', - name: 'Alice Example' - }, - { - uri: 'https://pod.example/contacts/2#this', - name: 'Bob Stone' - } - ] - }) - - const kb = makeKb({ - contactWebIdsByCardUri: { - 'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me', - 'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me' - } - }) - const me = new NamedNode('https://user-4.example/profile/card#me') - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - await openDropdown(form) - await setSearchQuery(form, 'EXA ali') - - const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') - const bobRow = rowFor(form, 'https://bob.example/profile/card#me') - - expect(aliceRow).not.toBeNull() - expect(aliceRow?.style.display).toBe('block') - expect(bobRow).not.toBeNull() - expect(bobRow?.style.display).toBe('none') - }) - - it('skips non-NamedNode foaf:knows values during traversal', async () => { - mockListAddressBooks.mockResolvedValue({ publicUris: [], privateUris: [] }) - - const me = new NamedNode('https://user-5.example/profile/card#me') - const friend = new NamedNode('https://friend.example/profile/card#me') - const kb = makeKb({ - namesByWebId: { - [friend.value]: 'Frank Friend' - }, - knowsByWebId: { - [me.value]: [{ value: 'https://not-a-named-node.example/#it' }, friend] - } - }) - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - await openDropdown(form) - - const friendRow = rowFor(form, friend.value) - const bogusRow = rowFor(form, 'https://not-a-named-node.example/#it') - - expect(friendRow).not.toBeNull() - expect(rowLabel(friendRow)).toBe('Friend') - expect(bogusRow).toBeNull() - }) - - it('merges duplicate people and prefers Contact label over Friend', async () => { - const sharedWebId = 'https://alice.example/profile/card#me' - mockReadAddressBook.mockResolvedValue({ - contacts: [ - { - uri: 'https://pod.example/contacts/shared#this', - name: 'Alice Contact' - } - ] - }) - - const me = new NamedNode('https://user-6.example/profile/card#me') - const friend = new NamedNode(sharedWebId) - const kb = makeKb({ - contactWebIdsByCardUri: { - 'https://pod.example/contacts/shared#this': sharedWebId - }, - namesByWebId: { - [sharedWebId]: 'Alice Friend' - }, - knowsByWebId: { - [me.value]: [friend] - } - }) - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - await openDropdown(form) - - const mergedRow = rowFor(form, sharedWebId) - expect(mergedRow).not.toBeNull() - expect(rowLabel(mergedRow)).toBe('Contact') - }) - - it('shows no-match status after discovery when query has no results', async () => { - const kb = makeKb() - const me = new NamedNode('https://user-7.example/profile/card#me') - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement - - await openDropdown(form) - await setSearchQuery(form, 'thiswillnotmatch') - - expect(dropdown.textContent).toContain('No contacts match that name.') - }) - - it('updates hidden live status text for no-match state', async () => { - const kb = makeKb() - const me = new NamedNode('https://user-10.example/profile/card#me') - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement - - await openDropdown(form) - await setSearchQuery(form, 'no-person-will-match-this') - - expect(liveRegion.textContent).toContain('No contacts match that name.') - }) - - it('includes people discovered from the Solid catalog', async () => { - mockListAddressBooks.mockResolvedValue({ publicUris: [], privateUris: [] }) - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: jest.fn().mockResolvedValue(` - @prefix ex: . - @prefix xsd: . - - a ex:Person ; - ex:name "Catalog Person" ; - ex:webid ; - ex:modified "2025-10-12T16:39:56.789Z"^^xsd:dateTime . - `) - }) - - const kb = makeKb() - const me = new NamedNode('https://user-12.example/profile/card#me') - - const form = createPeopleSearch(document, kb as any, me) - document.body.appendChild(form) - - await openDropdown(form) - - const catalogRow = rowFor(form, 'https://catalog-person.example/profile/card#me') - expect(catalogRow).not.toBeNull() - expect(rowLabel(catalogRow)).toBe('People') - }) -}) From 3b95bae4504e46ac3e163b75c61cd1d5d4431385 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 10:25:27 +1000 Subject: [PATCH 18/21] remove PeopleSearch story --- src/stories/PeopleSearch.stories.js | 201 ---------------------------- 1 file changed, 201 deletions(-) delete mode 100644 src/stories/PeopleSearch.stories.js diff --git a/src/stories/PeopleSearch.stories.js b/src/stories/PeopleSearch.stories.js deleted file mode 100644 index 838d4dc95..000000000 --- a/src/stories/PeopleSearch.stories.js +++ /dev/null @@ -1,201 +0,0 @@ -import '../v2/components/forms/peopleSearch/index' -import ContactsModuleRdfLib from '@solid-data-modules/contacts-rdflib' - -const CATALOG_URL = - 'https://raw.githubusercontent.com/solid/catalog/refs/heads/main/catalog-data.ttl' - -function makeMockKb () { - const meWebId = 'https://demo.example/profile/card#me' - const friendWebId = 'https://friend.example/profile/card#me' - const personFromKnowsWebId = 'https://person.example/profile/card#me' - const contactCardUri = 'https://demo.example/contacts/alice#this' - - const namesByWebId = { - [friendWebId]: 'Frank Friend', - [personFromKnowsWebId]: 'Pat Person' - } - - return { - fetcher: { - load: async function () { - return undefined - } - }, - updater: {}, - any: function (subject, predicate) { - const subjectValue = subject && subject.value - const predicateValue = predicate && predicate.value - - if (!subjectValue || !predicateValue) { - return null - } - - if ( - predicateValue.includes('foaf/0.1/name') || - predicateValue.endsWith('#name') || - predicateValue.includes('/2006/vcard/ns#fn') - ) { - const personName = namesByWebId[subjectValue] - return personName ? { value: personName } : null - } - - if ( - predicateValue.includes('/2006/vcard/ns#url') && - subjectValue === contactCardUri - ) { - return $rdf.namedNode(`${contactCardUri}-url`) - } - - return null - }, - anyValue: function (subject, predicate) { - const subjectValue = subject && subject.value - const predicateValue = predicate && predicate.value - - if (!subjectValue || !predicateValue) { - return null - } - - if ( - predicateValue.includes('/2006/vcard/ns#value') && - subjectValue === `${contactCardUri}-url` - ) { - return 'https://alice.example/profile/card#me' - } - - return null - }, - each: function (subject, predicate) { - const subjectValue = subject && subject.value - const predicateValue = predicate && predicate.value - - if (!subjectValue || !predicateValue) { - return [] - } - - if (!predicateValue.includes('foaf/0.1/knows')) { - return [] - } - - if (subjectValue === meWebId) { - return [ - $rdf.namedNode(friendWebId), - $rdf.namedNode(personFromKnowsWebId) - ] - } - - return [] - } - } -} - -function installPeopleSearchMocks () { - const originalListAddressBooks = - ContactsModuleRdfLib.prototype.listAddressBooks - const originalReadAddressBook = - ContactsModuleRdfLib.prototype.readAddressBook - const originalFetch = globalThis.fetch - - ContactsModuleRdfLib.prototype.listAddressBooks = async function () { - return { - publicUris: ['https://demo.example/address-book#this'], - privateUris: [] - } - } - - ContactsModuleRdfLib.prototype.readAddressBook = async function () { - return { - contacts: [ - { - uri: 'https://demo.example/contacts/alice#this', - name: 'Alice Contact' - } - ] - } - } - - globalThis.fetch = async function (input, init) { - const url = typeof input === 'string' ? input : input && input.url - - if (url === CATALOG_URL) { - return { - ok: true, - status: 200, - text: async function () { - return ` - @prefix ex: . - - a ex:Person ; - ex:name "Catalog Person" ; - ex:webid . - ` - } - } - } - - if (typeof originalFetch === 'function') { - return originalFetch(input, init) - } - - return { - ok: false, - status: 404, - text: async function () { - return '' - } - } - } - - return function restoreMocks () { - ContactsModuleRdfLib.prototype.listAddressBooks = originalListAddressBooks - ContactsModuleRdfLib.prototype.readAddressBook = originalReadAddressBook - globalThis.fetch = originalFetch - } -} - -export default { - title: 'Forms/PeopleSearch' -} - -export const SignedOut = { - render: () => { - const element = document.createElement('solid-ui-people-search') - element.store = makeMockKb() - return element - }, - name: 'signed out' -} - -export const WithMockData = { - render: () => { - const restoreMocks = installPeopleSearchMocks() - - const wrapper = document.createElement('div') - const info = document.createElement('p') - info.textContent = - 'Mocked sources: address book, foaf:knows, and Solid catalog. Type to filter.' - wrapper.appendChild(info) - - const originalCurrentUser = window.SolidLogic?.authn?.currentUser - const me = $rdf.namedNode('https://demo.example/profile/card#me') - if (window.SolidLogic?.authn) { - window.SolidLogic.authn.currentUser = () => me - } - const picker = document.createElement('solid-ui-people-search') - picker.store = makeMockKb() - picker.openProfilesOnSelect = false - wrapper.appendChild(picker) - - const cleanup = function () { - restoreMocks() - if (window.SolidLogic?.authn) { - window.SolidLogic.authn.currentUser = originalCurrentUser - } - wrapper.removeEventListener('DOMNodeRemovedFromDocument', cleanup) - } - wrapper.addEventListener('DOMNodeRemovedFromDocument', cleanup) - - return wrapper - }, - name: 'with mock data' -} From 807f8f83994a42307384fd3d0c924380b25442e9 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 10:30:10 +1000 Subject: [PATCH 19/21] fix lint errors --- jest.config.mjs | 4 +--- .../forms/peopleSearch/PeopleSearch.test.ts | 2 +- .../forms/peopleSearch/PeopleSearch.ts | 18 ++++++++---------- src/v2/components/forms/peopleSearch/index.ts | 2 +- .../forms/peopleSearch/peopleSearchHelpers.ts | 2 +- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/jest.config.mjs b/jest.config.mjs index 7faa2a18b..6db09b116 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -15,11 +15,9 @@ export default { ], moduleNameMapper: { '^@solid-data-modules/contacts-rdflib$': '/__mocks__/contacts-rdflib.ts', - }, - setupFilesAfterEnv: ['./test/helpers/setup.ts'], - moduleNameMapper: { '^.+\\.css$': '/__mocks__/styleMock.js' }, + setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], roots: ['/src', '/test', '/__mocks__'], } diff --git a/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts b/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts index a49fae073..660c01b64 100644 --- a/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts +++ b/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts @@ -142,4 +142,4 @@ describe('SolidUIPeopleSearch', () => { openSpy.mockRestore() }) -}) \ No newline at end of file +}) diff --git a/src/v2/components/forms/peopleSearch/PeopleSearch.ts b/src/v2/components/forms/peopleSearch/PeopleSearch.ts index 9bc7b3a34..f004510e3 100644 --- a/src/v2/components/forms/peopleSearch/PeopleSearch.ts +++ b/src/v2/components/forms/peopleSearch/PeopleSearch.ts @@ -10,8 +10,7 @@ import { matchesPeopleSearchNameWords, mergePeopleSearchPerson, sortPeopleSearchPeople, - type PeopleSearchPerson, - type PeopleSearchRelationshipLabel + type PeopleSearchPerson } from './peopleSearchHelpers' export interface PeopleSearchSelectDetail { @@ -96,7 +95,7 @@ export class PeopleSearch extends LitElement { this._user = authn.currentUser() this._resetDiscoveryState() if (this._user) { - void this._ensureDiscovery() + this._ensureDiscovery() } } @@ -109,7 +108,7 @@ export class PeopleSearch extends LitElement { } if (!this._discoveryPromise) { - void this._ensureDiscovery() + this._ensureDiscovery() } const suggestions = this._buildSuggestions(query) @@ -162,7 +161,7 @@ export class PeopleSearch extends LitElement { if (changedProperties.has('store') && !changedProperties.has('_user')) { this._resetDiscoveryState() if (this._user) { - void this._ensureDiscovery() + this._ensureDiscovery() } } } @@ -248,11 +247,10 @@ export class PeopleSearch extends LitElement { this._updateStatus(this._query, 0) this._discoveryPromise = (async () => { - const renderPerson = async (person: PeopleSearchPerson) => { - const merged = this._mergePerson(person) + const renderPerson = async (person: PeopleSearchPerson): Promise => { + this._mergePerson(person) this._refreshComboboxOptions(this._query) this._updateStatus(this._query, this._buildSuggestions(this._query).length) - return merged } await discoverPeopleSearchEntries({ @@ -283,7 +281,7 @@ export class PeopleSearch extends LitElement { private _handleComboboxFocusIn () { if (!this._discoveryPromise && this._user) { - void this._ensureDiscovery() + this._ensureDiscovery() } this._refreshComboboxOptions(this._query) this._updateStatus(this._query, this._buildSuggestions(this._query).length) @@ -332,4 +330,4 @@ export class PeopleSearch extends LitElement { ` } -} \ No newline at end of file +} diff --git a/src/v2/components/forms/peopleSearch/index.ts b/src/v2/components/forms/peopleSearch/index.ts index af622ddba..65c5e3781 100644 --- a/src/v2/components/forms/peopleSearch/index.ts +++ b/src/v2/components/forms/peopleSearch/index.ts @@ -20,4 +20,4 @@ const PEOPLE_SEARCH_TAG_NAME = 'solid-ui-people-search' if (!customElements.get(PEOPLE_SEARCH_TAG_NAME)) { customElements.define(PEOPLE_SEARCH_TAG_NAME, PeopleSearch) -} \ No newline at end of file +} diff --git a/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts b/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts index bdf2294c2..1b4875c95 100644 --- a/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts +++ b/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts @@ -344,4 +344,4 @@ async function discoverFoafPeople ( queue = nextQueue } -} \ No newline at end of file +} From 463e30df8b6fc74b2835dfc93245cfa2a82f4999 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 10:41:29 +1000 Subject: [PATCH 20/21] fix build errors --- .../components/forms/peopleSearch/PeopleSearch.test.ts | 9 ++++++--- src/v2/components/forms/peopleSearch/index.ts | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts b/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts index 660c01b64..f28605279 100644 --- a/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts +++ b/src/v2/components/forms/peopleSearch/PeopleSearch.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals' -import { namedNode } from 'rdflib' +import { namedNode, type NamedNode } from 'rdflib' import { authSession, authn } from 'solid-logic' import { PeopleSearch } from './PeopleSearch' -import './index' import ns from '../../../../ns' jest.mock('@solid-data-modules/contacts-rdflib', () => ({ @@ -32,6 +31,10 @@ const mockCurrentUser = authn.currentUser as jest.Mock const mockOn = authSession.events.on as jest.Mock const mockOff = authSession.events.off as jest.Mock +if (!customElements.get('solid-ui-people-search')) { + customElements.define('solid-ui-people-search', PeopleSearch) +} + function getPortalRoot () { const portalHost = document.querySelector('[data-solid-ui-combobox-portal]') as HTMLDivElement | null return portalHost?.shadowRoot ?? null @@ -49,7 +52,7 @@ describe('SolidUIPeopleSearch', () => { mockCurrentUser.mockReset() mockOn.mockReset() mockOff.mockReset() - ;(globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch = undefined + Reflect.deleteProperty(globalThis, 'fetch') }) it('is defined as a custom element', () => { diff --git a/src/v2/components/forms/peopleSearch/index.ts b/src/v2/components/forms/peopleSearch/index.ts index 65c5e3781..97dc9e68a 100644 --- a/src/v2/components/forms/peopleSearch/index.ts +++ b/src/v2/components/forms/peopleSearch/index.ts @@ -2,11 +2,13 @@ import { PeopleSearch } from './PeopleSearch' export { PeopleSearch } export type { - PeopleSearchPerson, - PeopleSearchRelationshipLabel, PeopleSearchSelectDetail, PeopleSearchSuggestion } from './PeopleSearch' +export type { + PeopleSearchPerson, + PeopleSearchRelationshipLabel +} from './peopleSearchHelpers' export { DEFAULT_CATALOG_URL, discoverPeopleSearchEntries, From e84ef9e13dc2ba09e4f6a7f6f73404a962b6bbf6 Mon Sep 17 00:00:00 2001 From: Sharon Stratsianis Date: Tue, 5 May 2026 11:22:04 +1000 Subject: [PATCH 21/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/v2/components/forms/peopleSearch/PeopleSearch.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/v2/components/forms/peopleSearch/PeopleSearch.ts b/src/v2/components/forms/peopleSearch/PeopleSearch.ts index f004510e3..257b2563a 100644 --- a/src/v2/components/forms/peopleSearch/PeopleSearch.ts +++ b/src/v2/components/forms/peopleSearch/PeopleSearch.ts @@ -190,6 +190,7 @@ export class PeopleSearch extends LitElement { } combobox.options = this._buildSuggestions(query) + combobox.requestUpdate() } private _updateStatus (query: string, visibleCount: number) {