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' } 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) + }) +})