('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}
+
+ `
+ }
+}
diff --git a/src/v2/components/forms/peopleSearch/README.md b/src/v2/components/forms/peopleSearch/README.md
new file mode 100644
index 00000000..4df4f84a
--- /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 00000000..97dc9e68
--- /dev/null
+++ b/src/v2/components/forms/peopleSearch/index.ts
@@ -0,0 +1,25 @@
+import { PeopleSearch } from './PeopleSearch'
+
+export { PeopleSearch }
+export type {
+ PeopleSearchSelectDetail,
+ PeopleSearchSuggestion
+} from './PeopleSearch'
+export type {
+ PeopleSearchPerson,
+ PeopleSearchRelationshipLabel
+} from './peopleSearchHelpers'
+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)
+}
diff --git a/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts b/src/v2/components/forms/peopleSearch/peopleSearchHelpers.ts
new file mode 100644
index 00000000..1b4875c9
--- /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
+ }
+}
diff --git a/src/v2/components/forms/shared/listboxStyles.ts b/src/v2/components/forms/shared/listboxStyles.ts
index 7ed20bd7..376f7786 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 38d2ddfa..608ee9c7 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 e0c76398..ecc94dd9 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
}