Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ Finally, if you're going to use a component that requires global state (like `<s
</solid-ui-provider>
```

### Widget navigation icons

Resource navigation links in widget rows and cards use the imported Lucide
`square-arrow-out-up-right` component in neutral gray, sized to match remove
icons. The `linkIcon` helper still accepts a custom image URL as its third
argument; explicit custom icons retain their existing image rendering.

### Attachment-list delete confirmations

`widgets.attachmentList(dom, subject, container, options)` accepts an optional
`options.confirmationContainer` HTML element. When provided, delete confirmations
are rendered in normal flow in that element, outside the list's rows, with Cancel
focused. Only one confirmation is shown per confirmation container; unrelated
children are preserved. Mount the container in a visible location above or beside
the list.

Omitting this option preserves the existing row-local popup and positioning.
`personTR` / `renderAsRow` and `renderAsDiv` accept the same option.
`deleteButtonWithCheck` accepts the container as an optional fifth argument;
existing four-argument calls are unchanged.

## Use directly in a browser

Solid-UI provides **ESM** bundles for direct browser usage. You'll also need to include the CSS styles:
Expand Down
206 changes: 103 additions & 103 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/components/combobox/Combobox.styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
padding: 10px;
padding-right: 40px;
width: 100%;

/* Global focus styles don't reach into the shadow DOM, so mirror them here. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think these are the way we should do the styles anymore I might be wrong but I think this is the way we did them in mashlib but now everything is in themes.css

so for instance i think they should be
outline: 2px solid var(--solid-ui-color-primary, #7C4DFF). (only --focus-indicator-width and this --color-primary if you wanted style configuration from the caller, and with that you should add them at the top under :host if you want to do that.
for
box-shadow: 0 0 0 1px #F8F9FB, 0 0 0 4px rgba(124, 77, 255, 0.2); maybe just that for now, otherwise you can also add to themes.css a solid-ui-color-{color}-{level} for this color.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a bigger style redo in most panes. Later.

&:focus-visible {
outline: var(--focus-indicator-width, 2px) solid var(--color-primary, var(--solid-ui-color-primary, #7C4DFF));
outline-offset: 2px;
box-shadow: 0 0 0 1px var(--color-background, #F8F9FB), 0 0 0 4px rgba(124, 77, 255, 0.2);
transition: none;
}
}

icon-lucide-chevron-down {
Expand Down
97 changes: 81 additions & 16 deletions src/widgets/buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type RenderAsDivOptions = {
image?: HTMLImageElement,
title?: string,
deleteFunction?: () => void,
confirmationContainer?: HTMLElement,
link?: boolean,
noun?: string,
draggable?: boolean,
Expand Down Expand Up @@ -464,16 +465,21 @@ export function faviconOrDefault (dom: HTMLDocument, x: NamedNode) {
/* Two-option dialog pop-up
*/

function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFunction) {
function renderDeleteConfirmPopup (dom: HTMLDocument, refererenceElement: HTMLElement, prompt: string, deleteFunction: () => any, restoreFocus: () => void) {
function removePopup () {
refererenceElement.parentElement.removeChild(refererenceElement)
refererenceElement.remove()
restoreFocus()
}
function removePopupAndDoDeletion () {
removePopup()
deleteFunction()
const result = deleteFunction()
if (result && typeof result.then === 'function') {
return result.then(restoreFocus)
}
restoreFocus()
}
const popup = dom.createElement('div')
popup.style = style.confirmPopupStyle
popup.style.cssText = style.confirmPopupStyle
popup.style.position = 'absolute'
popup.style.top = '-1em' // try leaving original button clear

Expand All @@ -492,7 +498,7 @@ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFuncti
xButton.style.gridRow = '2'

const cancelPrompt = popup.appendChild(dom.createElement('button'))
cancelPrompt.style = style.buttonStyle
cancelPrompt.style.cssText = style.buttonStyle
cancelPrompt.style.gridRow = '2'
cancelPrompt.style.gridColumn = '2'
cancelPrompt.textContent = 'Cancel' // @@ I18n
Expand All @@ -503,7 +509,7 @@ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFuncti
affirmIcon.style.gridColumn = '1'

const sureButtonElt = popup.appendChild(dom.createElement('button'))
sureButtonElt.style = style.buttonStyle
sureButtonElt.style.cssText = style.buttonStyle
sureButtonElt.style.gridRow = '1'
sureButtonElt.style.gridColumn = '2'
sureButtonElt.textContent = prompt
Expand All @@ -519,33 +525,87 @@ function renderDeleteConfirmPopup (dom, refererenceElement, prompt, deleteFuncti
}
/**
* Delete button with a check you really mean it
* Return a promise from deleteFunction for focus restoration after asynchronous deletion.
* @@ Supress check if command key held down?
*/
export function deleteButtonWithCheck (
dom: HTMLDocument,
container: HTMLElement,
noun: string,
deleteFunction: () => any
deleteFunction: () => any,
confirmationContainer?: HTMLElement
) {
function createPopup () {
const focusableSelector = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const focusScopes: { element: HTMLElement, index: number }[] = []
if (confirmationContainer) {
for (let ancestor: HTMLElement | null = container; ancestor; ancestor = ancestor.parentElement) {
const controls = Array.from(ancestor.querySelectorAll<HTMLElement>(focusableSelector))
focusScopes.push({ element: ancestor, index: controls.indexOf(deleteButton) })
}
}
function restoreFocus () {
if (!confirmationContainer) return
if (deleteButton.isConnected) {
deleteButton.focus()
return
}
for (const scope of focusScopes) {
if (!scope.element.isConnected) continue
const controls = scope.element.querySelectorAll<HTMLElement>(focusableSelector)
if (controls.length) {
controls[Math.min(Math.max(scope.index, 0), controls.length - 1)].focus()
return
}
}
const survivingScope = focusScopes.find(scope => scope.element.isConnected)
if (survivingScope) {
survivingScope.element.tabIndex = -1
survivingScope.element.focus()
}
}
const refererenceElement = dom.createElement('div')
container.insertBefore(refererenceElement, deleteButton)
refererenceElement.style.position = 'relative' // Needed as reference for popup
refererenceElement.appendChild(renderDeleteConfirmPopup(dom, refererenceElement, prompt, deleteFunction))
if (confirmationContainer) {
confirmationContainer.querySelector(':scope > [data-delete-confirmation]')?.remove()
refererenceElement.setAttribute('data-delete-confirmation', '')
refererenceElement.setAttribute('role', 'dialog')
refererenceElement.setAttribute('aria-label', prompt)
confirmationContainer.appendChild(refererenceElement)
} else {
container.insertBefore(refererenceElement, deleteButton)
refererenceElement.style.position = 'relative' // Needed as reference for popup
}
const popup = renderDeleteConfirmPopup(dom, refererenceElement, prompt, deleteFunction, restoreFocus)
refererenceElement.appendChild(popup)
if (confirmationContainer) {
popup.style.position = 'static'
popup.style.top = ''
Array.from(popup.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.textContent === 'Cancel')?.focus()
Comment thread
timea-solid marked this conversation as resolved.
}
}
const minusIconURI = iconBase + 'noun_2188_red.svg' // white minus in red #cc0000 circle
const deleteButton = dom.createElement('img')
deleteButton.setAttribute('src', minusIconURI)
deleteButton.tabIndex = 0
deleteButton.setAttribute('role', 'button')

deleteButton.setAttribute('style', style.smallButtonStyle) // @@tsc - would set deleteButton.style
deleteButton.style.float = 'right' // Historically this has alwaus floated right

const prompt = 'Remove this ' + noun
deleteButton.title = prompt
deleteButton.setAttribute('aria-label', prompt)
// @@ In an ideal world, make use of hover an accessibility option
deleteButton.classList.add('hoverControlHide')

deleteButton.addEventListener('click', createPopup)
deleteButton.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
deleteButton.click()
}
})

container.classList.add('hoverControl')
container.appendChild(deleteButton)
Expand Down Expand Up @@ -769,7 +829,7 @@ export function renderAsRow (dom: HTMLDocument, pred: NamedNode, obj: NamedNode,
}

if (options.deleteFunction) {
deleteButtonWithCheck(dom, td3, options.noun || 'one', options.deleteFunction)
deleteButtonWithCheck(dom, td3, options.noun || 'one', options.deleteFunction, options.confirmationContainer)
}
if (obj.uri) {
// blank nodes need not apply
Expand Down Expand Up @@ -813,7 +873,7 @@ export function createLinkDiv (dom: HTMLDocument, div: HTMLDivElement, obj: Name
linkDiv.setAttribute('style', style.linkDivStyle)

if (options.deleteFunction) {
deleteButtonWithCheck(dom, linkDiv, options.noun || 'one', options.deleteFunction)
deleteButtonWithCheck(dom, linkDiv, options.noun || 'one', options.deleteFunction, options.confirmationContainer)
}

if (obj.uri) {
Expand Down Expand Up @@ -878,6 +938,8 @@ export type attachmentListOptions = {
predicate?: NamedNode
uploadFolder?: NamedNode
noun?: string
/** Render delete confirmations here in normal flow, restoring focus to the list when closed. */
confirmationContainer?: HTMLElement
renderSupportingInfo?: RenderSupportingInfo
renderNameSuffix?: RenderNameSuffix
refreshOnDocumentLoad?: boolean
Expand All @@ -900,11 +962,12 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML
const refreshOnDocumentLoad = options.refreshOnDocumentLoad ?? true

const deleteAttachment = function (target) {
if (!kb.updater) {
const updater = kb.updater
if (!updater) {
throw new Error('kb has no updater')
}

kb.updater.update(st(subject, predicate, target, doc) as any, [], function (
return new Promise<void>(resolve => updater.update(st(subject, predicate, target, doc) as any, [], function (
uri,
ok,
errorBody,
Expand All @@ -915,14 +978,16 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML
} else {
complain(undefined, 'Error deleting one: ' + errorBody)
}
})
resolve()
}))
}

function createNewRow (target) {
const theTarget = target
const opt: any = { noun }
opt.renderSupportingInfo = options.renderSupportingInfo
opt.renderNameSuffix = options.renderNameSuffix
opt.confirmationContainer = options.confirmationContainer

if (hasAsyncEnrichedRowOptions && refreshOnDocumentLoad && target?.uri && kb.fetcher) {
const targetDoc = target.doc()
Expand All @@ -943,7 +1008,7 @@ export function attachmentList (dom: HTMLDocument, subject: NamedNode, div: HTML

if (modify) {
opt.deleteFunction = function () {
deleteAttachment(theTarget)
return deleteAttachment(theTarget)
}
}
return personTR(dom, predicate, target, opt)
Expand Down
18 changes: 11 additions & 7 deletions src/widgets/buttons/iconLinks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NamedNode } from 'rdflib'
import { originalIconBase } from '../../lib/iconBase'
import '~icons/lucide/square-arrow-out-up-right'

/**
* Creates an anchor tag for a NamedNode
Expand All @@ -21,12 +21,16 @@ export function linkIcon (dom: HTMLDocument, subject: NamedNode, iconURI?: strin
// If diff web page
anchor.setAttribute('target', '_blank') // open in a new tab or window
} // as mailboxes and mail messages do not need new browser window
const img = anchor.appendChild(dom.createElement('img'))
img.setAttribute(
'src',
iconURI || originalIconBase + 'go-to-this.png'
)
img.setAttribute('style', 'margin: 0.3em;')
if (iconURI) {
const img = anchor.appendChild(dom.createElement('img'))
img.setAttribute('src', iconURI)
img.setAttribute('style', 'margin: 0.3em;')
} else {
anchor.setAttribute('aria-label', 'Open resource')
const icon = anchor.appendChild(dom.createElement('icon-lucide-square-arrow-out-up-right'))
icon.setAttribute('aria-hidden', 'true')
icon.setAttribute('style', 'display: inline-block; width: 1em; height: 1em; margin: 0.2em; color: var(--color-text-secondary, #4A5565);')
}
return anchor
}
/**
Expand Down
Loading
Loading