fix(admin): shadow-DOM-aware plain keys, commit drafts before shortcut saves and safe My account links (B-WR-01, B-WR-02, B-WR-03, B-WR-06, B-IN-01)
- plain-key shortcuts test composedPath()[0], and a custom element inside a form counts as editable (closed shadow roots) - Ctrl/Cmd+S and Ctrl/Cmd+Enter blur the focused field before clicking, so a pending taglist draft or native change is committed, then refocus it - safeUrl keeps javascript:/data: URLs out of href and window.open - a failed contributions GET shows a toast and keeps the sections; a section with error true shows an error line - My account confirm and stale texts get their own phrase keys - rebuilt modules/boardwalk/dist
This commit is contained in:
@@ -96,12 +96,31 @@ export function comboOf(event: KeyboardEvent): HotkeyCombo | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the target is inside an input, textarea, select or contenteditable element. */
|
||||
/**
|
||||
* True when the target is inside an input, textarea, select or contenteditable
|
||||
* element, or is a custom element (a tag name with "-") inside a form: a
|
||||
* plugin field type whose closed shadow root hides the real input, where
|
||||
* composedPath() stops at the host.
|
||||
*/
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!target || typeof (target as Element).closest !== 'function') {
|
||||
return false
|
||||
}
|
||||
return (target as Element).closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null
|
||||
const element = target as Element
|
||||
if (element.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null) {
|
||||
return true
|
||||
}
|
||||
return element.tagName.includes('-') && element.closest('form, [role="form"]') !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a key event really started: the first entry of composedPath(), which
|
||||
* reaches inside an open shadow root (event.target is retargeted to the
|
||||
* shadow host by the time the event reaches window), else event.target.
|
||||
*/
|
||||
export function originOf(event: Event): EventTarget | null {
|
||||
const path = typeof event.composedPath === 'function' ? event.composedPath() : []
|
||||
return path[0] ?? event.target
|
||||
}
|
||||
|
||||
/** The keys of a shortcut as shown on screen, one chip each. */
|
||||
@@ -202,7 +221,16 @@ function onModifierKey(event: KeyboardEvent): void {
|
||||
if (event.repeat || isDisabled(target)) {
|
||||
return
|
||||
}
|
||||
// Commit pending edits the way a pointer click would: blurring the
|
||||
// focused field runs its blur and native change handlers (a taglist's
|
||||
// typed-but-uncommitted tag) before the button reads the values.
|
||||
const active = doc.activeElement
|
||||
const blurred = active instanceof HTMLElement && active !== target && active !== doc.body ? active : null
|
||||
blurred?.blur()
|
||||
target.click()
|
||||
if (blurred?.isConnected) {
|
||||
blurred.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function onPlainKey(event: KeyboardEvent): void {
|
||||
@@ -210,7 +238,7 @@ function onPlainKey(event: KeyboardEvent): void {
|
||||
if (combo !== 'escape' && combo !== '/' && combo !== '?') {
|
||||
return
|
||||
}
|
||||
if (event.defaultPrevented || isEditableTarget(event.target)) {
|
||||
if (event.defaultPrevented || isEditableTarget(originOf(event))) {
|
||||
return
|
||||
}
|
||||
const doc = (event.target as Node | null)?.ownerDocument ?? document
|
||||
|
||||
@@ -22,12 +22,33 @@ const running = ref('')
|
||||
/** The URL the last action answered, per section, kept as a visible link. */
|
||||
const links = ref<Record<string, string>>({})
|
||||
|
||||
/**
|
||||
* The URL when it may be rendered and opened: an absolute http(s) URL or a
|
||||
* same-origin path (one leading "/"), else ''. The server applies the same
|
||||
* rule; this keeps a javascript: or data: URL out of href and window.open
|
||||
* even if one gets through.
|
||||
*/
|
||||
function safeUrl(u: string): string {
|
||||
if (/^https?:\/\//i.test(u)) {
|
||||
return u
|
||||
}
|
||||
if (u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')) {
|
||||
return u
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
const result = await api.GET('/account/contributions')
|
||||
sections.value = result.data?.data ?? []
|
||||
if (result.data) {
|
||||
sections.value = result.data.data ?? []
|
||||
return
|
||||
}
|
||||
// A failed load keeps the sections already shown and says so.
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
} catch {
|
||||
sections.value = []
|
||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,16 +61,17 @@ async function post(section: AccountContribution, action: AccountContributionAct
|
||||
if (result.data) {
|
||||
const answer = result.data.data
|
||||
showToast(answer.message || t('backend::lang.form.action_done'))
|
||||
if (answer.url) {
|
||||
links.value = { ...links.value, [section.name]: answer.url }
|
||||
window.open(answer.url, '_blank', 'noopener')
|
||||
const url = safeUrl(answer.url ?? '')
|
||||
if (url) {
|
||||
links.value = { ...links.value, [section.name]: url }
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
await load()
|
||||
return
|
||||
}
|
||||
const status = result.response.status
|
||||
if (status === 409) {
|
||||
showToast(t('backend::lang.form.action_stale'), 'danger')
|
||||
showToast(t('backend::lang.myaccount.action_stale'), 'danger')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
@@ -75,7 +97,7 @@ async function run(section: AccountContribution, action: AccountContributionActi
|
||||
}
|
||||
await props.confirm.ask(
|
||||
{
|
||||
message: action.confirm || t('backend::lang.form.action_confirm', { action: action.label }),
|
||||
message: action.confirm || t('backend::lang.myaccount.action_confirm', { action: action.label }),
|
||||
confirmLabel: action.label,
|
||||
danger: action.danger,
|
||||
},
|
||||
@@ -100,10 +122,13 @@ onMounted(() => {
|
||||
<p v-if="section.description" class="max-w-[640px] text-[13px] text-muted">{{ section.description }}</p>
|
||||
</div>
|
||||
<p v-if="section.status" data-account-contribution-status class="font-medium">{{ section.status }}</p>
|
||||
<p v-if="section.error" data-account-contribution-error class="text-[13px] text-muted">
|
||||
{{ t('backend::lang.myaccount.section_error') }}
|
||||
</p>
|
||||
<a
|
||||
v-if="links[section.name]"
|
||||
v-if="safeUrl(links[section.name] ?? '')"
|
||||
data-account-contribution-url
|
||||
:href="links[section.name]"
|
||||
:href="safeUrl(links[section.name] ?? '')"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary underline [overflow-wrap:anywhere]"
|
||||
|
||||
Reference in New Issue
Block a user