diff --git a/src/components/resource-action-dialog/ResourceActionDialog.ts b/src/components/resource-action-dialog/ResourceActionDialog.ts index 58d92ce0..d977fefc 100644 --- a/src/components/resource-action-dialog/ResourceActionDialog.ts +++ b/src/components/resource-action-dialog/ResourceActionDialog.ts @@ -97,7 +97,7 @@ export default class ResourceActionDialog extends WebComponent { }) if (event.shiftKey && focusedIndex === 0) { event.preventDefault() - buttons.at(-1)?.focus() + buttons[buttons.length - 1]?.focus() } else if (!event.shiftKey && focusedIndex === buttons.length - 1) { event.preventDefault() buttons[0]?.focus() diff --git a/src/dataContentPane.css b/src/dataContentPane.css index 517fe45f..58d977cd 100644 --- a/src/dataContentPane.css +++ b/src/dataContentPane.css @@ -2,114 +2,121 @@ white-space: pre-wrap; } -div.data-content-pane { +.data-content-pane { + --color-background-row-alternate: var(--solid-ui-color-gray-150, #f0f0f0); + --color-background-row-default: var(--solid-ui-color-white, #ffffff); border: 1px solid var(--solid-ui-color-gray-200, #cbd5e1); - padding: var(--spacing-base, 0.5rem) 40px; - margin-bottom: var(--spacing-base, 0.5rem); + padding: 8px 40px; + margin-bottom: 8px; box-sizing: border-box; } -div.data-content-pane table.data-content-pane__table { +.data-content-pane .data-content-pane__table { width: 100%; border-collapse: separate; border-spacing: 0; } -div.data-content-pane table.data-content-pane__table--root { +.data-content-pane .data-content-pane__table--root { table-layout: auto; } -div.data-content-pane .data-content-pane__row--root { +.data-content-pane .data-content-pane__row--root { display: flex; align-items: flex-start; } -div.data-content-pane .data-content-pane__row--root > td { - vertical-align: top; -} - -div.data-content-pane .data-content-pane__subject-cell { +.data-content-pane .data-content-pane__subject-cell { flex: 0 0 auto; - width: clamp(7rem, 12%, 10rem); - padding-right: 0.8rem; + width: clamp(160px, 18%, 240px); + padding-right: 12.8px; } -div.data-content-pane .data-content-pane__details-cell { +.data-content-pane .data-content-pane__details-cell { flex: 0 1 auto; min-width: 0; } -div.data-content-pane .data-content-pane__subject-cell, -div.data-content-pane td.data-content-pane__predicate-cell, -div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell), -div.data-content-pane .data-content-pane__subject-cell a, -div.data-content-pane td.data-content-pane__predicate-cell a, -div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) a { +.data-content-pane .data-content-pane__subject-cell, +.data-content-pane .data-content-pane__predicate-cell, +.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell), +.data-content-pane .data-content-pane__subject-cell a, +.data-content-pane .data-content-pane__predicate-cell a, +.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) a { white-space: normal; overflow-wrap: anywhere; word-break: break-word; } -div.data-content-pane table.data-content-pane__table--property { +.data-content-pane .data-content-pane__subject-cell, +.data-content-pane .data-content-pane__subject-cell a { + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; +} + +.data-content-pane .data-content-pane__table--property { table-layout: auto; width: auto; max-width: 100%; } +.data-content-pane .data-content-pane__details-cell:has(.data-content-pane__nested-formula) { + flex: 1 1 0; +} + +.data-content-pane .data-content-pane__table--property:has(.data-content-pane__nested-formula) { + width: 100%; + table-layout: fixed; +} + .data-content-pane__nested-formula { - border-top: solid 1px var(--color-data-pane-border-top, black); - border-left: solid 1px var(--color-data-pane-border-top, black); - border-bottom: solid 1px var(--color-data-pane-border-side, #777); - border-right: solid 1px var(--color-data-pane-border-side, #777); - padding: var(--spacing-base, 0.5rem); - border-radius: var(--border-radius-md, 0.5rem); -} - -div.data-content-pane td { - padding-left: var(--spacing-xxxs, 0.2rem); - padding-top: var(--spacing-small, 0.1rem); - padding-right: var(--spacing-xxxs, 0.2rem); - padding-bottom: 0.05rem; - vertical-align: top; + display: block; + container-type: inline-size; + width: 100%; + max-width: 100%; + box-sizing: border-box; + border: 1px solid var(--solid-ui-color-gray-200, #cbd5e1); + padding: 8px; + border-radius: 8px; } -div.data-content-pane table.data-content-pane__table--property td { - padding-top: 0.3rem; - padding-bottom: 0.3rem; +.data-content-pane td, +.data-content-pane th { + padding: 1.6px 3.2px 0.8px; + vertical-align: top; } -div.data-content-pane tr { - margin-bottom: var(--spacing-2xs, 0.625rem); - padding-top: var(--spacing-small, 0.1rem); - padding-bottom: var(--spacing-small, 0.1rem); +.data-content-pane .data-content-pane__table--property td, +.data-content-pane .data-content-pane__table--property th { + padding-top: 4.8px; + padding-bottom: 4.8px; } -.data-content-pane a { - color: var(--color-text-link, #3b5998); - text-decoration: none; - font-weight: bold; +.data-content-pane tr { + margin-bottom: 10px; + padding-top: 1.6px; + padding-bottom: 1.6px; } +.data-content-pane a, .data-content-pane a:link { - color: var(--color-text-link, #3b5998); + color: var(--solid-ui-color-tertiary, #083575); text-decoration: none; - font-weight: bold; + font-weight: 600; } .data-content-pane a:visited { - color: var(--color-text-link-visited, #3b5998); - text-decoration: none; - font-weight: bold; + color: #3b5998; } .data-content-pane a:hover { - color: var(--color-text-link-hover, #3b5998); + color: var(--solid-ui-color-tertiary, #083575); text-decoration: underline; - font-weight: bold; } .data-content-pane a:active { - color: var(--color-text-link-active, #888); + color: var(--solid-ui-color-gray-500, #6a7282); text-decoration: none; } @@ -117,162 +124,205 @@ div.data-content-pane tr { white-space: pre-wrap; } -div.data-content-pane td.data-content-pane__predicate-cell { - min-width: 8.5rem; - width: clamp(8.5rem, 18vw, 12rem); - max-width: 12rem; - padding-right: var(--spacing-base, 0.5rem); +.data-content-pane .data-content-pane__predicate-cell { + min-width: 136px; + width: clamp(136px, 18vw, 192px); + max-width: 192px; + padding-right: 8px; + box-sizing: border-box; + background-color: inherit; } -div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) { - padding-left: var(--spacing-xxs, 0.3125rem); +.data-content-pane .data-content-pane__subject-cell { + background-color: inherit; } -div.data-content-pane td.data-content-pane__predicate-cell a { - color: var(--color-text-muted, #444); +.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) { + padding-left: 5px; } -.data-content-pane__row--even { +.data-content-pane .data-content-pane__predicate-cell a { + color: var(--solid-ui-color-gray-600, #4a5565); +} + +.data-content-pane__row--even, +.data-content-pane__nested-table--dark { background-color: var(--color-background-row-alternate, #f0f0f0); } -.data-content-pane__row--odd { +.data-content-pane__row--odd, +.data-content-pane__nested-table--light { + background-color: var(--color-background-row-default, white); +} + +.data-content-pane .data-content-pane__table--property > tbody > tr:nth-child(odd), +.data-content-pane .data-content-pane__table--property > tr:nth-child(odd) { background-color: var(--color-background-row-default, white); } +.data-content-pane .data-content-pane__table--property > tbody > tr:nth-child(even), +.data-content-pane .data-content-pane__table--property > tr:nth-child(even) { + background-color: var(--color-background-row-alternate, #f0f0f0); +} + .data-content-pane__row--top-aligned { vertical-align: top; - margin-top: 0; - margin-bottom: 0; - padding-top: 0; - padding-bottom: 0; + margin: 0; + padding: 0; } -.data-content-pane__nested-table--light { - background-color: var(--color-background-row-default, white); -} +@container (max-width: 560px) { + .data-content-pane .data-content-pane__nested-formula .data-content-pane__row--root, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__table--property, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__table--property > tbody, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__row--property { + display: block; + width: 100%; + box-sizing: border-box; + } -.data-content-pane__nested-table--dark { - background-color: var(--color-background-row-alternate, #f0f0f0); + .data-content-pane .data-content-pane__nested-formula .data-content-pane__subject-cell, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__subject-cell a, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__details-cell, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__predicate-cell, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__value-cell { + display: block; + width: 100%; + min-width: 0; + max-width: none; + box-sizing: border-box; + white-space: normal; + overflow-wrap: anywhere; + } } -div.data-content-pane[data-layout='mobile'] .data-content-pane__row--root { - display: flex; - align-items: flex-start; - padding-bottom: var(--spacing-xs, 0.75rem); +.data-content-pane[data-layout='mobile'] .data-content-pane__row--root { + display: block; + padding-bottom: 12px; } -div.data-content-pane[data-layout='mobile'] { +.data-content-pane[data-layout='mobile'] { padding-left: 0; padding-right: 0; } -div.data-content-pane[data-layout='mobile'] .data-content-pane__subject-cell { - flex: 0 0 40%; - width: 40%; - max-width: 40%; +.data-content-pane[data-layout='mobile'] .data-content-pane__subject-cell { + display: block; + width: 100%; + max-width: 100%; box-sizing: border-box; - padding-right: var(--spacing-xs, 0.75rem); + padding-right: 0; padding-bottom: 0; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } -div.data-content-pane[data-layout='mobile'] .data-content-pane__details-cell { - flex: 0 0 60%; - width: 60%; - max-width: 60%; +.data-content-pane[data-layout='mobile'] .data-content-pane__details-cell { + display: block; + width: 100%; + max-width: 100%; min-width: 0; box-sizing: border-box; } -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property > tbody > tr, -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property > tr { +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property > tbody > tr, +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property > tr { margin-bottom: 0; } -div.data-content-pane[data-layout='mobile'] .data-content-pane__row--top-aligned + .data-content-pane__row--top-aligned { - margin-top: 0; -} - -div.data-content-pane[data-layout='mobile'] .data-content-pane__row--top-aligned { +.data-content-pane[data-layout='mobile'] .data-content-pane__row--top-aligned { display: block; margin-bottom: 0; padding-top: 0; padding-bottom: 0; } -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property, -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property > tbody { +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property, +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property > tbody { display: block; width: 100%; } -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property td { +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property td { display: block; width: 100%; box-sizing: border-box; } -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property td.data-content-pane__predicate-cell { +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property .data-content-pane__predicate-cell { + display: block; width: 100%; min-width: 0; - padding-bottom: 0.1rem; + padding-bottom: 1.6px; } -div.data-content-pane[data-layout='mobile'] table.data-content-pane__table--property td:not(.data-content-pane__predicate-cell) { +.data-content-pane[data-layout='mobile'] .data-content-pane__table--property td:not(.data-content-pane__predicate-cell) { padding-top: 0; - padding-left: 0.85rem; + padding-left: 13.6px; } -@media (max-width: 700px) { - div.data-content-pane { +@media (max-width: 768px) { + .data-content-pane .data-content-pane__nested-formula .data-content-pane__table--property, + .data-content-pane .data-content-pane__nested-formula .data-content-pane__table--property > tbody { + display: block; + width: 100%; + } + + .data-content-pane { + font-size: 14px; padding-left: 0; padding-right: 0; } - div.data-content-pane .data-content-pane__row--root { - display: flex; - align-items: flex-start; + .data-content-pane .data-content-pane__row--root { + display: block; } - div.data-content-pane .data-content-pane__subject-cell { - flex: 0 0 40%; - width: 40%; - max-width: 40%; - padding-right: var(--spacing-xs, 0.75rem); + .data-content-pane .data-content-pane__subject-cell { + display: block; + width: 100%; + max-width: 100%; + padding-right: 0; padding-bottom: 0; box-sizing: border-box; } - div.data-content-pane .data-content-pane__details-cell { - flex: 0 0 60%; - width: 60%; - max-width: 60%; + .data-content-pane .data-content-pane__subject-cell a { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + } + + .data-content-pane .data-content-pane__details-cell { + display: block; + width: 100%; + max-width: 100%; box-sizing: border-box; } - div.data-content-pane .data-content-pane__row--top-aligned { + .data-content-pane .data-content-pane__row--top-aligned { display: block; - margin-top: 0; - margin-bottom: 0; - padding-top: 0; - padding-bottom: 0; + margin: 0; + padding: 0; } - div.data-content-pane .data-content-pane__row--top-aligned > td { + .data-content-pane .data-content-pane__row--top-aligned > td, + .data-content-pane .data-content-pane__row--top-aligned > th { display: block; width: 100%; box-sizing: border-box; } - div.data-content-pane .data-content-pane__row--top-aligned > td.data-content-pane__predicate-cell { + .data-content-pane .data-content-pane__row--top-aligned > .data-content-pane__predicate-cell { min-width: 0; max-width: none; width: 100%; - padding-bottom: var(--spacing-small, 0.1rem); + padding-bottom: 1.6px; } - div.data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) { + .data-content-pane .data-content-pane__row--top-aligned > td:not(.data-content-pane__predicate-cell) { padding-top: 0; - padding-left: 0.85rem; + padding-left: 13.6px; } } diff --git a/src/dataContentPane.ts b/src/dataContentPane.ts index 085df2f2..8c552449 100644 --- a/src/dataContentPane.ts +++ b/src/dataContentPane.ts @@ -9,8 +9,8 @@ // - Expand automatically all the way down // - original source view? Use ffox view source -import * as UI from 'solid-ui' -import * as $rdf from 'rdflib' +import { icons, log, ns, utils, widgets } from 'solid-ui' +import { Serializer } from 'rdflib' import DOMPurify from 'dompurify' import type { DataBrowserContext, RenderEnvironment } from 'pane-registry' import type { @@ -21,8 +21,6 @@ import type { } from 'rdflib' import './dataContentPane.css' -const ns = UI.ns - type SubjectTerm = NamedNode | BlankNode type ObjectTerm = Statement['object'] | Formula @@ -40,8 +38,24 @@ type DataContentPaneLike = { ) => HTMLTableElement } +let renderInstanceId = 0 + +function openDataContentLink (event: Event): void { + const mouseEvent = event as MouseEvent + if (mouseEvent.metaKey || mouseEvent.ctrlKey || mouseEvent.shiftKey || mouseEvent.altKey) return + const documentWithOutline = typeof document !== 'undefined' + ? document as typeof document & { outlineManager?: unknown } + : undefined + const hasOutlineManager = !!documentWithOutline?.outlineManager + const hasPaneOutliner = typeof window !== 'undefined' && + typeof (window as Window & { panes?: { getOutliner?: () => unknown } }).panes?.getOutliner === 'function' + if (hasOutlineManager || hasPaneOutliner) { + widgets.openHrefInOutlineMode(event) + } +} + export const dataContentPane = { - icon: UI.icons.originalIconBase + 'rdf_flyer.24.gif', + icon: icons.originalIconBase + 'rdf_flyer.24.gif', name: 'dataContents', @@ -63,31 +77,32 @@ export const dataContentPane = { if (n === 0) return null return 'Data (' + n + ')' }, - /* - shouldGetFocus: function(subject) { - return store.whether(subject, UI.ns.rdf('type'), UI.ns.link('RDFDocument')) - }, -*/ statementsAsTables: function statementsAsTables ( sts: Statement[], context: DataBrowserContext, initialRoots?: SubjectTerm[] ): HTMLTableElement { const myDocument = context.dom - // const outliner = context.getOutliner(myDocument) const rep = myDocument.createElement('table') rep.classList.add('data-content-pane__table', 'data-content-pane__table--root') const isMobileLayout = context.environment?.layout === 'mobile' - const sz = $rdf.Serializer(context.session.store) + const sz = Serializer(context.session.store) const res = sz.rootSubjects(sts) as RootSubjectsResult let roots = res.roots const subjects = res.subjects const loopBreakers = res.loopBreakers ?? {} for (const x in loopBreakers) { - console.log('\tdataContentPane: loopbreaker:' + x) + log.debug('\tdataContentPane: loopbreaker:' + x) } const doneBnodes: Record = {} const referencedBnodes: Record = {} + const renderId = ++renderInstanceId + let cellId = 0 + + function nextCellId (prefix: string): string { + cellId++ + return `data-content-${renderId}-${prefix}-${cellId}` + } function propertyTree ( subject: SubjectTerm, @@ -95,6 +110,7 @@ export const dataContentPane = { ): HTMLTableElement { const rep = myDocument.createElement('table') rep.classList.add('data-content-pane__table', 'data-content-pane__table--property') + rep.setAttribute('aria-label', 'Properties') let lastPred: string | null = null const subjectStatements = subjects[sz.toStr(subject)] if (!subjectStatements) { @@ -112,18 +128,16 @@ export const dataContentPane = { if (!isMobileLayout && lastPred && same > 1) { predicateTD?.setAttribute('rowspan', '' + same) } - predicateTD = myDocument.createElement('td') + predicateTD = myDocument.createElement('th') predicateTD.setAttribute('class', 'data-content-pane__predicate-cell') + predicateTD.setAttribute('scope', 'row') + predicateTD.id = nextCellId('predicate') const anchor = myDocument.createElement('a') anchor.setAttribute('href', st.predicate.uri) - anchor.addEventListener( - 'click', - UI.widgets.openHrefInOutlineMode, - true - ) + anchor.addEventListener('click', openDataContentLink, true) anchor.appendChild( myDocument.createTextNode( - UI.utils.predicateLabelForXML(st.predicate) + utils.predicateLabelForXML(st.predicate) ) ) predicateTD.appendChild(anchor) @@ -134,6 +148,7 @@ export const dataContentPane = { same++ const objectTD = myDocument.createElement('td') objectTD.classList.add('data-content-pane__value-cell') + objectTD.setAttribute('headers', predicateTD?.id ?? '') objectTD.appendChild(objectTree(st.object, nestingLevel + 1)) tr.appendChild(objectTD) rep.appendChild(tr) @@ -151,19 +166,16 @@ export const dataContentPane = { case 'NamedNode': anchor = myDocument.createElement('a') anchor.setAttribute('href', obj.uri) - anchor.addEventListener( - 'click', - UI.widgets.openHrefInOutlineMode, - true - ) - anchor.appendChild(myDocument.createTextNode(UI.utils.label(obj))) + anchor.addEventListener('click', openDataContentLink, true) + anchor.appendChild(myDocument.createTextNode(utils.label(obj))) return anchor case 'Literal': - if (!obj.datatype || !obj.datatype.uri) { + if (obj.language || !obj.datatype || !obj.datatype.uri) { res = myDocument.createElement('div') res.classList.add('data-content-pane__literal') res.textContent = obj.value + if (obj.language) res.setAttribute('lang', obj.language) return res } else if ( obj.datatype.uri === @@ -171,6 +183,8 @@ export const dataContentPane = { ) { res = myDocument.createElement('div') res.classList.add('embeddedXHTML') + res.setAttribute('role', 'group') + res.setAttribute('aria-label', 'Embedded HTML content') res.innerHTML = DOMPurify.sanitize(obj.value) return res } @@ -182,6 +196,10 @@ export const dataContentPane = { const referenceAnchor = myDocument.createElement('a') referenceAnchor.setAttribute('href', '#' + obj.toNT().slice(2)) referenceAnchor.setAttribute('class', 'bnodeRef') + referenceAnchor.setAttribute( + 'aria-label', + `Referenced blank node ${obj.toNT().slice(3)}` + ) referenceAnchor.textContent = '*' + obj.toNT().slice(3) return referenceAnchor } @@ -197,12 +215,12 @@ export const dataContentPane = { } case 'Collection': - res = myDocument.createElement('table') + res = myDocument.createElement('ol') res.setAttribute('class', 'collectionAsTables') for (let i = 0; i < obj.elements.length; i++) { - const tr = myDocument.createElement('tr') - res.appendChild(tr) - tr.appendChild(objectTree(obj.elements[i] as ObjectTerm, nestingLevel + 1)) + const item = myDocument.createElement('li') + item.appendChild(objectTree(obj.elements[i] as ObjectTerm, nestingLevel + 1)) + res.appendChild(item) } return res @@ -210,7 +228,7 @@ export const dataContentPane = { res = (context.session.paneRegistry .byName('dataContents') as DataContentPaneLike) .statementsAsTables(obj.statements, context) - res.setAttribute('class', 'data-content-pane__nested-formula') + res.classList.add('data-content-pane__nested-formula') return res case 'Variable': @@ -236,15 +254,18 @@ export const dataContentPane = { 'data-content-pane__row--root' ) rep.appendChild(tr) - const subjectTD = myDocument.createElement('td') + const subjectTD = myDocument.createElement('th') subjectTD.classList.add('data-content-pane__subject-cell') + subjectTD.setAttribute('scope', 'row') + subjectTD.id = nextCellId('subject') tr.appendChild(subjectTD) const TDTree = myDocument.createElement('td') TDTree.classList.add('data-content-pane__details-cell') + TDTree.setAttribute('headers', subjectTD.id) tr.appendChild(TDTree) const root = roots[i] if (root.termType === 'BlankNode') { - subjectTD.appendChild(myDocument.createTextNode(UI.utils.label(root))) + subjectTD.appendChild(myDocument.createTextNode(utils.label(root))) } else { subjectTD.appendChild(objectTree(root, 0)) } @@ -253,11 +274,15 @@ export const dataContentPane = { for (const bNT in referencedBnodes) { const table = doneBnodes[bNT] if (table === true) continue + const caption = myDocument.createElement('caption') const anchor = myDocument.createElement('a') anchor.setAttribute('id', bNT.slice(2)) + anchor.setAttribute('tabindex', '-1') anchor.setAttribute('class', 'bnodeDef') + anchor.setAttribute('aria-label', `Blank node ${bNT.slice(3)}`) anchor.textContent = bNT.slice(3) + ')' - table.insertBefore(anchor, table.firstChild) + caption.appendChild(anchor) + table.insertBefore(caption, table.firstChild) } return rep }, @@ -282,16 +307,16 @@ export const dataContentPane = { if (kb.holds(subject, undefined, undefined, subject)) { initialRoots.push(subject) } - const ps = kb.any(subject, UI.ns.foaf('primaryTopic'), undefined, subject) + const ps = kb.any(subject, ns.foaf('primaryTopic'), undefined, subject) if (ps && (ps.termType === 'NamedNode' || ps.termType === 'BlankNode')) { initialRoots.push(ps as SubjectTerm) } - div.appendChild( - context.session.paneRegistry - .byName('dataContents') - .statementsAsTables(sts, context, initialRoots) - ) + const table = context.session.paneRegistry + .byName('dataContents') + .statementsAsTables(sts, context, initialRoots) + table.setAttribute('aria-label', `Data content for ${utils.label(subject)}`) + div.appendChild(table) } const div = myDocument.createElement('div') diff --git a/test/unit/dataContentPane.test.ts b/test/unit/dataContentPane.test.ts new file mode 100644 index 00000000..b5d8aecc --- /dev/null +++ b/test/unit/dataContentPane.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { Collection, graph, literal, namedNode, parse, sym } from 'rdflib' +import { dataContentPane } from '../../src/dataContentPane' + +function context (store: any) { + return { dom: document, session: { store } } as any +} + +describe('data content pane', () => { + it('preserves responsive table classes when rendering a nested formula', () => { + const store = graph() + parse( + '<#me> <#example> { <#item> <#label> "Nested formula example". }.', + store, 'https://pod.example/card', 'text/n3' + ) + const renderContext = context(store) + renderContext.session.paneRegistry = { byName: () => dataContentPane } + + const table = dataContentPane.statementsAsTables(store.statements, renderContext) + const formula = table.querySelector('.data-content-pane__nested-formula') + + expect(formula).toHaveClass('data-content-pane__table', 'data-content-pane__table--root') + expect(formula).toHaveTextContent('Nested formula example') + }) + + it('keeps header IDs unique across nested and separate renders', () => { + const store = graph() + const subject = namedNode('https://pod.example/item') + const predicate = namedNode('https://pod.example/name') + store.add(subject, predicate, literal('One')) + store.add( + subject, + namedNode('https://pod.example/example'), + store.formula([]) + ) + const renderContext = context(store) + renderContext.session.paneRegistry = { byName: () => dataContentPane } + + const first = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + renderContext + ) + const second = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + renderContext + ) + const ids = [ + ...first.querySelectorAll('[id]'), + ...second.querySelectorAll('[id]') + ].map(element => element.id) + + expect(new Set(ids).size).toBe(ids.length) + for (const valueCell of [ + ...first.querySelectorAll('[headers]'), + ...second.querySelectorAll('[headers]') + ]) { + expect(ids).toContain(valueCell.getAttribute('headers')) + } + }) + + it('exposes RDF relationships and literal language metadata', () => { + const store = graph() + const subject = sym('https://pod.example/item') + const predicate = sym('https://pod.example/name') + store.add(subject, predicate, literal('Bonjour', 'fr')) + store.add(subject, predicate, literal('Au revoir', 'fr')) + + const table = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + context(store) + ) + const value = table.querySelector('.data-content-pane__value-cell') + const predicateHeader = table.querySelector('.data-content-pane__predicate-cell') + + expect(table.querySelector('th[scope="row"]')).toBeTruthy() + expect(predicateHeader).toBeTruthy() + expect(Array.from(table.querySelectorAll('[lang="fr"]')).map(node => node.textContent)) + .toEqual(expect.arrayContaining(['Bonjour', 'Au revoir'])) + expect(predicateHeader).toHaveAttribute('rowspan', '2') + expect(value?.getAttribute('headers')).toBe(predicateHeader?.id) + }) + + it('renders named nodes, collections, and nested blank nodes semantically', () => { + const store = graph() + const subject = namedNode('https://pod.example/item') + const related = namedNode('https://pod.example/related') + const nested = store.bnode('nested') + store.add(subject, namedNode('https://pod.example/links'), related) + store.add(subject, namedNode('https://pod.example/details'), nested) + store.add(subject, namedNode('https://pod.example/otherDetails'), nested) + store.add(nested, namedNode('https://pod.example/label'), literal('Nested')) + const collection = new Collection() + collection.append(related) + collection.append(literal('Second')) + store.add(subject, namedNode('https://pod.example/list'), collection) + + const table = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + context(store) + ) + + expect(table.querySelector(`a[href="${related.uri}"]`)).toBeTruthy() + expect(table.querySelector('ol > li')).toBeTruthy() + expect(table.querySelector('table[aria-label="Properties"]')).toBeTruthy() + expect(table.querySelector('.bnodeDef[tabindex="-1"]')).toBeTruthy() + }) + + it('preserves sanitized XMLLiteral content and labels its embedded region', () => { + const store = graph() + const subject = namedNode('https://pod.example/item') + const xmlLiteral = literal( + '

Safe

Diagram', + namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral') + ) + store.add(subject, namedNode('https://pod.example/content'), xmlLiteral) + + const table = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + context(store) + ) + const embedded = table.querySelector('[role="group"]') + + expect(embedded).toHaveAttribute('aria-label', 'Embedded HTML content') + expect(embedded).toHaveTextContent('Safe') + expect(embedded?.querySelector('img')).toHaveAttribute('alt', 'Diagram') + expect(embedded?.querySelector('script')).toBeNull() + }) + + it('keeps links usable when no outline manager is available', () => { + const store = graph() + const subject = namedNode('https://pod.example/item') + const target = namedNode('https://pod.example/target') + store.add(subject, namedNode('https://pod.example/related'), target) + const table = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + context(store) + ) + const link = table.querySelector(`a[href="${target.uri}"]`) as HTMLAnchorElement + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) + + link.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + }) + + it('leaves modified clicks to native browser navigation', () => { + const store = graph() + const subject = namedNode('https://pod.example/item') + const target = namedNode('https://pod.example/target') + store.add(subject, namedNode('https://pod.example/related'), target) + const table = dataContentPane.statementsAsTables( + store.statementsMatching(subject), + context(store) + ) + const link = table.querySelector(`a[href="${target.uri}"]`) as HTMLAnchorElement + const event = new MouseEvent('click', { + bubbles: true, + cancelable: true, + ctrlKey: true + }) + + link.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + }) +}) diff --git a/test/unit/paneLayout.test.ts b/test/unit/paneLayout.test.ts index 84f3f4fc..5ddc6f35 100644 --- a/test/unit/paneLayout.test.ts +++ b/test/unit/paneLayout.test.ts @@ -18,10 +18,10 @@ describe('pane content spacing', () => { /\.paneDiv\.defaultPane \{[\s\S]*padding-inline: 40px;/ ) expect(stylesheet('dataContentPane.css')).toMatch( - /div\.data-content-pane \{[\s\S]*padding: var\(--spacing-base, 0\.5rem\) 40px;/ + /\.data-content-pane \{[^}]*padding: 8px 40px;/ ) expect(stylesheet('dataContentPane.css')).not.toMatch( - /\.data-content-pane__nested-formula[^{]*\{[\s\S]*40px/ + /\.data-content-pane__nested-formula[^{]*\{[^}]*40px/ ) })