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` +
+ ${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}
` + } + })} +
+ ` + } + + 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 {