diff --git a/.gitignore b/.gitignore
index 92168dbd6..f94e75f5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules
dist
src/versionInfo.ts
+src/types/custom-elements.d.ts
.idea
.vscode
coverage
diff --git a/package-lock.json b/package-lock.json
index 64d6f2639..e13bde3b3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1986,9 +1986,9 @@
}
},
"node_modules/@csstools/css-calc": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
- "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true,
"funding": [
{
@@ -2010,9 +2010,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
- "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"dev": true,
"funding": [
{
@@ -2027,7 +2027,7 @@
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.1.0",
- "@csstools/css-calc": "^3.2.1"
+ "@csstools/css-calc": "^3.3.0"
},
"engines": {
"node": ">=20.19.0"
@@ -2061,9 +2061,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
- "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true,
"funding": [
{
@@ -2637,9 +2637,9 @@
}
},
"node_modules/@eslint-community/eslint-utils": {
- "version": "4.9.1",
- "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
- "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4364,9 +4364,9 @@
}
},
"node_modules/@shoelace-style/localize": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.2.tgz",
- "integrity": "sha512-h3+2/cFWGaw3KQUwintkP4Cy3PtrVW//ysr9DM5nOfIXYekgrHwsELk/nyxc8hmjVP/Kcon7KCzvUtSwUBipfQ==",
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/@shoelace-style/localize/-/localize-3.2.3.tgz",
+ "integrity": "sha512-pWoZywizMilenLfQcpE9bjaqi7WKiQXJB42cxvmSPwYS76U776wJ7B5QQ0P28yVit1vpHuFZzq1CtfVFv32jaQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
@@ -6368,9 +6368,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.44",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz",
- "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==",
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
+ "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -6405,16 +6405,16 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
+ "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
- "node": "18 || 20 || >=22"
+ "node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -6431,9 +6431,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.6",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
- "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
"dev": true,
"funding": [
{
@@ -6451,9 +6451,9 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.10.42",
- "caniuse-lite": "^1.0.30001803",
- "electron-to-chromium": "^1.5.389",
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
"node-releases": "^2.0.51",
"update-browserslist-db": "^1.2.3"
},
@@ -7264,9 +7264,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.394",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.394.tgz",
- "integrity": "sha512-Wmt2Gm0o8JWBuGgmc4XZ0u9s1RaCRqhxP47phplmfg04+qypTUurpeJGP45A7Fhv7jdrrVH44PLlR9qXo37cVQ==",
+ "version": "1.5.395",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
+ "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==",
"dev": true,
"license": "ISC"
},
@@ -8494,9 +8494,9 @@
}
},
"node_modules/flatted": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
- "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz",
+ "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==",
"dev": true,
"license": "ISC"
},
@@ -10874,9 +10874,9 @@
"license": "CC0-1.0"
},
"node_modules/mdurl": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
- "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
+ "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
"dev": true,
"license": "MIT"
},
@@ -11574,13 +11574,14 @@
"license": "MIT"
},
"node_modules/own-keys": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
- "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
+ "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "get-intrinsic": "^1.2.6",
+ "call-bound": "^1.0.4",
+ "get-intrinsic": "^1.3.0",
"object-keys": "^1.1.1",
"safe-push-apply": "^1.0.0"
},
@@ -11741,9 +11742,9 @@
"integrity": "sha512-+yEXtNdlCs5N0Zy/9uvkifgf/RqnGu0WqP4j9Wu1Us4YReFe1YNBh2Krmf8B1xGxjpYnta63K55QP8bkafnOzA=="
},
"node_modules/package-manager-detector": {
- "version": "1.7.0",
- "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz",
- "integrity": "sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==",
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
+ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"dev": true,
"license": "MIT"
},
@@ -12056,9 +12057,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.21",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
- "integrity": "sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==",
+ "version": "8.5.22",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
+ "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"dev": true,
"funding": [
{
@@ -12317,9 +12318,9 @@
}
},
"node_modules/react": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
- "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -12327,16 +12328,16 @@
}
},
"node_modules/react-dom": {
- "version": "19.2.7",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
- "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
- "react": "^19.2.7"
+ "react": "^19.2.8"
}
},
"node_modules/react-is": {
diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts
index 21787ac60..e4ea5593c 100644
--- a/src/components/combobox/Combobox.ts
+++ b/src/components/combobox/Combobox.ts
@@ -56,6 +56,9 @@ export default class Combobox extends FormControlComponent {
@query('input')
protected accessor controlElement: HTMLInputElement | null = null
+ @property({ type: Boolean, reflect: true })
+ accessor readonly = false
+
@state()
private accessor filter = ''
diff --git a/src/components/input/Input.styles.css b/src/components/input/Input.styles.css
index cd29288a8..e593ce1b6 100644
--- a/src/components/input/Input.styles.css
+++ b/src/components/input/Input.styles.css
@@ -23,5 +23,12 @@
color: var(--solid-ui-color-gray-700);
font-size: inherit;
}
+
+ input:read-only {
+ border: none;
+ padding: 0;
+ cursor: not-allowed;
+ }
}
+
}
diff --git a/src/components/input/Input.ts b/src/components/input/Input.ts
index 9cfcf0b94..68aad1953 100644
--- a/src/components/input/Input.ts
+++ b/src/components/input/Input.ts
@@ -11,6 +11,15 @@ export default class Input extends FormControlComponent {
@property({ type: String, reflect: true })
accessor type = 'text';
+ @property({ type: String, reflect: true })
+ accessor placeholder = '';
+
+ @property({ type: Boolean, reflect: true })
+ accessor required = false;
+
+ @property({ type: Boolean, reflect: true })
+ accessor readonly = false;
+
@query('input')
protected accessor controlElement: HTMLInputElement | null = null;
@@ -26,6 +35,7 @@ export default class Input extends FormControlComponent {
placeholder=${this.placeholder}
?required=${this.required}
.value=${this.value}
+ ?readonly=${this.readonly}
@input=${() => this.controlTrait.onInput()}
@keydown=${this.onKeyDown}
/>
diff --git a/src/components/rdf-form/RDFForm.ts b/src/components/rdf-form/RDFForm.ts
new file mode 100644
index 000000000..0011a7e44
--- /dev/null
+++ b/src/components/rdf-form/RDFForm.ts
@@ -0,0 +1,171 @@
+import { property, state } from 'lit/decorators.js'
+import { html } from 'lit/html.js'
+import type { PropertyValues } from 'lit'
+import { consume } from '@lit/context'
+import { customElement, WebComponent } from '@/lib/components'
+import ns from '../../lib/ns'
+import { fetchData, findForm, sortBySequence } from '../../lib/forms/rdfFormsHelper'
+import { sym, LiveStore } from 'rdflib'
+import '@/components/rdf-input'
+import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext'
+
+const urlConverter = {
+ fromAttribute (value: string | null): URL | null {
+ if (!value) return null
+
+ try {
+ return new URL(value)
+ } catch {
+ return null
+ }
+ },
+ toAttribute (value: URL | null) {
+ if (!value) return null
+ return value
+ }
+}
+
+const hrefFromUrlValue = (value: URL | null): string =>
+ value?.href ?? ''
+
+@customElement('solid-ui-rdf-form')
+export default class RDFForm extends WebComponent {
+ @consume({ context: storeContext, subscribe: true })
+ private accessor storeContext: StoreContext = DEFAULT_STORE
+
+ @property({ attribute: false })
+ accessor passedInStore: LiveStore | null = null
+
+ private get currentStore (): LiveStore {
+ return this.passedInStore ?? this.storeContext.store
+ }
+
+ @state()
+ private accessor entireDataIsReadonly: boolean = true // to protect data, we default to not editable
+
+ @state()
+ private accessor _loadVersion = 0
+
+ @state()
+ private accessor _documentsLoaded = false
+
+ @property({ converter: urlConverter })
+ accessor formUrl: URL | null = null
+
+ @property({ converter: urlConverter })
+ accessor subjectUrl: URL | null = null
+
+ render () {
+ if (!this._documentsLoaded) {
+ return html``
+ }
+
+ const store = this.currentStore
+
+ const subjectUrl = hrefFromUrlValue(this.subjectUrl)
+ if (subjectUrl && store.updater?.editable(subjectUrl) !== undefined && store.updater?.editable(subjectUrl) !== false) {
+ this.entireDataIsReadonly = false
+ }
+
+ const formRoot = findForm(this.currentStore, hrefFromUrlValue(this.formUrl)) // If there are more 'a ui:Form' elements in a form file
+ if (!formRoot) throw new Error('No ui:Form found in ' + hrefFromUrlValue(this.formUrl))
+
+ const formDocument = sym(hrefFromUrlValue(this.formUrl)) // rdflib NamedNode for the document
+ const parts = store.each(formRoot, ns.ui('parts'), null, formDocument)
+ 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, formDocument)
+ 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
+ }
+ })
+
+ return html`
+
+ `
+ }
+
+ protected override willUpdate (changedProperties: PropertyValues) {
+ super.willUpdate(changedProperties)
+ if (changedProperties.has('formUrl') ||
+ changedProperties.has('subjectUrl') ||
+ changedProperties.has('passedInStore')
+ ) {
+ this.loadDocumentsIfNeeded()
+ }
+ }
+
+ private async loadDocumentsIfNeeded () {
+ const store = this.currentStore
+ const formUrl = hrefFromUrlValue(this.formUrl)
+ const subjectUrl = hrefFromUrlValue(this.subjectUrl)
+
+ if (!formUrl || !subjectUrl) return
+
+ try {
+ await fetchData(store, formUrl)
+ await fetchData(store, subjectUrl)
+ this._loadVersion += 1
+ this._documentsLoaded = true
+ } catch (error) {
+ console.error('Failed to load RDF documents', error)
+ }
+ }
+}
diff --git a/src/components/rdf-form/RDForm.stories.ts b/src/components/rdf-form/RDForm.stories.ts
new file mode 100644
index 000000000..c3924e425
--- /dev/null
+++ b/src/components/rdf-form/RDForm.stories.ts
@@ -0,0 +1,28 @@
+import { html } from 'lit'
+import './RDFForm'
+
+const meta = {
+ title: 'Design System/RDF Form',
+ args: {
+ formUrl: 'https://solidos.solidcommunity.net/public/2021/solidUiFormTestData/dummyFormTestFile.ttl', // we need a working URL
+ subjectUrl: 'https://solidos.solidcommunity.net/public/2021/alice.ttl#me'
+ },
+
+ argTypes: {
+ formUrl: { control: 'text' },
+ subjectUrl: { control: 'text' }
+ },
+} as const
+
+const render = ({ formUrl, subjectUrl }: typeof meta.args) => {
+ 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..b8e96f83c
--- /dev/null
+++ b/src/components/rdf-input/RDFInput.ts
@@ -0,0 +1,206 @@
+import { property } from 'lit/decorators.js'
+import { html } from 'lit/html.js'
+import ns from '../../lib/ns'
+import { customElement, generateId, WebComponent } from '@/lib/components'
+import { Literal, NamedNode, st } from 'rdflib'
+import { label } from '../../utils'
+import { mostSpecificClassURI } from '../../lib/forms/rdfFormsHelper'
+import { FieldParamsObject, fieldParams as fieldTypeParams, InputType } from '../../lib/forms/fieldParams'
+import { DEFAULT_STORE, storeContext, StoreContext } from '@/lib/forms/store/StoreContext'
+import { consume } from '@lit/context'
+import '@/components/input'
+
+@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
+
+ @consume({ context: storeContext, subscribe: true })
+ private accessor storeContext: StoreContext = DEFAULT_STORE
+
+ @property({ attribute: false, type: Object })
+ accessor formSubject: NamedNode | null = null
+
+ @property({ attribute: false, type: Object })
+ accessor dataSubject: NamedNode | null = null
+
+ @property({ type: Number })
+ accessor storeVersion = 0
+
+ private _updateInFlight = false
+ private _pendingUpdateValue: string | null = null
+
+ @property({ type: Boolean, reflect: true })
+ accessor readonly: boolean = true // to protect data, we default to not editable
+
+ constructor () {
+ super()
+
+ this.id = generateId()
+ }
+
+ render () {
+ const formDocument = this.getDocument(this.formSubject)
+
+ // for building the HTML input element
+ const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), formDocument)
+ const inputLabel = this.getInputLabel(this.formSubject, uiPropertyTerm, formDocument)
+ const readonly = this.getReadOnly(this.readonly, this.formSubject, formDocument)
+
+ const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.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 placeholder = readonly ? '' : this.defaultInputValue(params)
+ const inputValue = this.termToInputValue(selectedTerm)
+
+ return html`
+ `
+ }
+
+ private getDocument (subject: NamedNode | null | undefined) {
+ return subject?.doc ? subject.doc() : undefined
+ }
+
+ private getFormProperty (subject: NamedNode | null | undefined, property: NamedNode, graph?: any): NamedNode | undefined {
+ if (!subject) return undefined
+ return this.storeContext.store.any(subject, property, null, graph) as NamedNode | undefined
+ }
+
+ private getInputLabel (formFieldSubject: NamedNode | null | undefined, uiPropertyTerm?: NamedNode, graph?: any): string {
+ if (!formFieldSubject) return ''
+ const uiLabel = this.storeContext.store.any(formFieldSubject, ns.ui('label'), null, graph)
+ const propertyLabel = uiPropertyTerm ? label(uiPropertyTerm, true) : ''
+ return uiLabel ? uiLabel.value : propertyLabel
+ }
+
+ private getReadOnly (readonly: boolean, formFieldSubject?: NamedNode | null, graph?: any): boolean {
+ if (formFieldSubject && readonly === false) { // if readonly is false, we can ovverride it if the field is marked as uneditable in the form
+ return !!this.storeContext.store.anyJS(formFieldSubject, ns.ui('suppressEmptyUneditable'), null, graph)
+ }
+ return readonly
+ }
+
+ private getSelectedTerm (
+ dataSubject?: NamedNode | null,
+ uiPropertyTerm?: NamedNode,
+ formFieldSubject?: NamedNode | null,
+ params?: { defaultInputValue?: string }
+ ) {
+ const defaultTerm = formFieldSubject
+ ? this.storeContext.store.any(formFieldSubject, ns.ui('default'))
+ : undefined
+
+ if (!uiPropertyTerm || !dataSubject) {
+ return defaultTerm
+ }
+
+ const inputTerm = this.storeContext.store.any(dataSubject, uiPropertyTerm)
+ return inputTerm || defaultTerm
+ }
+
+ private termToInputValue (term: any) {
+ if (!term || !('value' in term) || !term.value) return ''
+
+ try {
+ return decodeURIComponent(term.value)
+ } catch {
+ return String(term.value)
+ }
+ }
+
+ private defaultInputValue (params: { defaultInputValue?: string } = {}) {
+ const stripped = params.defaultInputValue ?? ''
+ return stripped.replace(/ /g, '')
+ }
+
+ private async updateData (e: CustomEvent) {
+ const newValue = (e.target as HTMLInputElement).value
+ this._pendingUpdateValue = newValue
+
+ if (this._updateInFlight) {
+ return
+ }
+
+ await this.runPendingUpdate()
+ }
+
+ private async runPendingUpdate () {
+ if (this._pendingUpdateValue === null) {
+ return
+ }
+
+ const newValue = this._pendingUpdateValue
+ this._pendingUpdateValue = null
+ this._updateInFlight = true
+
+ const uiPropertyTerm = this.getFormProperty(this.formSubject, ns.ui('property'), this.getDocument(this.formSubject))
+ if (!uiPropertyTerm || !this.dataSubject) {
+ this._updateInFlight = false
+ return
+ }
+
+ const dataDocument = this.getDocument(this.dataSubject)
+ if (dataDocument && this.storeContext.store.updater?.editable(dataDocument) === false) {
+ this._updateInFlight = false
+ return
+ }
+
+ const toDeleteSt = this.storeContext.store.statementsMatching(this.dataSubject, uiPropertyTerm)
+ let toInsertSt: Array> = []
+
+ if (newValue) {
+ let objectFromNewValue
+ const fieldType = this.formSubject ? mostSpecificClassURI(this.storeContext.store, this.formSubject) : undefined
+ const params: FieldParamsObject = fieldType ? fieldTypeParams[fieldType] ?? {} : {}
+ if (params.namedNode) {
+ objectFromNewValue = this.storeContext.store.sym(newValue)
+ } else if (params.defaultInputValue) {
+ objectFromNewValue = encodeURIComponent(newValue.replace(/ /g, ''))
+ objectFromNewValue = this.storeContext.store.sym(params.defaultInputValue + objectFromNewValue)
+ } else {
+ if (params.dt) {
+ objectFromNewValue = new Literal(
+ newValue.trim(),
+ undefined,
+ ns.xsd(params.dt)
+ )
+ } else {
+ objectFromNewValue = new Literal(newValue)
+ }
+ }
+ toInsertSt = toDeleteSt.map((statement) => st(statement.subject, statement.predicate, objectFromNewValue, statement.why))
+ if (toInsertSt.length === 0) {
+ toInsertSt = [st(this.dataSubject, uiPropertyTerm, objectFromNewValue, this.getDocument(this.dataSubject))]
+ }
+ }
+
+ try {
+ await this.storeContext.store.updater.updateMany(toDeleteSt, toInsertSt as any)
+ this.storeVersion += 1
+ } catch (err) {
+ console.error('RDFInput update failed', err)
+ } finally {
+ this._updateInFlight = false
+ }
+
+ if (this._pendingUpdateValue !== null) {
+ await this.runPendingUpdate()
+ }
+ }
+}
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..5d8aad7cc
--- /dev/null
+++ b/src/lib/forms/rdfFormsHelper.ts
@@ -0,0 +1,123 @@
+import { sym, LiveStore, parse } from 'rdflib'
+import type { Term } from 'rdflib/lib/tf-types'
+// eslint-disable-next-line camelcase
+import type { Quad_Subject, NamedNode } from 'rdflib/lib/tf-types'
+import ns from '../../lib/ns'
+
+const baseUri = 'https://solidos.github.io/solid-ui/src/ontology/'
+
+export function loadDocument (
+ store: LiveStore,
+ documentSource: string,
+ documentName: string,
+ documentURI?: string,
+ preferRemote = false
+) {
+ 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))
+ }
+
+ const parseSource = () => {
+ return new Promise((resolve, reject) => {
+ parse(documentSource, store, finalDocumentUri, 'text/turtle', (err) => {
+ if (err) {
+ console.error('Parse document error for ', finalDocumentUri, err)
+ reject(err)
+ } else {
+ resolve()
+ }
+ })
+ })
+ }
+
+ if (preferRemote && documentURI) {
+ return store.fetcher.load(documentURI, {
+ force: true,
+ clearPreviousData: true,
+ }).then(() => {}).catch((err) => {
+ if (documentSource && documentSource.trim().length > 0) {
+ return parseSource()
+ }
+ throw err
+ })
+ }
+
+ if (documentSource && documentSource.trim().length > 0) {
+ return parseSource()
+ }
+
+ if (documentURI) {
+ return store.fetcher.load(documentURI, {
+ force: true,
+ clearPreviousData: true,
+ }).then(() => {})
+ }
+
+ return Promise.reject(new Error(`No document source or URI for ${documentName}`))
+}
+
+export async function fetchData (
+ store: LiveStore,
+ documentURI: string
+) {
+ const document = sym(documentURI) // rdflib NamedNode for the document
+
+ if (store.holds(undefined, undefined, undefined, document)) {
+ store.removeStatements(store.statementsMatching(undefined, undefined, undefined, document))
+ }
+
+ return await store.fetcher.load(documentURI, {
+ force: true,
+ clearPreviousData: true,
+ })
+}
+
+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]
+}
+
+// Find the first ui:Form node in a store, optionally matching a fragment.
+// code based on Jeff Zucker's sol-components: https://github.com/jeff-zucker/sol-components (core/form-utils.js)
+export function findForm (store: LiveStore, sourceUri: string): NamedNode | null {
+ const docUrl = sourceUri.split('#')[0]
+ const fragment = sourceUri.includes('#') ? sourceUri.split('#')[1] : null
+ if (fragment) {
+ const candidate = sym(docUrl + '#' + fragment)
+ if (store.holds(candidate, ns.rdf('type'), ns.ui('Form'))) return candidate
+ }
+ const forms = store.each(null, ns.rdf('type'), ns.ui('Form'))
+ const found = forms.find((term) => term.termType === 'NamedNode')
+ return found ? (found as NamedNode) : null
+}
diff --git a/src/lib/forms/store/NoopStore.ts b/src/lib/forms/store/NoopStore.ts
new file mode 100644
index 000000000..fac3b041d
--- /dev/null
+++ b/src/lib/forms/store/NoopStore.ts
@@ -0,0 +1,8 @@
+import { LiveStore } from 'rdflib'
+import { StoreContext } from './StoreContext'
+
+export default class NoopStore implements StoreContext {
+ get store (): LiveStore {
+ throw new Error('Cannot use RDF forms without a store')
+ }
+}
diff --git a/src/lib/forms/store/StoreContext.ts b/src/lib/forms/store/StoreContext.ts
new file mode 100644
index 000000000..8cd36d6be
--- /dev/null
+++ b/src/lib/forms/store/StoreContext.ts
@@ -0,0 +1,10 @@
+import { createContext } from '@lit/context'
+import { LiveStore } from 'rdflib'
+import NoopStore from './NoopStore'
+
+export interface StoreContext {
+ store: LiveStore
+}
+
+export const DEFAULT_STORE = new NoopStore()
+export const storeContext = createContext(Symbol('storeContext'))
diff --git a/src/storybook/components/StorybookProvider.ts b/src/storybook/components/StorybookProvider.ts
index 0980d9e6e..22f35835c 100644
--- a/src/storybook/components/StorybookProvider.ts
+++ b/src/storybook/components/StorybookProvider.ts
@@ -6,6 +6,8 @@ import StorybookAuth from '../auth/StorybookAuth'
import { Account, authContext } from '@/lib/auth'
import '@/components/dialogs-root'
+import { storeContext, StoreContext } from '@/lib/forms/store/StoreContext'
+import StorybookStore from '../store/StorybookStore'
@customElement('storybook-provider')
export class StorybookProvider extends WebComponent {
@@ -21,6 +23,9 @@ export class StorybookProvider extends WebComponent {
@provide({ context: authContext })
private accessor auth = new StorybookAuth()
+ @provide({ context: storeContext })
+ private accessor store: StoreContext = new StorybookStore()
+
willUpdate (changedProperties: PropertyValues) {
super.willUpdate(changedProperties)
@@ -33,6 +38,10 @@ export class StorybookProvider extends WebComponent {
}
this.auth.account = new Account(this.webId, this.avatarUrl)
+
+ if (this.store) {
+ // read `store` so the property is considered used
+ }
}
protected render () {
diff --git a/src/storybook/store/StorybookStore.ts b/src/storybook/store/StorybookStore.ts
new file mode 100644
index 000000000..0a71df8af
--- /dev/null
+++ b/src/storybook/store/StorybookStore.ts
@@ -0,0 +1,15 @@
+import { StoreContext } from '@/lib/forms/store/StoreContext'
+import * as rdf from 'rdflib'
+import { LiveStore } from 'rdflib'
+
+export default class StorybookStore implements StoreContext {
+ public store: LiveStore = createStore()
+}
+
+function createStore (): rdf.LiveStore {
+ const store = rdf.graph() as LiveStore
+ store.updater = new rdf.UpdateManager(store) // Add real-time live updates store.updater
+ store.fetcher = new rdf.Fetcher(store) // Add fetcher for loading RDF data
+ store.features = [] // disable automatic node merging on store load
+ return store
+}
diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts
index 7366bab22..62587eafe 100644
--- a/src/types/custom-elements.d.ts
+++ b/src/types/custom-elements.d.ts
@@ -1,8 +1,3 @@
-/**
- * This file is auto-generated by vite-config/components.ts.
- * Do not edit this file directly.
- */
-
import type Account from '../components/account/Account'
import type Avatar from '../components/avatar/Avatar'
import type Button from '../components/button/Button'
@@ -15,7 +10,6 @@ import type DialogHeader from '../components/dialog-header/DialogHeader'
import type DialogProvider from '../components/dialog-provider/DialogProvider'
import type DialogsRoot from '../components/dialogs-root/DialogsRoot'
import type Guard from '../components/guard/Guard'
-import type Header from '../components/header/Header'
import type Input from '../components/input/Input'
import type LoginButton from '../components/login-button/LoginButton'
import type LoginModal from '../components/login-modal/LoginModal'
@@ -23,10 +17,14 @@ import type LogoutButton from '../components/logout-button/LogoutButton'
import type Menu from '../components/menu/Menu'
import type MenuItem from '../components/menu-item/MenuItem'
import type PhotoCapture from '../components/photo-capture/PhotoCapture'
+import type PhotoCaptureModal from '../components/photo-capture-modal/PhotoCaptureModal'
import type Provider from '../components/provider/Provider'
+import type RDFForm from '../components/rdf-form/RDFForm'
+import type RDFInput from '../components/rdf-input/RDFInput'
import type Select from '../components/select/Select'
import type SelectOption from '../components/select-option/SelectOption'
import type SignupButton from '../components/signup-button/SignupButton'
+import type SolidEmblem from '../components/solid-emblem/SolidEmblem'
declare global {
interface HTMLElementTagNameMap {
@@ -42,7 +40,6 @@ declare global {
'solid-ui-dialog-provider': DialogProvider
'solid-ui-dialogs-root': DialogsRoot
'solid-ui-guard': Guard
- 'solid-ui-header': Header
'solid-ui-input': Input
'solid-ui-login-button': LoginButton
'solid-ui-login-modal': LoginModal
@@ -50,9 +47,13 @@ declare global {
'solid-ui-menu': Menu
'solid-ui-menu-item': MenuItem
'solid-ui-photo-capture': PhotoCapture
+ 'solid-ui-photo-capture-modal': PhotoCaptureModal
'solid-ui-provider': Provider
+ 'solid-ui-rdf-form': RDFForm
+ 'solid-ui-rdf-input': RDFInput
'solid-ui-select': Select
'solid-ui-select-option': SelectOption
'solid-ui-signup-button': SignupButton
+ 'solid-ui-solid-emblem': SolidEmblem
}
}
diff --git a/vite-config/components.ts b/vite-config/components.ts
index fe98bfa6a..786623004 100644
--- a/vite-config/components.ts
+++ b/vite-config/components.ts
@@ -1,5 +1,6 @@
-import { existsSync, readdirSync } from 'node:fs'
+import { existsSync, readdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
+import type { Plugin } from 'vite'
const projectRoot = resolve(import.meta.dirname, '..')
@@ -10,6 +11,7 @@ export const litDecoratorPaths = [
]
export const componentsSrcDir = join(projectRoot, 'src/components')
+export const customElementsTypesPath = join(projectRoot, 'src/types/custom-elements.d.ts')
export function discoverComponents(): string[] {
return readdirSync(componentsSrcDir, { withFileTypes: true })
@@ -21,3 +23,50 @@ export function discoverComponents(): string[] {
.map((entry) => entry.name)
.sort()
}
+
+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 components = discoverComponents()
+
+ const lines = []
+
+ for (const component of components) {
+ const className = getPascalCase(component)
+ lines.push(`import type ${className} from '../components/${component}/${className}'`)
+ }
+
+ lines.push('', 'declare global {', ' interface HTMLElementTagNameMap {')
+
+ for (const component of components) {
+ const className = getPascalCase(component)
+ lines.push(` 'solid-ui-${component}': ${className}`)
+ }
+
+ lines.push(' }', '}', '')
+
+ writeFileSync(customElementsTypesPath, lines.join('\n'), 'utf-8')
+}
+
+export function customElementsTypesPlugin(): Plugin {
+ return {
+ name: 'solid-ui-custom-elements-types',
+ buildStart() {
+ generateCustomElementsTypes()
+ },
+ handleHotUpdate(context) {
+ if (context.file.startsWith(componentsSrcDir)) {
+ generateCustomElementsTypes()
+ }
+ },
+ }
+}
diff --git a/vite.config.mts b/vite.config.mts
index 553392981..aad46c504 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -8,11 +8,12 @@ import css from './vite-config/css'
import resolveConfig from './vite-config/resolve'
import stylesConfig from './vite-config/styles'
import { cdnLegacyConfig, cdnConfig } from './vite-config/cdn'
-import { discoverComponents, litDecoratorPaths } from './vite-config/components'
+import { discoverComponents, litDecoratorPaths, customElementsTypesPlugin } from './vite-config/components'
const basePlugins = [
css(),
icons(),
+ customElementsTypesPlugin(),
]
function defaultConfig(): UserConfig {