diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts
new file mode 100644
index 000000000..de868c576
--- /dev/null
+++ b/src/components/rdf-form/RDFForm.ts
@@ -0,0 +1,137 @@
+import { property, state } from 'lit/decorators.js'
+import { html } from 'lit/html.js'
+import { customElement, WebComponent } from '@/lib/components'
+import ns from '../../lib/ns'
+import { loadDocument, sortBySequence } from '../../lib/forms/rdfFormsHelper'
+import { sym, Namespace } from 'rdflib'
+import { store } from 'solid-logic'
+import '@/components/rdf-input'
+
+@customElement('solid-ui-rdf-form')
+export default class RDFForm extends WebComponent {
+ @state()
+ private accessor _parsedUrl: URL | null = null
+
+ @state()
+ private accessor _parsedUrl2: URL | null = null
+
+ @property({ type: String })
+ accessor whichForm = 'this'
+
+ @property({ type: String })
+ accessor rdfTurtleFormatSource = ''
+
+ @property({ type: String })
+ accessor rdfName = ''
+
+ @property({ type: String })
+ set rdfURI (value: string) {
+ try {
+ this._parsedUrl = new URL(value)
+ } catch {
+ this._parsedUrl = null // Handle invalid URL
+ }
+ }
+
+ get rdfURI (): string {
+ return this._parsedUrl ? this._parsedUrl.href : ''
+ }
+
+ @property({ type: String })
+ accessor whichSubject = 'me'
+
+ @property({ type: String })
+ accessor subjectTurtleFormatSource = ''
+
+ @property({ type: String })
+ accessor subjectName = ''
+
+ @property({ type: String })
+ set subjectURI (value: string) {
+ try {
+ this._parsedUrl2 = new URL(value)
+ } catch {
+ this._parsedUrl2 = null // Handle invalid URL
+ }
+ }
+
+ get subjectURI (): string {
+ return this._parsedUrl2 ? this._parsedUrl2.href : ''
+ }
+
+ render () {
+ // TODO: detect format
+ loadDocument(store, this.rdfTurtleFormatSource, this.rdfName, this.rdfURI) // load form
+ loadDocument(store, this.subjectTurtleFormatSource, this.subjectName, this.subjectURI) // load data
+ const document = sym(this.rdfURI) // rdflib NamedNode for the document
+ const exactForm = this.whichForm // If there are more 'a ui:Form' elements in a form file
+ const formThis = Namespace(this.rdfURI + '#')(exactForm) // NamedNode for #this in the form
+
+ const parts = store.each(formThis, ns.ui('parts'), null, document)
+ const partsBySequence = sortBySequence(store, parts)
+ const partItems = (partsBySequence || []).flatMap(item => {
+ if (item && typeof item === 'object' && 'elements' in item && Array.isArray((item as any).elements)) {
+ return (item as any).elements
+ }
+ return [item]
+ })
+ const uiFields = partItems.map(item => {
+ const types = store.each(item as any, ns.rdf('type'), null, document)
+ const typeNode = types[0]
+ const value = typeNode ? ((typeNode as any).value || String(typeNode)) : ((item as any).value || String(item))
+ const hashIndex = value.lastIndexOf('#')
+ return {
+ value: item,
+ fieldValue: hashIndex >= 0 ? value.slice(hashIndex + 1) : value
+ }
+ })
+ const me = Namespace(this.subjectURI + '#')(this.whichSubject)
+
+ return html`
+ ${uiFields.map(part => {
+ switch (part.fieldValue) {
+ case 'PhoneField':
+ case 'EmailField':
+ case 'ColorField':
+ case 'DateField':
+ case 'DateTimeField':
+ case 'TimeField':
+ case 'NumericField':
+ case 'IntegerField':
+ case 'DecimalField':
+ case 'FloatField':
+ case 'TextField':
+ case 'SingleLineTextField':
+ case 'NamedNodeURIField': {
+ return html` `
+ }
+ case 'MultiLineTextField':
+ return html``
+ case 'BooleanField':
+ return html``
+ case 'TristateField':
+ return html``
+ case 'Classifier':
+ return html``
+ case 'Choice':
+ return html``
+ case 'Multiple':
+ return html``
+ case 'Options':
+ return html``
+ case 'AutocompleteField':
+ return html``
+ case 'Comment':
+ case 'Heading':
+ return html``
+ default:
+ return html`
Unknown part type: ${part}
`
+ }
+ })}
+ `
+ }
+}
diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts
new file mode 100644
index 000000000..fd54bb8d1
--- /dev/null
+++ b/src/components/rdf-form/RDForm.stories.ts
@@ -0,0 +1,115 @@
+import { html } from 'lit'
+import { defineStoryRender } from '../../storybook'
+import './RDFForm'
+
+const meta = {
+ title: 'Design System/RDF Form',
+ args: {
+ rdfTurtleFormatSource: `
+ @prefix : .
+ @prefix foaf: .
+ @prefix sched: .
+ @prefix cal: .
+ @prefix dc: .
+ @prefix rdfs: .
+ @prefix ui: .
+ @prefix trip: .
+ @prefix vcard: .
+ @prefix xsd: .
+
+ # A Form with 2 fields and a nested subgroup
+
+ :form a ui:Form;
+ ui:parts (:nameField :emailField :addresses) .
+
+ :nameField a ui:SingleLineTextField ;
+ ui:property vcard:fn;
+ ui:label "name" .
+
+ :emailField a ui:EmailField ;
+ ui:property vcard:hasEmail; # @@ check
+ ui:label "email" .
+
+ :addresses
+ a ui:Multiple ; # -- Allows zero or one or more
+ ui:part :oneAddress ;
+ ui:property vcard:hasAddress .
+
+ :oneAddress
+ a ui:Group ; # A subgroup of the main form
+ ui:parts ( :street :locality :postcode :region :country ).
+
+ :street
+ a ui:SingleLineTextField ;
+ ui:maxLength "128" ;
+ ui:property vcard:street-address ;
+ ui:size "40" .
+
+ :locality
+ a ui:SingleLineTextField ;
+ ui:maxLength "128" ;
+ ui:property vcard:locality ;
+ ui:size "40" .
+
+ :postcode
+ a ui:SingleLineTextField ;
+ ui:maxLength "25" ;
+ ui:property vcard:postal-code ;
+ ui:size "25" .
+
+ :region
+ a ui:SingleLineTextField ;
+ ui:maxLength "128" ;
+ ui:property vcard:region ;
+ ui:size "40" .
+
+ :country
+ a ui:SingleLineTextField ;
+ ui:maxLength "128" ;
+ ui:property vcard:country-name ;
+ ui:size "40" .
+ `,
+ rdfURI: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', // we need a working URL
+ whichForm: 'form',
+ rdfName: 'dummyFormTestFile.ttl',
+ whichSubject: 'me',
+ subjectTurtleFormatSource: `
+ @prefix : .
+ @prefix vcard: .
+
+ :me a vcard:Individual ;
+ vcard:fn "Alice" ;
+ vcard:hasEmail .
+ `,
+ subjectName: 'alice.ttl',
+ subjectURI: 'https://solidos.solidcommunity.net/public/2021/alice.ttl'
+ },
+
+ argTypes: {
+ rdfTurtleFormatSource: { control: 'text' },
+ rdfURI: { control: 'text' },
+ whichForm: { control: 'text' },
+ rdfName: { control: 'text' },
+ subjectTurtleFormatSource: { control: 'text' },
+ subjectName: { control: 'text' },
+ subjectURI: { control: 'text' }
+ },
+} as const
+
+const render = defineStoryRender(({ rdfTurtleFormatSource, rdfURI, whichForm, rdfName, subjectTurtleFormatSource, subjectName, subjectURI }) => {
+ return html`
+
+
+ `
+})
+
+export default meta
+
+export const Primary = { render }
diff --git a/src/components/rdf-form/index.ts b/src/components/rdf-form/index.ts
new file mode 100644
index 000000000..487b3a932
--- /dev/null
+++ b/src/components/rdf-form/index.ts
@@ -0,0 +1,4 @@
+import RDFForm from './RDFForm'
+
+export { RDFForm }
+export default RDFForm
diff --git a/src/components/rdf-input/RDFInput.ts b/src/components/rdf-input/RDFInput.ts
new file mode 100644
index 000000000..1f0cace6a
--- /dev/null
+++ b/src/components/rdf-input/RDFInput.ts
@@ -0,0 +1,100 @@
+import { property } from 'lit/decorators.js'
+import { html } from 'lit/html.js'
+import ns from '../../lib/ns'
+import { customElement, WebComponent } from '@/lib/components'
+import { LiveStore, NamedNode } from 'rdflib'
+import { label } from '../../utils'
+import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper'
+import { fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams'
+import { ifDefined } from 'lit/directives/if-defined.js'
+
+@customElement('solid-ui-rdf-input')
+export default class RDFInput extends WebComponent {
+ // example RDF Turtle format source:
+ // :nameField a ui:SingleLineTextField ;
+ // ui:property vcard:fn;
+ // ui:label "name" .
+
+ // formSubject describes the field metadata
+ // dataSubject points to the data resource containing the value
+
+ @property({ attribute: false })
+ accessor store!: LiveStore
+
+ @property({ attribute: false, type: Object })
+ accessor formSubject!: NamedNode
+
+ @property({ attribute: false, type: Object })
+ accessor dataSubject!: NamedNode
+
+ render () {
+ const formGraph = this.getFormGraph(this.formSubject)
+
+ // for building the HTML input element
+ const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formGraph)
+ const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formGraph)
+ const readonly = this.getReadOnly(this.formSubject, formGraph)
+
+ const fieldType = this.formSubject ? mostSpecificClassURI(this.store, this.formSubject) : undefined
+ const params = fieldType ? fieldTypeParams[fieldType] ?? {} : {}
+ const inputType: InputType = params.type ?? 'text'
+
+ // for populating the HTML input element
+ const selectedTerm = this.getSelectedTerm(this.dataSubject, uiPropertyTerm, this.formSubject, params)
+ const inputValue = this.termToInputValue(selectedTerm, params)
+
+ return html`
+ ${inputLabel ? html`` : ''}
+
+ `
+ }
+
+ private getFormGraph (subject?: NamedNode) {
+ return subject?.doc ? subject.doc() : undefined
+ }
+
+ private getFormProperty (subject: NamedNode | undefined, property: NamedNode, graph?: any): NamedNode | undefined {
+ if (!subject) return undefined
+ return this.store.any(subject, property, null, graph) as NamedNode | undefined
+ }
+
+ private getInputLabel (formFieldSubject: NamedNode | undefined, uiPropertyTerm?: NamedNode, graph?: any): string {
+ if (!formFieldSubject) return ''
+ const uiLabel = this.store.any(formFieldSubject, ns.ui('label'), null, graph)
+ const propertyLabel = uiPropertyTerm ? label(uiPropertyTerm, true) : ''
+ return uiLabel ? uiLabel.value : propertyLabel
+ }
+
+ private getReadOnly (formFieldSubject?: NamedNode, graph?: any): boolean {
+ if (!formFieldSubject) return false
+ return !!this.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph)
+ }
+
+ private getSelectedTerm (
+ dataSubject?: NamedNode,
+ uiPropertyTerm?: NamedNode,
+ formFieldSubject?: NamedNode,
+ params?: { defaultInputValue?: string }
+ ) {
+ const defaultTerm = formFieldSubject
+ ? this.store.any(formFieldSubject, ns.ui('default'))
+ : undefined
+
+ if (!uiPropertyTerm || !dataSubject) {
+ return defaultTerm
+ }
+
+ const inputTerm = this.store.any(dataSubject, uiPropertyTerm)
+ return inputTerm || defaultTerm
+ }
+
+ private termToInputValue (term: any, params: { defaultInputValue?: string } = {}) {
+ if (!term || !('value' in term) || !term.value) return undefined
+
+ const decoded = decodeURIComponent(term.value)
+ if (!params.defaultInputValue) return decoded
+
+ const stripped = decoded.replace(params.defaultInputValue, '')
+ return stripped.replace(/ /g, '')
+ }
+}
diff --git a/src/components/rdf-input/index.ts b/src/components/rdf-input/index.ts
new file mode 100644
index 000000000..bffe87ff2
--- /dev/null
+++ b/src/components/rdf-input/index.ts
@@ -0,0 +1,4 @@
+import RDFInput from './RDFInput'
+
+export { RDFInput }
+export default RDFInput
diff --git a/src/lib/forms/fieldParams.ts b/src/lib/forms/fieldParams.ts
new file mode 100644
index 000000000..6f8342f2b
--- /dev/null
+++ b/src/lib/forms/fieldParams.ts
@@ -0,0 +1,140 @@
+import ns from '../../lib/ns'
+import { style } from '../../lib/style'
+
+export type InputType =
+ | 'hidden'
+ | 'text'
+ | 'search'
+ | 'tel'
+ | 'url'
+ | 'email'
+ | 'password'
+ | 'datetime'
+ | 'date'
+ | 'month'
+ | 'week'
+ | 'time'
+ | 'datetime-local'
+ | 'number'
+ | 'range'
+ | 'color'
+ | 'checkbox'
+ | 'radio'
+ | 'file'
+ | 'submit'
+ | 'image'
+ | 'reset'
+ | 'button'
+
+export type FieldParamsObject = {
+ size?: number, // input element size attribute
+ type?: InputType, // input element type attribute. Default: 'text' (not for Comment and Heading)
+ element?: string, // element type to use (Comment and Heading only)
+ style?: string, // style to use
+ dt?: string, // xsd data type for the RDF Literal corresponding to the field value. Default: xsd:string
+ defaultInputValue?: string, // e.g. 'mailto:'. Default value in input field, will be removed when displaying actual value to user.
+ namedNode?: boolean, // if true, field value corresponds to the URI of an RDF NamedNode. Overrides dt and defaultInputValue.
+ pattern?: RegExp // for client-side input validation; field will go red if violated, green if ok
+}
+
+/**
+ * The fieldParams object defines various constants
+ * for use in various form fields. Depending on the
+ * field in questions, different values may be read
+ * from here.
+ */
+export const fieldParams: { [ fieldUri: string ]: FieldParamsObject } = {
+ /**
+ * Text field
+ *
+ * For possible date popups see e.g. http://www.dynamicdrive.com/dynamicindex7/jasoncalendar.htm
+ * or use HTML5: http://www.w3.org/TR/2011/WD-html-markup-20110113/input.date.html
+ */
+ [ns.ui('ColorField').uri]: {
+ size: 9,
+ type: 'color',
+ style: 'height: 3em;', // around 1.5em is padding
+ dt: 'color',
+ pattern: /^\s*#[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]([0-9a-f][0-9a-f])?\s*$/
+ }, // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/color
+
+ [ns.ui('DateField').uri]: {
+ size: 20,
+ type: 'date',
+ dt: 'date',
+ pattern: /^\s*[0-9][0-9][0-9][0-9](-[0-1]?[0-9]-[0-3]?[0-9])?Z?\s*$/
+ },
+
+ [ns.ui('DateTimeField').uri]: {
+ size: 20,
+ type: 'datetime-local', // See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime
+ dt: 'dateTime',
+ pattern: /^\s*[0-9][0-9][0-9][0-9](-[0-1]?[0-9]-[0-3]?[0-9])?(T[0-2][0-9]:[0-5][0-9](:[0-5][0-9])?)?Z?\s*$/
+ },
+
+ [ns.ui('TimeField').uri]: {
+ size: 10,
+ type: 'time',
+ dt: 'time',
+ pattern: /^\s*([0-2]?[0-9]:[0-5][0-9](:[0-5][0-9])?)\s*$/
+ },
+
+ [ns.ui('IntegerField').uri]: {
+ size: 12,
+ style: 'text-align: right;',
+ dt: 'integer',
+ pattern: /^\s*-?[0-9]+\s*$/
+ },
+
+ [ns.ui('DecimalField').uri]: {
+ size: 12,
+ style: 'text-align: right;',
+ dt: 'decimal',
+ pattern: /^\s*-?[0-9]*(\.[0-9]*)?\s*$/
+ },
+
+ [ns.ui('FloatField').uri]: {
+ size: 12,
+ style: 'text-align: right;',
+ dt: 'float',
+ pattern: /^\s*-?[0-9]*(\.[0-9]*)?((e|E)-?[0-9]*)?\s*$/
+ },
+
+ [ns.ui('SingleLineTextField').uri]: {
+
+ },
+ [ns.ui('NamedNodeURIField').uri]: {
+ namedNode: true
+ },
+ [ns.ui('TextField').uri]: {
+
+ },
+
+ [ns.ui('PhoneField').uri]: {
+ size: 20,
+ defaultInputValue: 'tel:',
+ pattern: /^\+?[\d-]+[\d]*$/
+ },
+
+ [ns.ui('EmailField').uri]: {
+ size: 30,
+ defaultInputValue: 'mailto:',
+ pattern: /^\s*.*@.*\..*\s*$/ // @@ Get the right regexp here
+ },
+
+ [ns.ui('Group').uri]: {
+ style: style.formGroupStyle
+ },
+
+ /**
+ * Non-interactive fields
+ */
+ [ns.ui('Comment').uri]: {
+ element: 'p',
+ style: style.commentStyle
+ },
+ [ns.ui('Heading').uri]: {
+ element: 'h3',
+ style: style.formHeadingStyle
+ }
+}
diff --git a/src/lib/forms/rdfFormsHelper.ts b/src/lib/forms/rdfFormsHelper.ts
new file mode 100644
index 000000000..02cf1b5c3
--- /dev/null
+++ b/src/lib/forms/rdfFormsHelper.ts
@@ -0,0 +1,62 @@
+import { sym, LiveStore, parse } from 'rdflib'
+import type { Term } from 'rdflib/lib/tf-types'
+// eslint-disable-next-line camelcase
+import type { Quad_Subject } from 'rdflib/lib/tf-types'
+import ns from '../../lib/ns'
+
+const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/'
+
+// we need to load into the store some additional information about Social Media accounts
+export function loadDocument (
+ store: LiveStore,
+ documentSource: string,
+ documentName: string,
+ documentURI?: string
+) {
+ const finalDocumentUri = documentURI || baseUri + documentName // Full URI to the file
+ const document = sym(finalDocumentUri) // rdflib NamedNode for the document
+
+ if (store.holds(undefined, undefined, undefined, document)) {
+ store.removeStatements(store.statementsMatching(undefined, undefined, undefined, document))
+ }
+ // we are using the social media form because it contains the information we need
+ // the form can be used for both use cases: create UI for edit and render UI for display
+ parse(documentSource, store, finalDocumentUri, 'text/turtle', (err) => {
+ if (err) {
+ console.error('loadDocument parse error for', finalDocumentUri, err)
+ }
+ })
+}
+
+export function sortBySequence (
+ store: LiveStore,
+ list: Term[]
+) {
+ const subfields = list.map((p) => {
+ const k = store.any(p as any, ns.ui('sequence'))
+ const seq = k ? Number((k as { value: string }).value) : 9999
+ return [Number.isNaN(seq) ? 9999 : seq, p] as const
+ })
+
+ subfields.sort((a, b) => a[0] - b[0])
+
+ return subfields.map(pair => pair[1])
+}
+
+/**
+ * Which class of field is this? Relies on http://www.w3.org/2000/01/rdf-schema#subClassOf and
+ * https://linkeddata.github.io/rdflib.js/doc/classes/formula.html#bottomtypeuris
+ * to find the most specific RDF type if there are multiple.
+ *
+ * @param subject a form field, e.g. `namedNode('https://timbl.com/timbl/Public/Test/Forms/individualForm.ttl#fullNameField')`
+ * @returns the URI of the most specific known class, e.g. `http://www.w3.org/ns/ui#SingleLineTextField`
+ */
+// eslint-disable-next-line camelcase
+export function mostSpecificClassURI (store: LiveStore, subject: Quad_Subject): string {
+ const typeUri = store.findTypeURIs(subject)
+ const specificTypes = store.bottomTypeURIs(typeUri) // most specific
+ const finalTypes: any[] = []
+ for (const t in specificTypes) finalTypes.push(t)
+ // if (finalTypes.length > 1) throw "Didn't expect "+subject+" to have multiple bottom types: "+finalTypes
+ return finalTypes[0]
+}
diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts
new file mode 100644
index 000000000..6c765dd06
--- /dev/null
+++ b/src/types/custom-elements.d.ts
@@ -0,0 +1,14 @@
+/**
+ * This file is auto-generated by vite-config/components.ts.
+ * Do not edit this file directly.
+ */
+
+import type RDFForm from '../components/rdf-form/RDFForm'
+import type RDFInput from '../components/rdf-input/RDFInput'
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'solid-ui-rdf-form': RDFForm
+ 'solid-ui-rdf-input': RDFInput
+ }
+}
diff --git a/vite-config/components.ts b/vite-config/components.ts
index 5681693ea..72b81a296 100644
--- a/vite-config/components.ts
+++ b/vite-config/components.ts
@@ -1,9 +1,11 @@
-import { existsSync, readdirSync } from 'node:fs'
+import { existsSync, readdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
const projectRoot = resolve(import.meta.dirname, '..')
export const componentsSrcDir = join(projectRoot, 'src/components')
+export const customElementsTypesPath = join(projectRoot, 'src/types/custom-elements.d.ts')
+const rdfComponentPrefix = 'rdf-'
export function discoverComponents(): string[] {
return readdirSync(componentsSrcDir, { withFileTypes: true })
@@ -15,3 +17,46 @@ export function discoverComponents(): string[] {
.map((entry) => entry.name)
.sort()
}
+
+export function discoverRdfComponents(): string[] {
+ return discoverComponents().filter((name) => name.startsWith(rdfComponentPrefix))
+}
+
+function getPascalCase(name: string): string {
+ return name
+ .split('-')
+ .map((segment) => {
+ if (segment === 'rdf') return 'RDF'
+ if (segment.length <= 2) return segment.toUpperCase()
+ return segment.charAt(0).toUpperCase() + segment.slice(1)
+ })
+ .join('')
+}
+
+export function generateCustomElementsTypes(): void {
+ const rdfComponents = discoverRdfComponents()
+
+ const lines = [
+ '/**',
+ ' * This file is auto-generated by vite-config/components.ts.',
+ ' * Do not edit this file directly.',
+ ' */',
+ '',
+ ]
+
+ for (const component of rdfComponents) {
+ const className = getPascalCase(component)
+ lines.push(`import type ${className} from '../components/${component}/${className}'`)
+ }
+
+ lines.push('', 'declare global {', ' interface HTMLElementTagNameMap {')
+
+ for (const component of rdfComponents) {
+ const className = getPascalCase(component)
+ lines.push(` 'solid-ui-${component}': ${className}`)
+ }
+
+ lines.push(' }', '}', '')
+
+ writeFileSync(customElementsTypesPath, lines.join('\n'), 'utf-8')
+}
diff --git a/vite.config.ts b/vite.config.ts
index 6b95b2afb..987b406b5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,9 +6,11 @@ import babel from './vite-config/babel'
import css from './vite-config/css'
import icons from './vite-config/icons'
import { cdnLegacyConfig, cdnConfig } from './vite-config/cdn'
-import { discoverComponents } from './vite-config/components'
+import { discoverComponents, generateCustomElementsTypes } from './vite-config/components'
import { stylesConfig } from './vite-config/styles'
+generateCustomElementsTypes()
+
const basePlugins = [
css(),
icons(),