diff --git a/src/acl/access-groups.ts b/src/acl/access-groups.ts
index 63f4dd7af..695473750 100644
--- a/src/acl/access-groups.ts
+++ b/src/acl/access-groups.ts
@@ -264,7 +264,7 @@ export class AccessGroups {
: 'No RDF type was detected for this URI.'
const error =
`Error: Failed to add access target: ${uri} is not a recognized ACL target type.` +
- ` Expected one of: vcard:WebID, vcard:Group, foaf:Person, foaf:Agent, solid:AppProvider, solid:AppProviderClass, or recognized ACL classes.` +
+ ' Expected one of: vcard:WebID, vcard:Group, foaf:Person, foaf:Agent, solid:AppProvider, solid:AppProviderClass, or recognized ACL classes.' +
' Hint: try dropping a WebID profile URI, a vcard:Group URI, or a web app origin.' +
typeDetails
debug.error(error)
diff --git a/src/pad.ts b/src/pad.ts
index 8ffcd9a6e..fca8f8736 100644
--- a/src/pad.ts
+++ b/src/pad.ts
@@ -46,7 +46,7 @@ class NotepadPart extends HTMLElement {
* @param {NamedNode} author - The author of text being displayed
* @returns {String} The CSS color generated, constrained to be light for a background color
*/
-export function lightColorHash(author?: NamedNode): string {
+export function lightColorHash (author?: NamedNode): string {
const hash = function (x) {
return x.split('').reduce(function (a, b) {
a = (a << 5) - a + b.charCodeAt(0)
@@ -66,7 +66,7 @@ export function lightColorHash(author?: NamedNode): string {
* @param {NamedNode} me - person who is logged into the pod
* @param {notepadOptions} options - the options that can be passed in consist of statusArea, exists
*/
-export function notepad(
+export function notepad (
dom: HTMLDocument,
padDoc: NamedNode,
subject: NamedNode,
@@ -164,7 +164,7 @@ export function notepad(
const next: any = kb.any(chunk as any, PAD('next'))
if (prev.sameTerm(subject) && next.sameTerm(subject)) {
// Last one
- log("You can't delete the only line.")
+ log('You can\'t delete the only line.')
return
}
@@ -210,7 +210,7 @@ export function notepad(
}, 1000)
} else {
log(' removePart FAILED ' + chunk + ': ' + errorMessage)
- log(" removePart was deleting :'" + del)
+ log(' removePart was deleting :\'' + del)
setPartStyle(part, 'color: black; background-color: #fdd;') // failed
const res = response
? (response as any).status
@@ -235,9 +235,9 @@ export function notepad(
updater.update(del, ins as any, function (uri, ok, errorBody) {
if (!ok) {
log(
- "Indent change FAILED '" +
+ 'Indent change FAILED \'' +
newIndent +
- "' for " +
+ '\' for ' +
padDoc +
': ' +
errorBody
@@ -351,11 +351,11 @@ export function notepad(
if (old !== part.lastSent) {
// Non-fatal: log a warning instead of throwing, to avoid crashing the pad UI.
console.warn(
- "Out of order, last sent expected '" +
+ 'Out of order, last sent expected \'' +
old +
- "' but found '" +
+ '\' but found \'' +
part.lastSent +
- "'"
+ '\''
)
}
}
@@ -380,11 +380,11 @@ export function notepad(
log(
' patch FAILED ' +
(xhr as any).status +
- " for '" +
+ ' for \'' +
old +
- "' -> '" +
+ '\' -> \'' +
newOne +
- "': " +
+ '\': ' +
errorBody
)
if ((xhr as any).status === 409) {
@@ -421,7 +421,7 @@ export function notepad(
} else {
clearStatus(true) // upstream
setPartStyle(part) // synced
- log(" Patch ok '" + old + "' -> '" + newOne + "' ")
+ log(' Patch ok \'' + old + '\' -> \'' + newOne + '\' ')
if (part.state === 4) {
// delete me
@@ -440,10 +440,10 @@ export function notepad(
})
}
- part.addEventListener('input', function inputChangeListener(_event) {
+ part.addEventListener('input', function inputChangeListener (_event) {
// debug.log("input changed "+part.value);
setPartStyle(part, undefined, true) // grey out - not synced
- log('Input event state ' + part.state + " value '" + part.value + "'")
+ log('Input event state ' + part.state + ' value \'' + part.value + '\'')
switch (part.state) {
case 3: // being deleted
return
@@ -498,7 +498,7 @@ export function notepad(
addListeners(part, chunk)
} else {
setPartStyle(part, 'color: #222; background-color: #fff')
- log("Note can't add listeners - not logged in")
+ log('Note can\'t add listeners - not logged in')
}
return part
}
@@ -584,7 +584,7 @@ export function notepad(
const consistencyCheck = function () {
const found: { [uri: string]: boolean } = {}
let failed = 0
- function complain2(msg) {
+ function complain2 (msg) {
complain(msg)
failed++
}
@@ -831,7 +831,7 @@ export function notepad(
*/
// @ignore exporting this only for the unit test
-export function getChunks(subject: NamedNode, kb: IndexedFormula) {
+export function getChunks (subject: NamedNode, kb: IndexedFormula) {
const chunks: any[] = []
for (
let chunk: any = kb.the(subject, PAD('next'));
@@ -847,7 +847,7 @@ export function getChunks(subject: NamedNode, kb: IndexedFormula) {
* Encode content to be put in XML or HTML elements
*/
// @ignore exporting this only for the unit test
-export function xmlEncode(str) {
+export function xmlEncode (str) {
return str.replace('&', '&').replace('<', '<').replace('>', '>')
}
@@ -856,7 +856,7 @@ export function xmlEncode(str) {
* @param { } pad - the notepad
* @param {store} pad - the data store
*/
-export function notepadToHTML(pad: any, kb: IndexedFormula) {
+export function notepadToHTML (pad: any, kb: IndexedFormula) {
const chunks = getChunks(pad, kb)
let html = '\n
\n'
const title = kb.anyValue(pad, ns.dct('title'))
@@ -866,13 +866,13 @@ export function notepadToHTML(pad: any, kb: IndexedFormula) {
html += ' \n \n'
let level = 0
- function increaseLevel(indent) {
+ function increaseLevel (indent) {
for (; level < indent; level++) {
html += '\n'
}
}
- function decreaseLevel(indent) {
+ function decreaseLevel (indent) {
for (; level > indent; level--) {
html += '
\n'
}
diff --git a/src/stories/Modals.stories.js b/src/stories/Modals.stories.js
new file mode 100644
index 000000000..b632cddf8
--- /dev/null
+++ b/src/stories/Modals.stories.js
@@ -0,0 +1,55 @@
+import * as UI from '../../src/index'
+
+export default {
+ title: 'Modals',
+}
+
+const demoLinks = [
+ { label: 'Solid Project', link: 'https://solidproject.org/' },
+ { label: 'SolidOS', link: 'https://github.com/solidos' },
+ { label: 'W3C', link: 'https://www.w3.org/' },
+]
+
+export const ListModal = {
+ render: () => {
+ const modal = UI.widgets.createListModal(document, demoLinks, {
+ withGreyedBackground: true,
+ ariaLabel: 'Helpful links',
+ })
+ modal.style.display = 'block'
+ return modal
+ },
+ name: 'List modal',
+}
+
+export const OpenListModal = {
+ render: () => {
+ const container = document.createElement('div')
+
+ const openButton = document.createElement('button')
+ openButton.setAttribute('type', 'button')
+ openButton.textContent = 'Open links modal'
+
+ const helper = document.createElement('p')
+ helper.textContent = 'Open the modal, then press Escape or use the close button.'
+
+ openButton.addEventListener('click', () => {
+ const existing = container.querySelector('.modal')
+ if (existing) {
+ existing.remove()
+ }
+
+ const modal = UI.widgets.createListModal(document, demoLinks, {
+ withGreyedBackground: true,
+ ariaLabel: 'Helpful links',
+ })
+ modal.style.display = 'block'
+ container.appendChild(modal)
+ })
+
+ container.appendChild(openButton)
+ container.appendChild(helper)
+ return container
+ },
+ name: 'Open list modal',
+}
diff --git a/src/style.js b/src/style.js
index 187160c85..e3adcf6fa 100644
--- a/src/style.js
+++ b/src/style.js
@@ -63,6 +63,15 @@ export const style = { // styleModule
imageDivStyle: 'width:2.5em; padding:0.5em; height: 2.5em;',
linkDivStyle: 'width:2em; padding:0.5em; height: 4em;',
+ // modals
+ modalUnorderedListStyle: 'padding: 0 .2em;',
+ modalListItemStyle: 'list-style: none; box-shadow: 1px 1px 1px 1px #888888; padding: .5em;',
+ modalAnchorStyle: 'text-decoration: none;',
+ modalContentStyle: 'display: flex; flex-direction: column; background-color: #fefefe;',
+ modalCloseStyle: 'background: none; border: 0; padding: 0; color: #aaaaaa; align-self: flex-end; font-size: 20px; font-weight: bold; margin-right: .2em; margin-top: .2em; cursor: pointer;',
+ modalCloseStyleHover: 'background: none; border: 0; padding: 0; color: #666666; align-self: flex-end; font-size: 20px; font-weight: bold; margin-right: .2em; margin-top: .2em; text-decoration: none; cursor: pointer;',
+ modalCloseStyleFocus: 'background: none; border: 0; padding: 0; color: #666666; align-self: flex-end; font-size: 20px; font-weight: bold; margin-right: .2em; margin-top: .2em; text-decoration: none; cursor: pointer; outline: 1px solid #888;',
+
// ACL
aclControlBoxContainer: 'margin: 1em;',
aclControlBoxHeader: 'font-size: 120%; margin: 0 0 1rem;',
diff --git a/src/widgets/index.js b/src/widgets/index.js
index 3b6f8e51d..8e9f860d4 100644
--- a/src/widgets/index.js
+++ b/src/widgets/index.js
@@ -23,6 +23,7 @@ export * from './buttons'
export * from './buttons/iconLinks'
export * from './error'
export * from './forms'
+export * from './modals'
export * from './forms/autocomplete/autocompleteBar'
export * from './forms/autocomplete/autocompletePicker'
diff --git a/src/widgets/modals.ts b/src/widgets/modals.ts
new file mode 100644
index 000000000..c1be37925
--- /dev/null
+++ b/src/widgets/modals.ts
@@ -0,0 +1,116 @@
+import {
+ ModalWidgetStyleOptions,
+ getModalStyle
+} from './modalsStyle'
+import { style } from '../style'
+
+export type ListItem = {
+ label: string,
+ link: string
+}
+
+// Two functions that need to be implemented to use the modal.
+// When the user clicks the button, open the modal.
+/* Click handler on the button to display it.
+btn.onclick = function() {
+ modal.style.display = "block";
+}
+
+// When the user clicks anywhere outside of the modal, close it.
+// Window click handler so that the modal will close
+// even if the user doesn't click close.
+window.onclick = function(event) {
+ if (event.target == modal) {
+ modal.style.display = "none";
+ } */
+const closeClickHandler = (modal: HTMLDivElement, returnFocusTo?: Element | null) => {
+ modal.style.display = 'none'
+ if (returnFocusTo && returnFocusTo instanceof HTMLElement) {
+ returnFocusTo.focus()
+ }
+}
+
+const createModal = (dom: HTMLDocument, options: ModalWidgetStyleOptions, returnFocusTo?: Element | null) => {
+ const modal = dom.createElement('div')
+ modal.classList.add('modal')
+ modal.setAttribute('role', 'dialog')
+ modal.setAttribute('aria-modal', options.withGreyedBackground ? 'true' : 'false')
+ modal.setAttribute('aria-label', options.ariaLabel || 'List dialog')
+ modal.setAttribute('tabindex', '-1')
+ modal.addEventListener('keydown', event => {
+ if (event.key === 'Escape') {
+ closeClickHandler(modal, returnFocusTo)
+ }
+ })
+ modal.setAttribute('style', getModalStyle(options))
+ return modal
+}
+
+const createModalContent = (dom: HTMLDocument) => {
+ const modalContent: HTMLDivElement = dom.createElement('div')
+ modalContent.setAttribute('style', style.modalContentStyle)
+ return modalContent
+}
+
+const createCloseButton = (dom: HTMLDocument, modal: HTMLDivElement, returnFocusTo?: Element | null) => {
+ const closeButton: HTMLButtonElement = dom.createElement('button')
+ closeButton.setAttribute('type', 'button')
+ closeButton.setAttribute('aria-label', 'Close modal')
+ closeButton.setAttribute('title', 'Close')
+ closeButton.textContent = 'x'
+ closeButton.addEventListener('click', () => closeClickHandler(modal, returnFocusTo))
+ closeButton.addEventListener('mouseenter', () => {
+ closeButton.setAttribute('style', style.modalCloseStyleHover)
+ })
+ closeButton.addEventListener('mouseleave', () => {
+ closeButton.setAttribute('style', style.modalCloseStyle)
+ })
+ closeButton.addEventListener('focus', () => {
+ closeButton.setAttribute('style', style.modalCloseStyleFocus)
+ })
+ closeButton.addEventListener('blur', () => {
+ closeButton.setAttribute('style', style.modalCloseStyle)
+ })
+ closeButton.setAttribute('style', style.modalCloseStyle)
+ return closeButton
+}
+
+const createListItems = (dom: HTMLDocument, list: ListItem) => {
+ const li:HTMLLIElement = dom.createElement('li')
+ li.setAttribute('style', style.modalListItemStyle)
+ const link: HTMLAnchorElement = dom.createElement('a')
+ link.setAttribute('style', style.modalAnchorStyle)
+ link.href = list.link
+ link.textContent = list.label
+ li.appendChild(link)
+ return li
+}
+
+const createUnorderedList = (dom: HTMLDocument, listOfLinks: ListItem[]) => {
+ const ul: HTMLUListElement = dom.createElement('ul')
+ ul.setAttribute('role', 'list')
+ ul.setAttribute('style', style.modalUnorderedListStyle)
+ listOfLinks.forEach(list => {
+ const li = createListItems(dom, list)
+ ul.appendChild(li)
+ })
+ return ul
+}
+export const createListModal = (dom: HTMLDocument, listOfLinks: ListItem[], options: ModalWidgetStyleOptions) => {
+ const returnFocusTo = dom.activeElement
+ const modal = createModal(dom, options, returnFocusTo)
+ const modalContent = createModalContent(dom)
+ const closeButton = createCloseButton(dom, modal, returnFocusTo)
+ const ul = createUnorderedList(dom, listOfLinks)
+ modalContent.appendChild(closeButton)
+ modalContent.appendChild(ul)
+ modal.appendChild(modalContent)
+
+ setTimeout(() => {
+ if (closeButton.isConnected) {
+ closeButton.focus()
+ }
+ }, 0)
+
+ return modal
+}
diff --git a/src/widgets/modalsStyle.ts b/src/widgets/modalsStyle.ts
new file mode 100644
index 000000000..f84720184
--- /dev/null
+++ b/src/widgets/modalsStyle.ts
@@ -0,0 +1,21 @@
+/**
+ * Get the modal styles, based on options such as position and background overlay.
+ * See https://design.inrupt.com/atomic-core/?cat=Organisms#Modals for modal design guidelines.
+ */
+export type ModalWidgetStyleOptions = {
+ topPosition?: string,
+ leftPosition?: string,
+ withGreyedBackground?: boolean,
+ ariaLabel?: string
+}
+
+export const getModalStyle = (options: ModalWidgetStyleOptions = {}) => {
+ const topPosition = (options.topPosition) ? options.topPosition : '50px'
+ const leftPosition = (options.leftPosition) ? options.leftPosition : '50px'
+
+ if (options.withGreyedBackground) {
+ return 'display: none; position: fixed; z-index: 1; overflow: auto; background-color: rgba(0,0,0,0.4); padding-top: 100px; left: 0; top: 0; width: 100%; height: 100%;'
+ }
+
+ return `display: none; position: fixed; z-index: 1; top: ${topPosition}; left: ${leftPosition}; overflow: auto; background-color: rgba(0,0,0,0.4);`
+}
diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts
index e87a158b3..3f2e397c4 100644
--- a/test/helpers/setup.ts
+++ b/test/helpers/setup.ts
@@ -21,7 +21,7 @@ global.WritableStream = WritableStream
// Node provides MessagePort via worker_threads; jsdom/undici expects it in global scope
try {
- // eslint-disable-next-line @typescript-eslint/no-var-requires
+ // Intentionally require worker_threads in Jest setup to expose MessagePort globals.
const { MessageChannel, MessagePort } = require('worker_threads')
global.MessageChannel = MessageChannel
global.MessagePort = MessagePort
diff --git a/test/unit/widgets/modals.test.ts b/test/unit/widgets/modals.test.ts
new file mode 100644
index 000000000..6205ff7a8
--- /dev/null
+++ b/test/unit/widgets/modals.test.ts
@@ -0,0 +1,82 @@
+import { silenceDebugMessages } from '../helpers/debugger'
+import { createListModal } from '../../../src/widgets/modals'
+
+silenceDebugMessages()
+
+describe('modals', () => {
+ afterEach(() => {
+ jest.useRealTimers()
+ document.body.innerHTML = ''
+ })
+
+ it('creates a dialog modal with default accessibility attributes', () => {
+ const modal = createListModal(document, [], {})
+
+ expect(modal).toBeInstanceOf(HTMLDivElement)
+ expect(modal.getAttribute('role')).toEqual('dialog')
+ expect(modal.getAttribute('aria-modal')).toEqual('false')
+ expect(modal.getAttribute('aria-label')).toEqual('List dialog')
+ expect(modal.getAttribute('tabindex')).toEqual('-1')
+ })
+
+ it('renders list items with links and custom aria label', () => {
+ const modal = createListModal(
+ document,
+ [{ label: 'Solid', link: 'https://solidproject.org/' }],
+ { ariaLabel: 'Resource links', withGreyedBackground: true }
+ )
+
+ const list = modal.querySelector('ul') as HTMLUListElement
+ const anchor = modal.querySelector('a') as HTMLAnchorElement
+
+ expect(list.getAttribute('role')).toEqual('list')
+ expect(anchor.textContent).toEqual('Solid')
+ expect(anchor.href).toEqual('https://solidproject.org/')
+ expect(modal.getAttribute('aria-label')).toEqual('Resource links')
+ expect(modal.getAttribute('aria-modal')).toEqual('true')
+ })
+
+ it('uses an accessible close button', () => {
+ const modal = createListModal(document, [], {})
+ const closeButton = modal.querySelector('button') as HTMLButtonElement
+
+ expect(closeButton).toBeInstanceOf(HTMLButtonElement)
+ expect(closeButton.getAttribute('type')).toEqual('button')
+ expect(closeButton.getAttribute('aria-label')).toEqual('Close modal')
+ })
+
+ it('closes on close button click and restores focus to trigger element', () => {
+ jest.useFakeTimers()
+
+ const trigger = document.createElement('button')
+ document.body.appendChild(trigger)
+ trigger.focus()
+
+ const modal = createListModal(document, [], {})
+ document.body.appendChild(modal)
+
+ jest.runAllTimers()
+
+ const closeButton = modal.querySelector('button') as HTMLButtonElement
+ modal.style.display = 'block'
+ closeButton.click()
+
+ expect(modal.style.display).toEqual('none')
+ expect(document.activeElement).toBe(trigger)
+ })
+
+ it('closes on Escape and restores focus to trigger element', () => {
+ const trigger = document.createElement('button')
+ document.body.appendChild(trigger)
+ trigger.focus()
+
+ const modal = createListModal(document, [], {})
+ document.body.appendChild(modal)
+ modal.style.display = 'block'
+
+ modal.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
+
+ expect(modal.style.display).toEqual('none')
+ expect(document.activeElement).toBe(trigger)
+ })
+})