Skip to content

Commit 0c08a1f

Browse files
Merge remote-tracking branch 'upstream/master' into GH-3832
2 parents 953f759 + 955d22b commit 0c08a1f

10 files changed

Lines changed: 426 additions & 49 deletions

File tree

assets/css/app.scss

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,65 @@ img.course-tool__icon {
10131013
}
10141014
}
10151015

1016+
@layer base {
1017+
.form-control:focus,
1018+
input:focus,
1019+
select:focus,
1020+
textarea:focus {
1021+
outline: 0 !important;
1022+
border-color: #1d4ed8 !important;
1023+
box-shadow: 0 0 0 2px rgba(29, 78, 216, .35) !important;
1024+
border-radius: .5rem;
1025+
transition: box-shadow .12s ease, border-color .12s ease;
1026+
}
1027+
input[type="checkbox"]:focus,
1028+
input[type="radio"]:focus {
1029+
outline: 3px solid #1d4ed8 !important;
1030+
outline-offset: 2px;
1031+
box-shadow: 0 0 0 2px rgba(29, 78, 216, .35) !important;
1032+
border-radius: 4px;
1033+
}
1034+
.p-button:focus,
1035+
.p-button:focus-visible,
1036+
button:focus,
1037+
button:focus-visible,
1038+
.btn:focus,
1039+
.btn:focus-visible,
1040+
[role="button"]:focus,
1041+
[role="button"]:focus-visible,
1042+
a:focus,
1043+
a:focus-visible {
1044+
outline: 0 !important;
1045+
box-shadow: 0 0 0 2px rgba(29, 78, 216, .35) !important;
1046+
border-radius: .5rem !important;
1047+
}
1048+
.p-dropdown:focus-within,
1049+
.p-multiselect:focus-within,
1050+
.p-calendar:focus-within,
1051+
.p-inputnumber:focus-within,
1052+
.p-autocomplete:focus-within {
1053+
outline: 0 !important;
1054+
border-color: #1d4ed8 !important;
1055+
box-shadow: 0 0 0 2px rgba(29, 78, 216, .35) !important;
1056+
border-radius: .5rem !important;
1057+
}
1058+
}
1059+
@layer components {
1060+
.form-group:focus-within,
1061+
.field:focus-within,
1062+
fieldset:focus-within {
1063+
box-shadow: none !important;
1064+
outline: 0 !important;
1065+
}
1066+
.select2-container--default .select2-selection:focus,
1067+
.select2-container--default.select2-container--focus .select2-selection {
1068+
outline: 0 !important;
1069+
border-color: #1d4ed8 !important;
1070+
box-shadow: 0 0 0 2px rgba(29, 78, 216, .35) !important;
1071+
border-radius: .5rem !important;
1072+
}
1073+
}
1074+
10161075
@import "~@fancyapps/fancybox/dist/jquery.fancybox.css";
10171076
@import "~timepicker/jquery.timepicker.min.css";
10181077
@import "~qtip2/dist/jquery.qtip.min.css";

assets/js/legacy/app.js

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,248 @@ $(document).scroll(function () {
431431
}
432432
})
433433

434+
// focus first meaningful field + Enter=submit (any form, any container)
435+
;(function () {
436+
// Avoid double-install
437+
if (window.__A11Y_INSTALLED__) {
438+
return
439+
}
440+
window.__A11Y_INSTALLED__ = true
441+
442+
const NS = "[A11Y]"
443+
const boundForms = new WeakSet()
444+
const TEXT_TYPES = new Set([
445+
"text",
446+
"email",
447+
"password",
448+
"search",
449+
"url",
450+
"tel",
451+
"number",
452+
"date",
453+
"datetime-local",
454+
"month",
455+
"time",
456+
"week",
457+
"color",
458+
])
459+
460+
const isVisible = (el) => {
461+
if (!el) return false
462+
const s = getComputedStyle(el)
463+
if (s.visibility === "hidden" || s.display === "none") return false
464+
const r = el.getBoundingClientRect()
465+
return r.width > 0 && r.height > 0
466+
}
467+
468+
const inViewport = (el) => {
469+
if (!el) return false
470+
const r = el.getBoundingClientRect()
471+
const h = window.innerHeight || document.documentElement.clientHeight
472+
return r.top < h && r.bottom > 0
473+
}
474+
475+
function listFocusable(root) {
476+
const nodes = Array.from(
477+
root.querySelectorAll(
478+
[
479+
'input:not([type="hidden"]):not([disabled])',
480+
"textarea:not([disabled])",
481+
"select:not([disabled])",
482+
'[contenteditable="true"]',
483+
].join(","),
484+
),
485+
)
486+
return nodes.filter((el) => {
487+
if (!isVisible(el)) return false
488+
if (el.tagName === "INPUT") {
489+
const type = (el.getAttribute("type") || "text").toLowerCase()
490+
if (!TEXT_TYPES.has(type)) return false
491+
if (el.readOnly) return false
492+
}
493+
return true
494+
})
495+
}
496+
497+
function pickFocusTarget(form) {
498+
// explicit markers
499+
const explicit = form.querySelector("[autofocus], [data-autofocus]")
500+
if (explicit && isVisible(explicit)) return explicit
501+
502+
// 'title' or 'name'
503+
const all = listFocusable(form)
504+
const match = all.find((el) => {
505+
const id = (el.id || "").toLowerCase()
506+
const name = (el.name || "").toLowerCase()
507+
return id.includes("title") || name.includes("title") || id === "name" || name === "name"
508+
})
509+
return match || all[0] || null
510+
}
511+
512+
function focusWithRetries(el, attempt = 0) {
513+
if (!el || !isVisible(el)) {
514+
if (attempt === 0) console.log(NS, "No visible element to focus.")
515+
return
516+
}
517+
518+
// If Select2 hid the <select>, focus the visible selection
519+
if (el.classList.contains("select2-hidden-accessible")) {
520+
const s2 = el.nextElementSibling && el.nextElementSibling.querySelector(".select2-selection")
521+
if (s2) el = s2
522+
}
523+
524+
el.focus({ preventScroll: false })
525+
const ok = document.activeElement === el
526+
console.log(NS, `Focus attempt #${attempt + 1}:`, ok ? "OK" : "retry")
527+
if (!ok && attempt < 8) setTimeout(() => focusWithRetries(el, attempt + 1), 60)
528+
}
529+
530+
// Wait until element (or an ancestor) becomes visible
531+
function waitVisible(el, cb, opts = { timeout: 12000, poll: 120 }) {
532+
let done = false
533+
const t0 = Date.now()
534+
535+
const stop = () => {
536+
done = true
537+
try {
538+
mo.disconnect()
539+
} catch {}
540+
clearInterval(iv)
541+
}
542+
543+
const tryCall = () => {
544+
if (done) return
545+
if (isVisible(el)) {
546+
stop()
547+
cb()
548+
} else if (Date.now() - t0 > opts.timeout) {
549+
stop()
550+
console.warn(NS, "Timeout waiting for form visibility.")
551+
}
552+
}
553+
554+
// Observe style/class/DOM changes anywhere (subtree)
555+
const mo = new MutationObserver(tryCall)
556+
try {
557+
mo.observe(document.documentElement, {
558+
attributes: true,
559+
childList: true,
560+
subtree: true,
561+
attributeFilter: ["style", "class", "hidden", "open"],
562+
})
563+
} catch {}
564+
const iv = setInterval(tryCall, opts.poll)
565+
tryCall()
566+
}
567+
568+
// ---------- core ----------
569+
function bindEnterAndMaybeFocus(form) {
570+
if (!form || boundForms.has(form)) return
571+
boundForms.add(form)
572+
form.dataset.a11yBound = "1"
573+
574+
// Enter = submit (capture on the form)
575+
const onKey = (e) => {
576+
if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return
577+
const t = e.target
578+
if (!t) return
579+
// Exceptions
580+
if (t.tagName === "TEXTAREA" || t.isContentEditable) return
581+
if (t.closest('[data-enter="ignore"], [data-no-enter-submit]')) return
582+
if (t.type === "submit" || t.type === "button") return
583+
if (t.type === "checkbox" || t.type === "radio" || t.type === "file" || t.type === "range" || t.type === "color")
584+
return
585+
if (t.tagName === "SELECT" && t.multiple) return
586+
if (t.closest("form") !== form) return
587+
588+
e.preventDefault()
589+
if (typeof form.requestSubmit === "function") {
590+
form.requestSubmit()
591+
} else {
592+
const btn = form.querySelector('button[type="submit"], input[type="submit"]')
593+
btn ? btn.click() : form.submit()
594+
}
595+
}
596+
form.addEventListener("keydown", onKey, true)
597+
598+
// Focus only once per form unless you remove dataset flag
599+
if (form.dataset.noAutofocus === "1") {
600+
return
601+
}
602+
603+
const doFocus = () => {
604+
if (form.dataset.a11yFocusedOnce === "1") return
605+
// Prefer a form that is in/near viewport if many exist
606+
if (!inViewport(form) && document.querySelector("form[data-a11yFocusedOnce='1']")) {
607+
// another form already took focus earlier
608+
return
609+
}
610+
const target = pickFocusTarget(form)
611+
requestAnimationFrame(() => setTimeout(() => focusWithRetries(target), 0))
612+
form.dataset.a11yFocusedOnce = "1"
613+
}
614+
615+
if (isVisible(form)) {
616+
doFocus()
617+
} else {
618+
waitVisible(form, () => {
619+
doFocus()
620+
})
621+
}
622+
}
623+
624+
function scanAllForms() {
625+
const forms = Array.from(document.getElementsByTagName("form"))
626+
if (!forms.length) {
627+
return
628+
}
629+
const vis = forms.filter(isVisible).length
630+
forms.forEach(bindEnterAndMaybeFocus)
631+
}
632+
633+
// global observer: new forms added dynamically
634+
const globalObserver = new MutationObserver((muts) => {
635+
let touched = false
636+
for (const m of muts) {
637+
if (m.type === "childList") {
638+
if (m.addedNodes && m.addedNodes.length) {
639+
m.addedNodes.forEach((n) => {
640+
if (n.nodeType === 1 && (n.tagName === "FORM" || n.querySelector?.("form"))) {
641+
touched = true
642+
}
643+
})
644+
}
645+
}
646+
}
647+
if (touched) {
648+
scanAllForms()
649+
}
650+
})
651+
652+
try {
653+
globalObserver.observe(document.documentElement, { childList: true, subtree: true })
654+
} catch (_) {}
655+
656+
// Expose for manual trigger (debug)
657+
window.A11Y = {
658+
scanNow: scanAllForms,
659+
_debug: { isVisible, pickFocusTarget },
660+
}
661+
662+
// Auto-run (no manual activation needed)
663+
if (document.readyState === "complete" || document.readyState === "interactive") {
664+
setTimeout(scanAllForms, 0)
665+
} else {
666+
document.addEventListener("DOMContentLoaded", scanAllForms)
667+
}
668+
window.addEventListener("load", scanAllForms)
669+
670+
// Focus inside Bootstrap modals
671+
document.addEventListener("shown.bs.modal", (e) => {
672+
scanAllForms()
673+
})
674+
})()
675+
434676
function get_url_params(q, attribute) {
435677
var hash
436678
if (q != undefined) {

assets/vue/components/basecomponents/BaseInputText.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
<div class="field">
33
<div class="p-float-label">
44
<InputText
5+
v-bind="$attrs"
56
:id="id"
67
:aria-label="label"
78
:class="{ 'p-invalid': isInvalid, [inputClass]: true }"
89
:disabled="disabled"
10+
:required="required"
911
:model-value="modelValue"
1012
type="text"
1113
@update:model-value="updateValue"
@@ -32,10 +34,12 @@
3234
<script setup>
3335
import InputText from "primevue/inputtext"
3436
37+
defineOptions({ inheritAttrs: false })
38+
3539
defineProps({
3640
id: {
3741
type: String,
38-
require: true,
42+
required: true,
3943
default: "",
4044
},
4145
label: {

0 commit comments

Comments
 (0)