mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-24 08:16:51 -04:00
Merge pull request #3025 from GSA/fix/frontend-optimization
Optimizing FE further with dead code cleanup, documentation updates, removal of more convoluting dependencies
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
(function (window) {
|
||||
"use strict";
|
||||
|
||||
const USWDS = window.USWDS || {};
|
||||
|
||||
function Summary (module) {
|
||||
this.module = module;
|
||||
this.$el = module.$formGroup.find('.selection-summary').first();
|
||||
this.el = module.formGroup.querySelector('.selection-summary');
|
||||
this.fieldLabel = module.fieldLabel;
|
||||
this.total = module.total;
|
||||
this.addContent();
|
||||
@@ -20,14 +18,17 @@
|
||||
}[field] || `No ${field}s`)
|
||||
};
|
||||
Summary.prototype.addContent = function() {
|
||||
const $hint = this.module.$formGroup.find('.usa-hint');
|
||||
this.$text = $(`<p class="selection-summary__text" />`);
|
||||
const hint = this.module.formGroup.querySelector('.usa-hint');
|
||||
this.text = document.createElement('p');
|
||||
this.text.className = 'selection-summary__text';
|
||||
|
||||
if (this.fieldLabel === 'folder') { this.$text.addClass('selection-summary__text--folders'); }
|
||||
if (this.fieldLabel === 'folder') { this.text.classList.add('selection-summary__text--folders'); }
|
||||
|
||||
this.$el.attr('id', $hint.attr('id'));
|
||||
this.$el.append(this.$text);
|
||||
$hint.remove();
|
||||
if (hint) {
|
||||
this.el.setAttribute('id', hint.getAttribute('id'));
|
||||
hint.remove();
|
||||
}
|
||||
this.el.appendChild(this.text);
|
||||
};
|
||||
Summary.prototype.update = function(selection) {
|
||||
let template;
|
||||
@@ -40,19 +41,19 @@
|
||||
template = 'none';
|
||||
}
|
||||
|
||||
this.$text.html(this.templates[template](selection, this.total, this.fieldLabel));
|
||||
this.text.innerHTML = this.templates[template](selection, this.total, this.fieldLabel);
|
||||
};
|
||||
Summary.prototype.bindEvents = function () {
|
||||
// take summary out of tab order when focus moves
|
||||
this.$el.on('blur', (e) => $(this).attr('tabindex', '-1'));
|
||||
this.el.addEventListener('blur', (e) => e.target.setAttribute('tabindex', '-1'));
|
||||
};
|
||||
|
||||
function Footer (module) {
|
||||
this.module = module;
|
||||
this.fieldLabel = module.fieldLabel;
|
||||
this.fieldsetId = module.$fieldset.attr('id');
|
||||
this.$el = this.getEl(this.module.expanded);
|
||||
this.module.$formGroup.append(this.$el);
|
||||
this.fieldsetId = module.fieldset.getAttribute('id');
|
||||
this.el = this.getEl(this.module.expanded);
|
||||
this.module.formGroup.appendChild(this.el);
|
||||
}
|
||||
Footer.prototype.buttonContent = {
|
||||
change: (fieldLabel) => `Choose ${fieldLabel}s`,
|
||||
@@ -63,37 +64,37 @@
|
||||
const buttonContent = this.buttonContent[buttonState](this.fieldLabel);
|
||||
const stickyClass = expanded ? ' js-stick-at-bottom-when-scrolling' : '';
|
||||
|
||||
return $(`<div class="selection-footer${stickyClass} margin-top-2">
|
||||
<button
|
||||
const div = document.createElement('div');
|
||||
div.className = `selection-footer${stickyClass} margin-top-2`;
|
||||
div.innerHTML = `<button
|
||||
class="usa-button usa-button--outline selection-footer__button"
|
||||
aria-expanded="${expanded ? 'true' : 'false'}"
|
||||
aria-controls="${this.fieldsetId}">
|
||||
${buttonContent}
|
||||
</button>
|
||||
</div>`);
|
||||
</button>`;
|
||||
return div;
|
||||
};
|
||||
Footer.prototype.update = function (expanded) {
|
||||
this.$el.remove();
|
||||
this.$el = this.getEl(expanded);
|
||||
this.el.remove();
|
||||
this.el = this.getEl(expanded);
|
||||
|
||||
this.module.$formGroup.append(this.$el);
|
||||
this.module.formGroup.appendChild(this.el);
|
||||
|
||||
};
|
||||
|
||||
function CollapsibleCheckboxes () {}
|
||||
CollapsibleCheckboxes.prototype._focusTextElement = ($el) => {
|
||||
$el
|
||||
.attr('tabindex', '-1')
|
||||
.focus();
|
||||
CollapsibleCheckboxes.prototype._focusTextElement = (el) => {
|
||||
el.setAttribute('tabindex', '-1');
|
||||
el.focus();
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.start = function(component) {
|
||||
this.$component = $(component);
|
||||
this.$formGroup = this.$component.find('.usa-form-group').first();
|
||||
this.$fieldset = this.$formGroup.find('fieldset').first();
|
||||
this.$checkboxes = this.$fieldset.find('input[type=checkbox]');
|
||||
this.fieldLabel = this.$component.data('fieldLabel');
|
||||
this.total = this.$checkboxes.length;
|
||||
this.legendText = this.$fieldset.find('legend').first().text().trim();
|
||||
this.component = component;
|
||||
this.formGroup = component.querySelector('.usa-form-group');
|
||||
this.fieldset = this.formGroup.querySelector('fieldset');
|
||||
this.checkboxes = this.fieldset.querySelectorAll('input[type=checkbox]');
|
||||
this.fieldLabel = component.dataset.fieldLabel;
|
||||
this.total = this.checkboxes.length;
|
||||
this.legendText = this.fieldset.querySelector('legend').textContent.trim();
|
||||
this.expanded = false;
|
||||
this.checkUncheckButtonsAdded = false;
|
||||
|
||||
@@ -103,71 +104,78 @@
|
||||
this.footer = new Footer(this);
|
||||
this.summary = new Summary(this);
|
||||
|
||||
this.$fieldset.before(this.summary.$el);
|
||||
this.fieldset.insertAdjacentElement('beforebegin', this.summary.el);
|
||||
|
||||
// add custom classes
|
||||
this.$formGroup.addClass('selection-wrapper');
|
||||
this.$fieldset.addClass('selection-content');
|
||||
this.formGroup.classList.add('selection-wrapper');
|
||||
this.fieldset.classList.add('selection-content');
|
||||
|
||||
// hide checkboxes
|
||||
this.$fieldset.hide();
|
||||
this.fieldset.style.display = 'none';
|
||||
|
||||
this.bindEvents();
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.getSelection = function() { return this.$checkboxes.filter(':checked').length; };
|
||||
CollapsibleCheckboxes.prototype.getSelection = function() {
|
||||
return Array.from(this.checkboxes).filter(cb => cb.checked).length;
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.addHeadingHideLegend = function() {
|
||||
const headingLevel = this.$component.data('heading-level') || '2';
|
||||
const headingLevel = this.component.dataset.headingLevel || '2';
|
||||
|
||||
this.$heading = $(`<h${headingLevel} class="heading-small"></h${headingLevel}>`);
|
||||
this.$heading.text(this.legendText);
|
||||
this.$fieldset.before(this.$heading);
|
||||
this.heading = document.createElement(`h${headingLevel}`);
|
||||
this.heading.className = 'heading-small';
|
||||
this.heading.textContent = this.legendText;
|
||||
this.fieldset.insertAdjacentElement('beforebegin', this.heading);
|
||||
|
||||
this.$fieldset.find('legend').addClass('usa-sr-only');
|
||||
this.fieldset.querySelector('legend').classList.add('usa-sr-only');
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.addCheckUncheckAllButtons = function() {
|
||||
const $buttonsContainer = $('<div class="check-uncheck-all-buttons margin-bottom-2"></div>');
|
||||
const buttonsContainer = document.createElement('div');
|
||||
buttonsContainer.className = 'check-uncheck-all-buttons margin-bottom-2';
|
||||
|
||||
this.$toggleAllButton = $('<button type="button" class="usa-button usa-button--outline usa-button--small">Select all</button>');
|
||||
this.toggleAllButton = document.createElement('button');
|
||||
this.toggleAllButton.type = 'button';
|
||||
this.toggleAllButton.className = 'usa-button usa-button--outline usa-button--small';
|
||||
this.toggleAllButton.textContent = 'Select all';
|
||||
|
||||
$buttonsContainer.append(this.$toggleAllButton);
|
||||
buttonsContainer.appendChild(this.toggleAllButton);
|
||||
|
||||
this.summary.$el.after($buttonsContainer);
|
||||
this.summary.el.insertAdjacentElement('afterend', buttonsContainer);
|
||||
|
||||
this.$toggleAllButton.on('click', this.toggleAll.bind(this));
|
||||
this.toggleAllButton.addEventListener('click', this.toggleAll.bind(this));
|
||||
|
||||
this.updateToggleButtonText();
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.toggleAll = function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const allChecked = this.$checkboxes.filter(':checked').length === this.$checkboxes.length;
|
||||
const allChecked = Array.from(this.checkboxes).filter(cb => cb.checked).length === this.checkboxes.length;
|
||||
|
||||
if (allChecked) {
|
||||
this.$checkboxes.prop('checked', false);
|
||||
this.checkboxes.forEach(cb => cb.checked = false);
|
||||
} else {
|
||||
this.$checkboxes.prop('checked', true);
|
||||
this.checkboxes.forEach(cb => cb.checked = true);
|
||||
}
|
||||
|
||||
this.handleSelection();
|
||||
this.updateToggleButtonText();
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.updateToggleButtonText = function() {
|
||||
if (!this.$toggleAllButton) return;
|
||||
if (!this.toggleAllButton) return;
|
||||
|
||||
const checkedCount = this.$checkboxes.filter(':checked').length;
|
||||
const allChecked = checkedCount === this.$checkboxes.length;
|
||||
const checkedCount = Array.from(this.checkboxes).filter(cb => cb.checked).length;
|
||||
const allChecked = checkedCount === this.checkboxes.length;
|
||||
|
||||
if (allChecked) {
|
||||
this.$toggleAllButton.text('Deselect all');
|
||||
this.toggleAllButton.textContent = 'Deselect all';
|
||||
} else {
|
||||
this.$toggleAllButton.text('Select all');
|
||||
this.toggleAllButton.textContent = 'Select all';
|
||||
}
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.expand = function(e) {
|
||||
if (e !== undefined) { e.preventDefault(); }
|
||||
|
||||
if (!this.expanded) {
|
||||
this.$fieldset.show();
|
||||
this.fieldset.style.display = '';
|
||||
this.expanded = true;
|
||||
this.summary.update(this.getSelection());
|
||||
this.footer.update(this.expanded);
|
||||
@@ -176,31 +184,31 @@
|
||||
this.addCheckUncheckAllButtons();
|
||||
this.checkUncheckButtonsAdded = true;
|
||||
} else {
|
||||
if (this.$toggleAllButton) {
|
||||
this.$toggleAllButton.parent().show();
|
||||
if (this.toggleAllButton) {
|
||||
this.toggleAllButton.parentElement.style.display = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shift focus whether expanded or not
|
||||
this._focusTextElement(this.$fieldset);
|
||||
this._focusTextElement(this.fieldset);
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.collapse = function(e) {
|
||||
if (e !== undefined) { e.preventDefault(); }
|
||||
|
||||
if (this.expanded) {
|
||||
this.$fieldset.hide();
|
||||
this.fieldset.style.display = 'none';
|
||||
this.expanded = false;
|
||||
this.summary.update(this.getSelection());
|
||||
this.footer.update(this.expanded);
|
||||
|
||||
if (this.$toggleAllButton) {
|
||||
this.$toggleAllButton.parent().hide();
|
||||
if (this.toggleAllButton) {
|
||||
this.toggleAllButton.parentElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// shift focus whether expanded or not
|
||||
this._focusTextElement(this.summary.$text);
|
||||
this._focusTextElement(this.summary.text);
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.handleClick = function(e) {
|
||||
if (this.expanded) {
|
||||
@@ -214,10 +222,15 @@
|
||||
this.updateToggleButtonText();
|
||||
};
|
||||
CollapsibleCheckboxes.prototype.bindEvents = function() {
|
||||
const self = this;
|
||||
this.formGroup.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.usa-button')) {
|
||||
this.handleClick.call(this, e);
|
||||
}
|
||||
});
|
||||
|
||||
this.$formGroup.on('click', '.usa-button', this.handleClick.bind(this));
|
||||
this.$checkboxes.on('click', this.handleSelection.bind(this));
|
||||
this.checkboxes.forEach(cb => {
|
||||
cb.addEventListener('click', this.handleSelection.bind(this));
|
||||
});
|
||||
|
||||
this.summary.bindEvents(this);
|
||||
};
|
||||
|
||||
@@ -1,98 +1,70 @@
|
||||
(function(window) {
|
||||
"use strict";
|
||||
|
||||
if (!document.queryCommandSupported('copy')) return;
|
||||
// Only initialize if modern Clipboard API is available
|
||||
if (!navigator.clipboard) return;
|
||||
|
||||
window.NotifyModules['copy-to-clipboard'] = function() {
|
||||
|
||||
const states = {
|
||||
'valueVisible': (options) => `
|
||||
<span class="copy-to-clipboard__value margin-bottom-1">${options.valueLabel ? '<span class="usa-sr-only">' + options.thing + ': </span>' : ''}${options.value}</span>
|
||||
<span class="copy-to-clipboard__notice" aria-live="assertive">
|
||||
${options.onload ? '' : options.thing + ' returned to page, press button to copy to clipboard'}
|
||||
</span>
|
||||
<button class="usa-button usa-button--outline copy-to-clipboard__button--copy">
|
||||
Copy ${options.thing} to clipboard${options.name ? '<span class="usa-sr-only"> for ' + options.name + '</span>' : ''}
|
||||
</button>
|
||||
`,
|
||||
'valueCopied': (options) => `
|
||||
<span class="copy-to-clipboard__notice" aria-live="assertive">
|
||||
<span class="usa-sr-only">${options.thing} </span>Copied to clipboard<span class="usa-sr-only">, press button to show in page</span>
|
||||
</span>
|
||||
<button class="usa-button copy-to-clipboard__button--show">
|
||||
Show ${options.thing}${options.name ? '<span class="usa-sr-only"> for ' + options.name + '</span>' : ''}
|
||||
</button>
|
||||
`
|
||||
};
|
||||
|
||||
this.getRangeFromElement = function (copyableElement) {
|
||||
const range = document.createRange();
|
||||
const childNodes = Array.prototype.slice.call(copyableElement.childNodes);
|
||||
let prefixIndex = -1;
|
||||
|
||||
childNodes.forEach((el, idx) => {
|
||||
if ((el.nodeType === 1) && el.classList.contains('usa-sr-only')) {
|
||||
prefixIndex = idx;
|
||||
}
|
||||
});
|
||||
|
||||
range.selectNodeContents(copyableElement);
|
||||
if (prefixIndex !== -1) { range.setStart(copyableElement, prefixIndex + 1); }
|
||||
|
||||
return range;
|
||||
};
|
||||
|
||||
this.copyValueToClipboard = function(copyableElement, callback) {
|
||||
var selection = window.getSelection ? window.getSelection() : document.selection,
|
||||
range = this.getRangeFromElement(copyableElement);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
document.execCommand('copy');
|
||||
selection.removeAllRanges();
|
||||
callback();
|
||||
/**
|
||||
* Copy text to clipboard using modern Clipboard API
|
||||
* @param {string} text - The text to copy
|
||||
* @param {Function} callback - Called after successful copy
|
||||
*/
|
||||
this.copyValueToClipboard = async function(text, callback) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
callback();
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.start = function(component) {
|
||||
|
||||
const $component = $(component),
|
||||
stateOptions = {
|
||||
value: $component.data('value'),
|
||||
thing: $component.data('thing')
|
||||
},
|
||||
name = $component.data('name');
|
||||
const value = component.dataset.value;
|
||||
const thing = component.dataset.thing;
|
||||
const name = component.dataset.name;
|
||||
|
||||
// if the name is distinct from the thing:
|
||||
// - it will be used in the rendering
|
||||
// - the value won't be identified by a heading so needs its own label
|
||||
if (name !== stateOptions.thing) {
|
||||
stateOptions.name = name;
|
||||
stateOptions.valueLabel = true;
|
||||
}
|
||||
// Determine button label
|
||||
const isMultiple = name !== thing;
|
||||
const buttonLabel = isMultiple
|
||||
? `Copy ${thing}`
|
||||
: `Copy ${thing} to clipboard`;
|
||||
const srSuffix = isMultiple ? ` for ${name}` : '';
|
||||
|
||||
$component
|
||||
.addClass('copy-to-clipboard')
|
||||
.css('min-height', $component.height())
|
||||
.html(states.valueVisible($.extend({ 'onload': true }, stateOptions)))
|
||||
.on(
|
||||
'click', '.copy-to-clipboard__button--copy', () =>
|
||||
this.copyValueToClipboard(
|
||||
$('.copy-to-clipboard__value', component)[0], () =>
|
||||
$component
|
||||
.html(states.valueCopied(stateOptions))
|
||||
.find('.usa-button').focus()
|
||||
)
|
||||
)
|
||||
.on(
|
||||
'click', '.copy-to-clipboard__button--show', () =>
|
||||
$component
|
||||
.html(states.valueVisible(stateOptions))
|
||||
.find('.usa-button').focus()
|
||||
);
|
||||
// Create simple HTML structure
|
||||
component.classList.add('copy-to-clipboard');
|
||||
component.innerHTML = `
|
||||
<div class="copy-to-clipboard__value">${value}</div>
|
||||
<button class="usa-button usa-button--outline copy-to-clipboard__button" type="button">
|
||||
${buttonLabel}<span class="usa-sr-only">${srSuffix}</span>
|
||||
</button>
|
||||
<span class="usa-sr-only" aria-live="polite" aria-atomic="true"></span>
|
||||
`;
|
||||
|
||||
if ('stickAtBottomWhenScrolling' in window.NotifyModules) {
|
||||
window.NotifyModules.stickAtBottomWhenScrolling.recalculate();
|
||||
}
|
||||
const button = component.querySelector('.copy-to-clipboard__button');
|
||||
const srAnnouncement = component.querySelector('[aria-live]');
|
||||
|
||||
// Handle copy button click
|
||||
button.addEventListener('click', () => {
|
||||
this.copyValueToClipboard(value, () => {
|
||||
// Change button text to "Copied!"
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = `Copied!<span class="usa-sr-only">${srSuffix}</span>`;
|
||||
button.disabled = true;
|
||||
|
||||
// Announce to screen readers
|
||||
srAnnouncement.textContent = `${thing} copied to clipboard`;
|
||||
|
||||
// Reset button after 2 seconds
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.disabled = false;
|
||||
srAnnouncement.textContent = '';
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,61 +11,71 @@
|
||||
|
||||
this.start = function(element) {
|
||||
|
||||
let textarea = $(element);
|
||||
let textarea = element;
|
||||
let visibleTextbox;
|
||||
|
||||
this.highlightPlaceholders = (
|
||||
typeof textarea.data('highlightPlaceholders') === 'undefined' ||
|
||||
!!textarea.data('highlightPlaceholders')
|
||||
typeof textarea.dataset.highlightPlaceholders === 'undefined' ||
|
||||
textarea.dataset.highlightPlaceholders !== 'false'
|
||||
);
|
||||
|
||||
this.$textbox = textarea
|
||||
.wrap(`
|
||||
<div class='textbox-highlight-wrapper' />
|
||||
`)
|
||||
.after(this.$background = $(`
|
||||
<div class="textbox-highlight-background" aria-hidden="true" />
|
||||
`))
|
||||
.on("input", this.update);
|
||||
// Create wrapper div
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'textbox-highlight-wrapper';
|
||||
|
||||
$(window).on("resize", this.resize);
|
||||
// Insert wrapper before textarea and move textarea into it
|
||||
textarea.parentNode.insertBefore(wrapper, textarea);
|
||||
wrapper.appendChild(textarea);
|
||||
|
||||
visibleTextbox = this.$textbox.clone().appendTo("body").css({
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
display: 'block'
|
||||
});
|
||||
this.initialHeight = visibleTextbox.height();
|
||||
// Create background div
|
||||
this.background = document.createElement('div');
|
||||
this.background.className = 'textbox-highlight-background';
|
||||
this.background.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.$background.css({
|
||||
'border-width': this.$textbox.css('border-width')
|
||||
});
|
||||
// Insert background after textarea
|
||||
textarea.parentNode.insertBefore(this.background, textarea.nextSibling);
|
||||
|
||||
this.textbox = textarea;
|
||||
|
||||
this.textbox.addEventListener("input", this.update);
|
||||
window.addEventListener("resize", this.resize);
|
||||
|
||||
// Clone textbox to measure initial height
|
||||
visibleTextbox = this.textbox.cloneNode(true);
|
||||
visibleTextbox.style.position = 'absolute';
|
||||
visibleTextbox.style.visibility = 'hidden';
|
||||
visibleTextbox.style.display = 'block';
|
||||
document.body.appendChild(visibleTextbox);
|
||||
|
||||
this.initialHeight = visibleTextbox.offsetHeight;
|
||||
|
||||
const borderWidth = window.getComputedStyle(this.textbox).borderWidth;
|
||||
this.background.style.borderWidth = borderWidth;
|
||||
|
||||
visibleTextbox.remove();
|
||||
|
||||
this.$textbox
|
||||
.trigger("input");
|
||||
this.textbox.dispatchEvent(new Event("input"));
|
||||
|
||||
};
|
||||
|
||||
this.resize = () => {
|
||||
|
||||
this.$background.width(this.$textbox.width());
|
||||
const computedStyle = window.getComputedStyle(this.textbox);
|
||||
const width = parseFloat(computedStyle.width);
|
||||
this.background.style.width = width + 'px';
|
||||
|
||||
this.$textbox.height(
|
||||
Math.max(
|
||||
this.initialHeight,
|
||||
this.$background.outerHeight()
|
||||
)
|
||||
);
|
||||
const backgroundHeight = this.background.offsetHeight;
|
||||
|
||||
this.textbox.style.height = Math.max(this.initialHeight, backgroundHeight) + 'px';
|
||||
|
||||
if ('stickAtBottomWhenScrolling' in window.NotifyModules) {
|
||||
window.NotifyModules.stickAtBottomWhenScrolling.recalculate();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
this.contentEscaped = () => $('<div/>').text(this.$textbox.val()).html();
|
||||
this.contentEscaped = () => {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = this.textbox.value;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
this.contentReplaced = () => this.contentEscaped().replace(
|
||||
tagPattern, (match, name, separator, value) => value && separator ?
|
||||
@@ -75,9 +85,8 @@
|
||||
|
||||
this.update = () => {
|
||||
|
||||
this.$background.html(
|
||||
this.highlightPlaceholders ? this.contentReplaced() : this.contentEscaped()
|
||||
);
|
||||
this.background.innerHTML =
|
||||
this.highlightPlaceholders ? this.contentReplaced() : this.contentEscaped();
|
||||
|
||||
this.resize();
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export const ErrorBanner = {
|
||||
hideBanner: () => $('.banner-dangerous').addClass('display-none'),
|
||||
showBanner: () => $('.banner-dangerous').removeClass('display-none')
|
||||
hideBanner: () => {
|
||||
document.querySelectorAll('.banner-dangerous').forEach(el => el.classList.add('display-none'));
|
||||
},
|
||||
showBanner: () => {
|
||||
document.querySelectorAll('.banner-dangerous').forEach(el => el.classList.remove('display-none'));
|
||||
}
|
||||
};
|
||||
|
||||
window.NotifyModules = window.NotifyModules || {};
|
||||
|
||||
@@ -4,15 +4,13 @@
|
||||
window.NotifyModules['track-error'] = function() {
|
||||
|
||||
this.start = function(element) {
|
||||
var component = $(element);
|
||||
|
||||
// Track error to analytics if available
|
||||
if (window.NotifyModules && window.NotifyModules.analytics && window.NotifyModules.analytics.trackEvent) {
|
||||
window.NotifyModules.analytics.trackEvent(
|
||||
'Error',
|
||||
component.data('error-type'),
|
||||
element.dataset.errorType,
|
||||
{
|
||||
'label': component.data('error-label')
|
||||
'label': element.dataset.errorLabel
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,30 +28,38 @@ function initUploadStatusAnnouncer() {
|
||||
"use strict";
|
||||
|
||||
window.NotifyModules['file-upload'] = function() {
|
||||
this.submit = () => this.$form.trigger('submit');
|
||||
this.submit = () => this.form.submit();
|
||||
|
||||
this.showCancelButton = () => {
|
||||
$('.file-upload-button', this.$form).replaceWith(`
|
||||
<button class='usa-button uploading-button' aria-disabled="true" tabindex="0">
|
||||
Uploading<span class="loading-spinner" role="status" aria-label="Uploading"></span>
|
||||
</button>
|
||||
`);
|
||||
const uploadButton = this.form.querySelector('.file-upload-button');
|
||||
if (uploadButton) {
|
||||
uploadButton.outerHTML = `
|
||||
<button class='usa-button uploading-button' aria-disabled="true" tabindex="0">
|
||||
Uploading<span class="loading-spinner" role="status" aria-label="Uploading"></span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
this.start = function(component) {
|
||||
this.$form = $(component);
|
||||
this.form = component;
|
||||
|
||||
this.$form.on('click', '[data-module="upload-trigger"]', function () {
|
||||
const inputId = $(this).data('file-input-id');
|
||||
const fileInput = document.getElementById(inputId);
|
||||
if (fileInput) fileInput.click();
|
||||
this.form.addEventListener('click', (event) => {
|
||||
const trigger = event.target.closest('[data-module="upload-trigger"]');
|
||||
if (trigger) {
|
||||
const inputId = trigger.dataset.fileInputId;
|
||||
const fileInput = document.getElementById(inputId);
|
||||
if (fileInput) fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
$(window).on("pageshow", () => this.$form[0].reset());
|
||||
window.addEventListener("pageshow", () => this.form.reset());
|
||||
|
||||
this.$form.on('change', '.file-upload-field', () => {
|
||||
this.submit();
|
||||
this.showCancelButton();
|
||||
this.form.addEventListener('change', (event) => {
|
||||
if (event.target.closest('.file-upload-field')) {
|
||||
this.submit();
|
||||
this.showCancelButton();
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Webpack Entry Point
|
||||
|
||||
// Vendor libraries
|
||||
import $ from 'jquery';
|
||||
window.jQuery = window.$ = $;
|
||||
|
||||
import './jquery-expose.js';
|
||||
import 'query-command-supported';
|
||||
import 'textarea-caret';
|
||||
import * as cbor from 'cbor-js';
|
||||
@@ -74,11 +70,9 @@ import './fileUpload.js';
|
||||
import './errorTracking.js';
|
||||
import './templateFolderForm.js';
|
||||
import './collapsibleCheckboxes.js';
|
||||
import './radioSlider.js';
|
||||
import './updateStatus.js';
|
||||
import './main.js';
|
||||
import './listEntry.js';
|
||||
import './stick-to-window-when-scrolling.js';
|
||||
import './totalMessagesChart.js';
|
||||
import './activityChart.js';
|
||||
import './job-polling.js';
|
||||
|
||||
7
app/assets/javascripts/jquery-expose.js
vendored
7
app/assets/javascripts/jquery-expose.js
vendored
@@ -1,7 +0,0 @@
|
||||
// Expose jQuery and other libraries to window for legacy code
|
||||
if (typeof jQuery !== 'undefined') {
|
||||
window.jQuery = window.$ = jQuery;
|
||||
}
|
||||
if (typeof getCaretCoordinates !== 'undefined') {
|
||||
window.getCaretCoordinates = getCaretCoordinates;
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
// This module creates dynamic add/remove input lists. It lets users add multiple entries
|
||||
// (like email addresses or domains) with "Add another" and "Remove" buttons. It handles
|
||||
// min/max entry limits, focus management, and preserves input attributes.
|
||||
// We're keeping this for now but it's not currently being used in the application.
|
||||
|
||||
var Modules = window.NotifyModules;
|
||||
var lists = [],
|
||||
listEntry,
|
||||
ListEntry;
|
||||
|
||||
ListEntry = function (elm) {
|
||||
var $elm = $(elm),
|
||||
idPattern = $elm.prop('id');
|
||||
var idPattern = elm.id;
|
||||
|
||||
if (!idPattern) { return false; }
|
||||
if (!idPattern) {
|
||||
return false;
|
||||
}
|
||||
this.idPattern = idPattern;
|
||||
this.elementSelector = '.list-entry, .input-list__button--remove, .input-list__button--add';
|
||||
this.entries = [];
|
||||
this.$wrapper = $elm;
|
||||
this.wrapper = elm;
|
||||
this.minEntries = 2;
|
||||
this.listItemName = this.$wrapper.data('listItemName');
|
||||
this.listItemName = this.wrapper.dataset.listItemName;
|
||||
this.getSharedAttributes();
|
||||
this.getOriginalClasses();
|
||||
|
||||
@@ -26,33 +32,43 @@
|
||||
this.render();
|
||||
this.bindEvents();
|
||||
};
|
||||
ListEntry.optionalAttributes = ['aria-describedby'];
|
||||
ListEntry.escapeHtml = function(unsafe) {
|
||||
if (!unsafe) return '';
|
||||
return String(unsafe)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
ListEntry.prototype.renderEntry = function(data) {
|
||||
var escapeHtml = ListEntry.escapeHtml;
|
||||
return `
|
||||
<div class="list-entry">
|
||||
<label for="${data.id}" class="usa-label">
|
||||
<span class="usa-sr-only">${data.listItemName} number </span>${data.number}.
|
||||
<label for="${escapeHtml(data.id)}" class="usa-label">
|
||||
<span class="usa-sr-only">${escapeHtml(data.listItemName)} number </span>${escapeHtml(data.number)}.
|
||||
</label>
|
||||
<input
|
||||
class="usa-input ${data.classes || ''}"
|
||||
name="${data.name}"
|
||||
id="${data.id}"
|
||||
${data.value ? `value="${data.value}"` : ''}
|
||||
class="usa-input ${escapeHtml(data.classes || '')}"
|
||||
name="${escapeHtml(data.name)}"
|
||||
id="${escapeHtml(data.id)}"
|
||||
${data.value ? `value="${escapeHtml(data.value)}"` : ''}
|
||||
${data.sharedAttributes}
|
||||
/>
|
||||
${data.button ? `
|
||||
<button type="button" class="usa-button usa-button--unstyled input-list__button--remove">
|
||||
Remove<span class="usa-sr-only"> ${data.listItemName} number ${data.number}</span>
|
||||
Remove<span class="usa-sr-only"> ${escapeHtml(data.listItemName)} number ${escapeHtml(data.number)}</span>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
ListEntry.prototype.renderAddButton = function(data) {
|
||||
return `<button type="button" class="usa-button usa-button--outline input-list__button--add margin-top-4">Add another ${data.listItemName} (${data.entriesLeft} remaining)</button>`;
|
||||
var escapeHtml = ListEntry.escapeHtml;
|
||||
return `<button type="button" class="usa-button usa-button--outline input-list__button--add margin-top-4">Add another ${escapeHtml(data.listItemName)} (${escapeHtml(data.entriesLeft)} remaining)</button>`;
|
||||
};
|
||||
ListEntry.prototype.getSharedAttributes = function () {
|
||||
var $inputs = this.$wrapper.find('input'),
|
||||
var inputs = this.wrapper.querySelectorAll('input'),
|
||||
generatedAttributes = ['id', 'name', 'value', 'class'],
|
||||
attributes = [],
|
||||
attrIdx,
|
||||
@@ -64,15 +80,16 @@
|
||||
elmIdx = attrsByElm.length,
|
||||
existingAttributes = [],
|
||||
elmAttrs,
|
||||
attrIdx;
|
||||
attrIdx,
|
||||
escapeHtml = ListEntry.escapeHtml;
|
||||
|
||||
while (elmIdx--) {
|
||||
elmAttrs = attrsByElm[elmIdx];
|
||||
attrIdx = elmAttrs.length;
|
||||
while (attrIdx--) {
|
||||
// prevent duplicates
|
||||
if ($.inArray(elmAttrs[attrIdx].name, existingAttributes) === -1) {
|
||||
attrStr += ` ${elmAttrs[attrIdx].name}="${elmAttrs[attrIdx].value}"`;
|
||||
if (existingAttributes.indexOf(elmAttrs[attrIdx].name) === -1) {
|
||||
attrStr += ` ${escapeHtml(elmAttrs[attrIdx].name)}="${escapeHtml(elmAttrs[attrIdx].value)}"`;
|
||||
existingAttributes.push(elmAttrs[attrIdx].name);
|
||||
}
|
||||
}
|
||||
@@ -80,11 +97,11 @@
|
||||
return attrStr;
|
||||
};
|
||||
|
||||
$inputs.each(function (idx, elm) {
|
||||
inputs.forEach(function (elm) {
|
||||
attrIdx = elm.attributes.length;
|
||||
elmAttributes = [];
|
||||
while(attrIdx--) {
|
||||
if ($.inArray(elm.attributes[attrIdx].name, generatedAttributes) === -1) {
|
||||
if (generatedAttributes.indexOf(elm.attributes[attrIdx].name) === -1) {
|
||||
elmAttributes.push({
|
||||
'name': elm.attributes[attrIdx].name,
|
||||
'value': elm.attributes[attrIdx].value
|
||||
@@ -99,9 +116,9 @@
|
||||
this.sharedAttributes = (attributes.length) ? getAttributesHTML(attributes) : '';
|
||||
};
|
||||
ListEntry.prototype.getOriginalClasses = function () {
|
||||
var $firstInput = this.$wrapper.find('input').first();
|
||||
if ($firstInput.length) {
|
||||
var classList = $firstInput.attr('class');
|
||||
var firstInput = this.wrapper.querySelector('input');
|
||||
if (firstInput) {
|
||||
var classList = firstInput.getAttribute('class');
|
||||
if (classList) {
|
||||
// Preserve any additional classes from the original input
|
||||
this.additionalClasses = classList;
|
||||
@@ -114,8 +131,8 @@
|
||||
};
|
||||
ListEntry.prototype.getValues = function () {
|
||||
this.entries = [];
|
||||
this.$wrapper.find('input').each(function (idx, elm) {
|
||||
var val = $(elm).val();
|
||||
this.wrapper.querySelectorAll('input').forEach(function (elm) {
|
||||
var val = elm.value;
|
||||
|
||||
this.entries.push(val);
|
||||
}.bind(this));
|
||||
@@ -144,11 +161,12 @@
|
||||
}
|
||||
};
|
||||
ListEntry.prototype.bindEvents = function () {
|
||||
this.$wrapper.on('click', '.input-list__button--remove', function (e) {
|
||||
this.removeEntry($(e.target));
|
||||
}.bind(this));
|
||||
this.$wrapper.on('click', '.input-list__button--add', function (e) {
|
||||
this.addEntry();
|
||||
this.wrapper.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.input-list__button--remove')) {
|
||||
this.removeEntry(e.target);
|
||||
} else if (e.target.closest('.input-list__button--add')) {
|
||||
this.addEntry();
|
||||
}
|
||||
}.bind(this));
|
||||
};
|
||||
ListEntry.prototype.shiftFocus = function (opts) {
|
||||
@@ -159,7 +177,13 @@
|
||||
} else { // opts.action === 'add'
|
||||
numberTargeted = opts.entryNumberFocused + 1;
|
||||
}
|
||||
this.$wrapper.find('.list-entry').eq(numberTargeted - 1).find('input').focus();
|
||||
var entries = this.wrapper.querySelectorAll('.list-entry');
|
||||
if (entries[numberTargeted - 1]) {
|
||||
var input = entries[numberTargeted - 1].querySelector('input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
ListEntry.prototype.removeEntryFromEntries = function (entryNumber) {
|
||||
var idx,
|
||||
@@ -173,7 +197,7 @@
|
||||
}
|
||||
this.entries = newEntries;
|
||||
};
|
||||
ListEntry.prototype.addEntry = function ($removeButton) {
|
||||
ListEntry.prototype.addEntry = function () {
|
||||
var currentLastEntryNumber = this.entries.length;
|
||||
|
||||
this.getValues();
|
||||
@@ -181,8 +205,23 @@
|
||||
this.render();
|
||||
this.shiftFocus({ 'action' : 'add', 'entryNumberFocused' : currentLastEntryNumber });
|
||||
};
|
||||
ListEntry.prototype.removeEntry = function ($removeButton) {
|
||||
var entryNumber = parseInt($removeButton.find('span').text().match(/\d+/)[0], 10);
|
||||
ListEntry.prototype.removeEntry = function (removeButton) {
|
||||
var button = removeButton.closest('.input-list__button--remove');
|
||||
if (!button) {
|
||||
console.error('ListEntry: Remove button not found');
|
||||
return;
|
||||
}
|
||||
var span = button.querySelector('span');
|
||||
if (!span) {
|
||||
console.error('ListEntry: Entry number span not found in remove button');
|
||||
return;
|
||||
}
|
||||
var match = span.textContent.match(/\d+/);
|
||||
if (!match) {
|
||||
console.error('ListEntry: Could not find entry number in remove button');
|
||||
return;
|
||||
}
|
||||
var entryNumber = parseInt(match[0], 10);
|
||||
|
||||
this.getValues();
|
||||
this.removeEntryFromEntries(entryNumber);
|
||||
@@ -190,8 +229,10 @@
|
||||
this.shiftFocus({ 'action' : 'remove', 'entryNumberFocused' : entryNumber });
|
||||
};
|
||||
ListEntry.prototype.render = function () {
|
||||
this.$wrapper.find(this.elementSelector).remove();
|
||||
$.each(this.entries, function (idx, entry) {
|
||||
this.wrapper.querySelectorAll(this.elementSelector).forEach(function(el) {
|
||||
el.remove();
|
||||
});
|
||||
this.entries.forEach(function (entry, idx) {
|
||||
var entryNumber = idx + 1,
|
||||
dataObj = {
|
||||
'id' : this.getId(entryNumber),
|
||||
@@ -207,10 +248,10 @@
|
||||
if (entryNumber > 1) {
|
||||
dataObj.button = true;
|
||||
}
|
||||
this.$wrapper.append(this.renderEntry(dataObj));
|
||||
this.wrapper.insertAdjacentHTML('beforeend', this.renderEntry(dataObj));
|
||||
}.bind(this));
|
||||
if (this.entries.length < this.maxEntries) {
|
||||
this.$wrapper.append(this.renderAddButton({
|
||||
this.wrapper.insertAdjacentHTML('beforeend', this.renderAddButton({
|
||||
'listItemName' : this.listItemName,
|
||||
'entriesLeft' : (this.maxEntries - this.entries.length)
|
||||
}));
|
||||
@@ -219,7 +260,7 @@
|
||||
|
||||
Modules['list-entry'] = function () {
|
||||
|
||||
this.start = component => lists.push(new ListEntry($(component)));
|
||||
this.start = component => lists.push(new ListEntry(component));
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -12,24 +12,25 @@
|
||||
}
|
||||
};
|
||||
|
||||
let filter = ($searchBox, $searchLabel, $liveRegion, $targets) => () => {
|
||||
let filter = (searchBox, searchLabel, liveRegion, targets) => () => {
|
||||
|
||||
let query = normalize($searchBox.val());
|
||||
let query = normalize(searchBox.value);
|
||||
let results = 0;
|
||||
let $noResultsMessage = $('.js-live-search-no-results');
|
||||
let noResultsMessage = document.querySelector('.js-live-search-no-results');
|
||||
|
||||
$targets.each(function() {
|
||||
targets.forEach(function(target) {
|
||||
|
||||
let content = $('.live-search-relevant', this).text() || $(this).text();
|
||||
let relevantElement = target.querySelector('.live-search-relevant');
|
||||
let content = relevantElement ? relevantElement.textContent : target.textContent;
|
||||
|
||||
if ($(this).has(':checked').length) {
|
||||
$(this).show();
|
||||
if (target.querySelector(':checked')) {
|
||||
target.classList.remove('js-hidden');
|
||||
results++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (query == '') {
|
||||
$(this).removeClass('js-hidden');
|
||||
target.classList.remove('js-hidden');
|
||||
results++;
|
||||
return;
|
||||
}
|
||||
@@ -37,34 +38,31 @@
|
||||
let isMatch = normalize(content).includes(normalize(query));
|
||||
|
||||
if (isMatch) {
|
||||
$(this).removeClass('js-hidden');
|
||||
target.classList.remove('js-hidden');
|
||||
results++;
|
||||
} else {
|
||||
$(this).addClass('js-hidden');
|
||||
target.classList.add('js-hidden');
|
||||
}
|
||||
});
|
||||
|
||||
if (query !== '' && results === 0) {
|
||||
$noResultsMessage.removeClass('js-hidden');
|
||||
} else {
|
||||
$noResultsMessage.addClass('js-hidden');
|
||||
if (noResultsMessage) {
|
||||
if (query !== '' && results === 0) {
|
||||
noResultsMessage.classList.remove('js-hidden');
|
||||
} else {
|
||||
noResultsMessage.classList.add('js-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'loaded') {
|
||||
if (query !== '') {
|
||||
$searchBox.attr('aria-label', $searchLabel.text().trim() + ', ' + resultsSummary(results));
|
||||
searchBox.setAttribute('aria-label', searchLabel.textContent.trim() + ', ' + resultsSummary(results));
|
||||
}
|
||||
state = 'active';
|
||||
} else {
|
||||
$searchBox.removeAttr('aria-label');
|
||||
$liveRegion.text(resultsSummary(results));
|
||||
searchBox.removeAttribute('aria-label');
|
||||
liveRegion.textContent = resultsSummary(results);
|
||||
}
|
||||
|
||||
// make sticky JS recalculate its cache of the element's position
|
||||
// because live search can change the height document
|
||||
if (window.NotifyModules && 'stickAtBottomWhenScrolling' in window.NotifyModules) {
|
||||
window.NotifyModules.stickAtBottomWhenScrolling.recalculate();
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -73,22 +71,24 @@
|
||||
|
||||
this.start = function(component) {
|
||||
|
||||
let $component = $(component);
|
||||
let searchBox = component.querySelector('input');
|
||||
let searchLabel = component.querySelector('label');
|
||||
let liveRegion = component.querySelector('.live-search__status');
|
||||
|
||||
let $searchBox = $('input', $component);
|
||||
let $searchLabel = $('label', $component);
|
||||
let $liveRegion = $('.live-search__status', $component);
|
||||
let targetsSelector = component.dataset.targets;
|
||||
let targets = Array.from(document.querySelectorAll(targetsSelector));
|
||||
|
||||
let filterFunc = filter(
|
||||
$searchBox,
|
||||
$searchLabel,
|
||||
$liveRegion,
|
||||
$($component.data('targets'))
|
||||
searchBox,
|
||||
searchLabel,
|
||||
liveRegion,
|
||||
targets
|
||||
);
|
||||
|
||||
state = 'loaded';
|
||||
|
||||
$searchBox.on('keyup input', filterFunc);
|
||||
searchBox.addEventListener('keyup', filterFunc);
|
||||
searchBox.addEventListener('input', filterFunc);
|
||||
|
||||
filterFunc();
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
var $ = window.jQuery;
|
||||
|
||||
// this javascript could be removed in the future since it is only being used
|
||||
// on the page "send-files-by-email". which is not currently in use.
|
||||
function ShowHideContent () {
|
||||
var self = this;
|
||||
var eventHandlers = new Map();
|
||||
|
||||
var selectors = {
|
||||
namespace: 'ShowHideContent',
|
||||
@@ -12,126 +13,159 @@
|
||||
checkbox: '[data-target] > input[type="checkbox"]'
|
||||
};
|
||||
|
||||
function initToggledContent () {
|
||||
var $control = $(this);
|
||||
var $content = getToggledContent($control);
|
||||
function initToggledContent (control) {
|
||||
var content = getToggledContent(control);
|
||||
|
||||
if ($content.length) {
|
||||
$control.attr('aria-controls', $content.attr('id'));
|
||||
$control.attr('aria-expanded', 'false');
|
||||
$content.attr('aria-hidden', 'true');
|
||||
if (content) {
|
||||
control.setAttribute('aria-controls', content.getAttribute('id'));
|
||||
control.setAttribute('aria-expanded', 'false');
|
||||
content.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function getToggledContent ($control) {
|
||||
function getToggledContent (control) {
|
||||
try {
|
||||
var id = $control.attr('aria-controls');
|
||||
var id = control.getAttribute('aria-controls');
|
||||
|
||||
if (!id) {
|
||||
id = $control.closest('[data-target]').data('target');
|
||||
var parent = control.closest('[data-target]');
|
||||
id = parent ? parent.dataset.target : null;
|
||||
}
|
||||
|
||||
if (!id || !/^[\w-]+$/.test(id)) {
|
||||
console.warn('Invalid element ID:', id);
|
||||
return $();
|
||||
return null;
|
||||
}
|
||||
|
||||
return $('#' + id);
|
||||
return document.getElementById(id);
|
||||
} catch (error) {
|
||||
console.error('Error getting toggled content:', error);
|
||||
return $();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showToggledContent ($control, $content) {
|
||||
if ($content.hasClass('display-none')) {
|
||||
$content.removeClass('display-none');
|
||||
$content.attr('aria-hidden', 'false');
|
||||
function showToggledContent (control, content) {
|
||||
if (content.classList.contains('display-none')) {
|
||||
content.classList.remove('display-none');
|
||||
content.setAttribute('aria-hidden', 'false');
|
||||
|
||||
if ($control.attr('aria-controls')) {
|
||||
$control.attr('aria-expanded', 'true');
|
||||
if (control.getAttribute('aria-controls')) {
|
||||
control.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideToggledContent ($control, $content) {
|
||||
$content.addClass('display-none');
|
||||
$content.attr('aria-hidden', 'true');
|
||||
function hideToggledContent (control, content) {
|
||||
content.classList.add('display-none');
|
||||
content.setAttribute('aria-hidden', 'true');
|
||||
|
||||
if ($control.attr('aria-controls')) {
|
||||
$control.attr('aria-expanded', 'false');
|
||||
if (control.getAttribute('aria-controls')) {
|
||||
control.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRadioContent ($control, $content) {
|
||||
var selector = selectors.radio + '[name=' + escapeElementName($control.attr('name')) + ']';
|
||||
var $radios = $(selector);
|
||||
function handleRadioContent (control, content) {
|
||||
var selector = selectors.radio + '[name=' + escapeElementName(control.getAttribute('name')) + ']';
|
||||
var radios = document.querySelectorAll(selector);
|
||||
|
||||
$radios.each(function () {
|
||||
hideToggledContent($(this), getToggledContent($(this)));
|
||||
radios.forEach(function (radio) {
|
||||
var radioContent = getToggledContent(radio);
|
||||
if (radioContent) {
|
||||
hideToggledContent(radio, radioContent);
|
||||
}
|
||||
});
|
||||
|
||||
showToggledContent($control, $content);
|
||||
if (content) {
|
||||
showToggledContent(control, content);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckboxContent ($control, $content) {
|
||||
if ($control.is(':checked')) {
|
||||
showToggledContent($control, $content);
|
||||
function handleCheckboxContent (control, content) {
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
if (control.checked) {
|
||||
showToggledContent(control, content);
|
||||
} else {
|
||||
hideToggledContent($control, $content);
|
||||
hideToggledContent(control, content);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeElementName (str) {
|
||||
// First escape backslashes, then escape other special characters
|
||||
// This prevents double-escaping issues identified by CodeQL
|
||||
return str
|
||||
? str.replace(/\\/g, '\\\\').replace(/([!"#$%&'()*+,./:;<=>?@[\]^`{|}~])/g, '\\$1')
|
||||
: str;
|
||||
return str ? str.replace(/\\/g, '\\\\').replace(/([!"#$%&'()*+,./:;<=>?@[\]^`{|}~])/g, '\\$1') : str;
|
||||
}
|
||||
|
||||
function setupHandlers () {
|
||||
var $controls = $(selectors.radio + ', ' + selectors.checkbox);
|
||||
var radios = document.querySelectorAll(selectors.radio);
|
||||
var checkboxes = document.querySelectorAll(selectors.checkbox);
|
||||
|
||||
$(selectors.radio).on('click.' + selectors.namespace, function () {
|
||||
handleRadioContent($(this), getToggledContent($(this)));
|
||||
radios.forEach(function (radio) {
|
||||
var handler = function () {
|
||||
handleRadioContent(radio, getToggledContent(radio));
|
||||
};
|
||||
radio.addEventListener('click', handler);
|
||||
eventHandlers.set(radio, handler);
|
||||
});
|
||||
|
||||
$(selectors.checkbox).on('click.' + selectors.namespace, function () {
|
||||
handleCheckboxContent($(this), getToggledContent($(this)));
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
var handler = function () {
|
||||
handleCheckboxContent(checkbox, getToggledContent(checkbox));
|
||||
};
|
||||
checkbox.addEventListener('click', handler);
|
||||
eventHandlers.set(checkbox, handler);
|
||||
});
|
||||
if ($controls.filter(':checked').length) {
|
||||
$controls.filter(':checked').each(function () {
|
||||
var $control = $(this);
|
||||
var $content = getToggledContent($control);
|
||||
|
||||
if ($control.is('[type=radio]')) {
|
||||
handleRadioContent($control, $content);
|
||||
} else {
|
||||
handleCheckboxContent($control, $content);
|
||||
var allControls = Array.from(radios).concat(Array.from(checkboxes));
|
||||
var checkedControls = allControls.filter(function (control) {
|
||||
return control.checked;
|
||||
});
|
||||
|
||||
if (checkedControls.length) {
|
||||
checkedControls.forEach(function (control) {
|
||||
var content = getToggledContent(control);
|
||||
|
||||
if (content) {
|
||||
if (control.type === 'radio') {
|
||||
handleRadioContent(control, content);
|
||||
} else {
|
||||
handleCheckboxContent(control, content);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.destroy = function () {
|
||||
var $controls = $(selectors.radio + ', ' + selectors.checkbox);
|
||||
var radios = document.querySelectorAll(selectors.radio);
|
||||
var checkboxes = document.querySelectorAll(selectors.checkbox);
|
||||
var allControls = Array.from(radios).concat(Array.from(checkboxes));
|
||||
|
||||
$controls.each(function () {
|
||||
var $control = $(this);
|
||||
var $content = getToggledContent($control);
|
||||
allControls.forEach(function (control) {
|
||||
var content = getToggledContent(control);
|
||||
|
||||
$control.removeAttr('aria-controls aria-expanded');
|
||||
$content.removeAttr('aria-hidden');
|
||||
control.removeAttribute('aria-controls');
|
||||
control.removeAttribute('aria-expanded');
|
||||
if (content) {
|
||||
content.removeAttribute('aria-hidden');
|
||||
}
|
||||
|
||||
var handler = eventHandlers.get(control);
|
||||
if (handler) {
|
||||
control.removeEventListener('click', handler);
|
||||
eventHandlers.delete(control);
|
||||
}
|
||||
});
|
||||
|
||||
$(selectors.radio).off('.' + selectors.namespace);
|
||||
$(selectors.checkbox).off('.' + selectors.namespace);
|
||||
};
|
||||
|
||||
self.init = function () {
|
||||
try {
|
||||
$(selectors.radio + ', ' + selectors.checkbox).each(initToggledContent);
|
||||
var radios = document.querySelectorAll(selectors.radio);
|
||||
var checkboxes = document.querySelectorAll(selectors.checkbox);
|
||||
var allControls = Array.from(radios).concat(Array.from(checkboxes));
|
||||
|
||||
allControls.forEach(initToggledContent);
|
||||
setupHandlers();
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
var $ = window.jQuery;
|
||||
|
||||
window.NotifyModules.moduleSystem = {
|
||||
find: function (container) {
|
||||
container = container || $('body');
|
||||
container = container || document.body;
|
||||
|
||||
var modules;
|
||||
var moduleSelector = '[data-module]';
|
||||
|
||||
modules = container.find(moduleSelector);
|
||||
// If container is already an element, use it directly
|
||||
if (container instanceof Element) {
|
||||
modules = Array.from(container.querySelectorAll(moduleSelector));
|
||||
|
||||
if (container.is(moduleSelector)) {
|
||||
modules = modules.add(container);
|
||||
// If the container itself is a module, include it
|
||||
if (container.matches && container.matches(moduleSelector)) {
|
||||
modules.unshift(container);
|
||||
}
|
||||
} else {
|
||||
modules = Array.from(document.querySelectorAll(moduleSelector));
|
||||
}
|
||||
|
||||
return modules;
|
||||
@@ -25,16 +29,16 @@
|
||||
for (var i = 0, l = modules.length; i < l; i++) {
|
||||
try {
|
||||
var module;
|
||||
var element = $(modules[i]);
|
||||
var type = this.camelCaseAndCapitalise(element.data('module'));
|
||||
var started = element.data('module-started');
|
||||
var element = modules[i];
|
||||
var type = this.camelCaseAndCapitalise(element.dataset.module);
|
||||
var started = element.dataset.moduleStarted;
|
||||
|
||||
if (typeof window.NotifyModules[type] === 'function' && !started) {
|
||||
module = new window.NotifyModules[type]();
|
||||
if (module.start) {
|
||||
module.start(element);
|
||||
}
|
||||
element.data('module-started', true);
|
||||
element.dataset.moduleStarted = 'true';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize module:', type || 'unknown', error);
|
||||
|
||||
@@ -2,45 +2,51 @@
|
||||
"use strict";
|
||||
|
||||
const disableSubmitButtons = function (event) {
|
||||
const $submitButton = $(this).find(':submit');
|
||||
const form = event.currentTarget;
|
||||
const submitButton = form.querySelector('input[type="submit"], button[type="submit"]');
|
||||
|
||||
if ($submitButton.data('clicked') === 'true') {
|
||||
if (!submitButton) return;
|
||||
|
||||
if (submitButton.dataset.clicked === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
$submitButton.data('clicked', 'true');
|
||||
submitButton.dataset.clicked = 'true';
|
||||
|
||||
// Add loading spinner for Send/Schedule/Cancel buttons
|
||||
const buttonName = $submitButton.attr('name')?.toLowerCase();
|
||||
const buttonName = submitButton.getAttribute('name')?.toLowerCase();
|
||||
if (["send", "schedule", "cancel"].includes(buttonName)) {
|
||||
// Use setTimeout with minimal delay to allow form submission to proceed first
|
||||
setTimeout(() => {
|
||||
$submitButton.prop('disabled', true);
|
||||
submitButton.disabled = true;
|
||||
|
||||
// Add loading spinner and aria-busy attribute for accessibility
|
||||
if ($submitButton.find('.loading-spinner').length === 0) {
|
||||
$submitButton.attr('aria-busy', 'true');
|
||||
$submitButton.append('<span class="loading-spinner" role="status" aria-label="Sending"></span>');
|
||||
if (submitButton.querySelector('.loading-spinner') === null) {
|
||||
submitButton.setAttribute('aria-busy', 'true');
|
||||
submitButton.insertAdjacentHTML('beforeend', '<span class="loading-spinner" role="status" aria-label="Sending"></span>');
|
||||
}
|
||||
|
||||
// Disable Cancel button too
|
||||
const $cancelButton = $('button[name]').filter(function () {
|
||||
return $(this).attr('name')?.toLowerCase() === 'cancel';
|
||||
const cancelButtons = Array.from(document.querySelectorAll('button[name]')).filter(button => {
|
||||
return button.getAttribute('name')?.toLowerCase() === 'cancel';
|
||||
});
|
||||
$cancelButton.prop('disabled', true);
|
||||
cancelButtons.forEach(button => button.disabled = true);
|
||||
}, 50); // Small delay to ensure form submits first
|
||||
} else {
|
||||
setTimeout(() => renableSubmitButton($submitButton)(), 1500);
|
||||
setTimeout(() => renableSubmitButton(submitButton)(), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const renableSubmitButton = ($submitButton) => () => {
|
||||
$submitButton.data('clicked', '');
|
||||
$submitButton.prop('disabled', false);
|
||||
$submitButton.attr('aria-busy', 'false');
|
||||
$submitButton.find('.loading-spinner').remove(); // clean up spinner
|
||||
const renableSubmitButton = (submitButton) => () => {
|
||||
submitButton.dataset.clicked = '';
|
||||
submitButton.disabled = false;
|
||||
submitButton.setAttribute('aria-busy', 'false');
|
||||
const spinner = submitButton.querySelector('.loading-spinner');
|
||||
if (spinner) spinner.remove(); // clean up spinner
|
||||
};
|
||||
|
||||
$('form').on('submit', disableSubmitButtons);
|
||||
document.querySelectorAll('form').forEach(form => {
|
||||
form.addEventListener('submit', disableSubmitButtons);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
var Modules = window.NotifyModules;
|
||||
|
||||
const escapeHtml = (unsafe) => {
|
||||
if (!unsafe) return '';
|
||||
return String(unsafe)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
// Template functions for rendering component states
|
||||
let renderStates = {
|
||||
'initial': function(data) {
|
||||
@@ -11,14 +21,14 @@
|
||||
${data.showNowAsDefault ? `
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" id="${data.name}-0" name="${data.name}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${data.name}-0">Now</label>
|
||||
<input class="usa-radio__input" checked="checked" id="${escapeHtml(data.name)}-0" name="${escapeHtml(data.name)}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${escapeHtml(data.name)}-0">Now</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
${data.categories.map(category =>
|
||||
`<input type='button' class='usa-button usa-button--outline radio-select__button--category' aria-expanded="false" value='${category}' />`
|
||||
`<input type='button' class='usa-button usa-button--outline radio-select__button--category' aria-expanded="false" value='${escapeHtml(category)}' />`
|
||||
).join('')}
|
||||
</div>
|
||||
`;
|
||||
@@ -28,16 +38,16 @@
|
||||
${data.showNowAsDefault ? `
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" id="${data.name}-0" name="${data.name}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${data.name}-0">Now</label>
|
||||
<input class="usa-radio__input" checked="checked" id="${escapeHtml(data.name)}-0" name="${escapeHtml(data.name)}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${escapeHtml(data.name)}-0">Now</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
${data.choices.map(choice => `
|
||||
<div class="usa-radio js-option">
|
||||
<input class="usa-radio__input" type="radio" value="${choice.value}" id="${choice.id}" name="${data.name}" />
|
||||
<label class="usa-radio__label" for="${choice.id}">${choice.label}</label>
|
||||
<input class="usa-radio__input" type="radio" value="${escapeHtml(choice.value)}" id="${escapeHtml(choice.id)}" name="${escapeHtml(data.name)}" />
|
||||
<label class="usa-radio__label" for="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
<input type='button' class='usa-button usa-button--outline radio-select__button--done margin-top-4' aria-expanded='true' value='Back to select a new time' />
|
||||
@@ -49,16 +59,16 @@
|
||||
${data.showNowAsDefault ? `
|
||||
<div class="radio-select__column margin-y-2">
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" id="${data.name}-0" name="${data.name}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${data.name}-0">Now</label>
|
||||
<input class="usa-radio__input" id="${escapeHtml(data.name)}-0" name="${escapeHtml(data.name)}" type="radio" value="">
|
||||
<label class="usa-radio__label" for="${escapeHtml(data.name)}-0">Now</label>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="radio-select__column margin-y-2">
|
||||
${data.choices.map(choice => `
|
||||
<div class="usa-radio">
|
||||
<input class="usa-radio__input" checked="checked" type="radio" value="${choice.value}" id="${choice.id}" name="${data.name}" />
|
||||
<label class="usa-radio__label" for="${choice.id}">${choice.label}</label>
|
||||
<input class="usa-radio__input" checked="checked" type="radio" value="${escapeHtml(choice.value)}" id="${escapeHtml(choice.id)}" name="${escapeHtml(data.name)}" />
|
||||
<label class="usa-radio__label" for="${escapeHtml(choice.id)}">${escapeHtml(choice.label)}</label>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
@@ -70,12 +80,13 @@
|
||||
};
|
||||
|
||||
let shiftFocus = function(elementToFocus, component) {
|
||||
const radios = component.querySelectorAll('[type=radio]');
|
||||
// The first option is always the default
|
||||
if (elementToFocus === 'default') {
|
||||
$('[type=radio]', component).eq(0).focus();
|
||||
if (elementToFocus === 'default' && radios[0]) {
|
||||
radios[0].focus();
|
||||
}
|
||||
if (elementToFocus === 'option') {
|
||||
$('[type=radio]', component).eq(1).focus();
|
||||
if (elementToFocus === 'option' && radios[1]) {
|
||||
radios[1].focus();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,24 +94,22 @@
|
||||
|
||||
this.start = function(component) {
|
||||
|
||||
let $component = $(component);
|
||||
let render = (state, data) => {
|
||||
$component.html(renderStates[state](data));
|
||||
component.innerHTML = renderStates[state](data);
|
||||
};
|
||||
// store array of all options in component
|
||||
let choices = $('label', $component).toArray().map(function(element) {
|
||||
let $element = $(element);
|
||||
let choices = Array.from(component.querySelectorAll('label')).map(function(element) {
|
||||
return {
|
||||
'id': $element.attr('for'),
|
||||
'label': $.trim($element.text()),
|
||||
'value': $element.prev('input').attr('value')
|
||||
'id': element.htmlFor,
|
||||
'label': element.textContent.trim(),
|
||||
'value': element.previousElementSibling.value
|
||||
};
|
||||
});
|
||||
let categories = $component.data('categories').split(',');
|
||||
let name = $component.find('input').eq(0).attr('name');
|
||||
let categories = component.dataset.categories.split(',');
|
||||
let name = component.querySelector('input').name;
|
||||
let mousedownOption = null;
|
||||
let showNowAsDefault = (
|
||||
$component.data('show-now-as-default').toString() === 'true' ?
|
||||
component.dataset.showNowAsDefault === 'true' ?
|
||||
{'name': name} : false
|
||||
);
|
||||
|
||||
@@ -129,22 +138,23 @@
|
||||
const parentNode = event.target.parentNode;
|
||||
|
||||
if (parentNode === mousedownOption) {
|
||||
const value = $('input', parentNode).attr('value');
|
||||
const input = parentNode.querySelector('input');
|
||||
const value = input ? input.value : '';
|
||||
|
||||
selectOption(value);
|
||||
|
||||
// clear tracking
|
||||
mousedownOption = null;
|
||||
$(document).off('mouseup', trackMouseup);
|
||||
document.removeEventListener('mouseup', trackMouseup);
|
||||
}
|
||||
};
|
||||
|
||||
// set events
|
||||
$component
|
||||
.on('click', '.radio-select__button--category', function(event) {
|
||||
|
||||
// set events using event delegation
|
||||
component.addEventListener('click', function(event) {
|
||||
// Handle category button clicks
|
||||
if (event.target.classList.contains('radio-select__button--category')) {
|
||||
event.preventDefault();
|
||||
let wordsInDay = $(this).attr('value').split(' ');
|
||||
let wordsInDay = event.target.value.split(' ');
|
||||
let day = wordsInDay[wordsInDay.length - 1].toLowerCase();
|
||||
render('choose', {
|
||||
'choices': choices.filter(
|
||||
@@ -154,57 +164,58 @@
|
||||
'showNowAsDefault': showNowAsDefault
|
||||
});
|
||||
shiftFocus('option', component);
|
||||
}
|
||||
|
||||
})
|
||||
.on('mousedown', '.js-option', function(event) {
|
||||
mousedownOption = this;
|
||||
|
||||
// mouseup on the same option completes the click action
|
||||
$(document).on('mouseup', trackMouseup);
|
||||
})
|
||||
// space and enter, clicked on a radio confirm that option was selected
|
||||
.on('keydown', 'input[type=radio]', function(event) {
|
||||
|
||||
// allow keypresses which aren’t enter or space through
|
||||
if (event.which !== 13 && event.which !== 32) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle done button clicks
|
||||
if (event.target.classList.contains('radio-select__button--done')) {
|
||||
event.preventDefault();
|
||||
let value = $(this).attr('value');
|
||||
selectOption(value);
|
||||
|
||||
})
|
||||
.on('click', '.radio-select__button--done', function(event) {
|
||||
|
||||
event.preventDefault();
|
||||
let $selection = $('input[type=radio]:checked', this.parentNode);
|
||||
if ($selection.length) {
|
||||
|
||||
let selection = event.target.parentNode.querySelector('input[type=radio]:checked');
|
||||
if (selection) {
|
||||
render('chosen', {
|
||||
'choices': choices.filter(
|
||||
element => element.value == $selection.eq(0).attr('value')
|
||||
element => element.value == selection.value
|
||||
),
|
||||
'name': name,
|
||||
'showNowAsDefault': showNowAsDefault
|
||||
});
|
||||
shiftFocus('option', component);
|
||||
|
||||
} else {
|
||||
|
||||
reset();
|
||||
shiftFocus('default', component);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
.on('click', '.radio-select__button--reset', function(event) {
|
||||
|
||||
// Handle reset button clicks
|
||||
if (event.target.classList.contains('radio-select__button--reset')) {
|
||||
event.preventDefault();
|
||||
reset();
|
||||
shiftFocus('default', component);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
component.addEventListener('mousedown', function(event) {
|
||||
// Handle option mousedown
|
||||
const option = event.target.closest('.js-option');
|
||||
if (option) {
|
||||
mousedownOption = option;
|
||||
// mouseup on the same option completes the click action
|
||||
document.addEventListener('mouseup', trackMouseup);
|
||||
}
|
||||
});
|
||||
|
||||
component.addEventListener('keydown', function(event) {
|
||||
// Handle radio keydown (space and enter)
|
||||
if (event.target.type === 'radio') {
|
||||
// allow keypresses which aren't enter or space through
|
||||
if (event.which !== 13 && event.which !== 32) {
|
||||
return true;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
let value = event.target.value;
|
||||
selectOption(value);
|
||||
}
|
||||
});
|
||||
|
||||
// set HTML to initial state
|
||||
render('initial', {
|
||||
@@ -213,7 +224,7 @@
|
||||
'showNowAsDefault': showNowAsDefault
|
||||
});
|
||||
|
||||
$component.css({'height': 'auto'});
|
||||
component.style.height = 'auto';
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
(function(window) {
|
||||
|
||||
"use strict";
|
||||
|
||||
window.NotifyModules['radio-slider'] = function() {
|
||||
|
||||
this.start = function(component) {
|
||||
|
||||
$(component)
|
||||
.on('click', function() {
|
||||
|
||||
valuesInLabel = $(this).find(':checked').next('label').text().split('/');
|
||||
|
||||
if (valuesInLabel.length === 2) {
|
||||
leftValue = valuesInLabel[0];
|
||||
rightValue = valuesInLabel[1];
|
||||
$(this).find('.radio-slider-left-value').text(leftValue);
|
||||
$(this).find('.radio-slider-right-value').text(rightValue);
|
||||
}
|
||||
|
||||
})
|
||||
.trigger('click');
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
})(window);
|
||||
@@ -1,995 +0,0 @@
|
||||
;(function (window) {
|
||||
'use strict';
|
||||
|
||||
var $ = window.jQuery;
|
||||
var NotifyModules = window.NotifyModules || {};
|
||||
var _mode = 'default';
|
||||
|
||||
// Constructor to make objects representing the area sticky elements can scroll in
|
||||
var ScrollArea = function (el, edge, selector) {
|
||||
var $el = el.$fixedEl;
|
||||
var $scrollArea = $el.closest('.sticky-scroll-area');
|
||||
|
||||
if($scrollArea.length === 0) {
|
||||
$scrollArea = $el.parent();
|
||||
$scrollArea.addClass('sticky-scroll-area');
|
||||
}
|
||||
|
||||
this._els = [el];
|
||||
this.edge = edge;
|
||||
this.selector = selector;
|
||||
this.node = $scrollArea.get(0);
|
||||
this.setEvents();
|
||||
};
|
||||
ScrollArea.prototype.addEl = function (el) {
|
||||
this._els.push(el);
|
||||
};
|
||||
ScrollArea.prototype.hasEl = function (el) {
|
||||
return $.inArray(el, this._els) !== -1;
|
||||
};
|
||||
ScrollArea.prototype.updateEls = function (usedEls) {
|
||||
this._els = usedEls;
|
||||
};
|
||||
ScrollArea.prototype.setEvents = function () {
|
||||
this.node.addEventListener('focus', this.focusHandler.bind(this), true);
|
||||
$(this.node).on('keyup', 'textarea', this.focusHandler.bind(this));
|
||||
};
|
||||
ScrollArea.prototype.removeEvents = function () {
|
||||
this.node.removeEventListener('focus', this.focusHandler.bind(this));
|
||||
$(this.node).find('textarea').off('keyup', 'textarea', this.focusHandler.bind(this));
|
||||
};
|
||||
ScrollArea.prototype.getFocusedDetails = {
|
||||
forElement: function ($focusedElement) {
|
||||
var focused = {
|
||||
'top': $focusedElement.offset().top,
|
||||
'height': $focusedElement.outerHeight(),
|
||||
'type': 'element'
|
||||
};
|
||||
focused.bottom = focused.top + focused.height;
|
||||
|
||||
return focused;
|
||||
},
|
||||
forCaret: function ($textarea) {
|
||||
var textarea = $textarea.get(0);
|
||||
var caretCoordinates = window.getCaretCoordinates(textarea, textarea.selectionEnd);
|
||||
var focused = {
|
||||
'top': $textarea.offset().top + caretCoordinates.top,
|
||||
'height': caretCoordinates.height,
|
||||
'type': 'caret'
|
||||
};
|
||||
|
||||
focused.bottom = focused.top + focused.height;
|
||||
|
||||
return focused;
|
||||
}
|
||||
};
|
||||
ScrollArea.prototype.focusHandler = function (e) {
|
||||
this.scrollToRevealElement($(document.activeElement));
|
||||
};
|
||||
ScrollArea.prototype.scrollToRevealElement = function ($el) {
|
||||
var nodeName = $el.get(0).nodeName.toLowerCase();
|
||||
var endOfFurthestEl = focusOverlap.endOfFurthestEl(this._els, this.edge);
|
||||
var isInSticky = function () {
|
||||
return $el.closest(this.selector).length > 0;
|
||||
}.bind(this);
|
||||
var focused;
|
||||
var overlap;
|
||||
|
||||
// if textarea is focused, we care about checking the caret, not the whole element
|
||||
if (nodeName === 'textarea') {
|
||||
focused = this.getFocusedDetails.forCaret($el);
|
||||
} else {
|
||||
if (isInSticky()) { return; }
|
||||
focused = this.getFocusedDetails.forElement($el);
|
||||
}
|
||||
|
||||
overlap = focusOverlap.getOverlap(focused, this.edge, endOfFurthestEl);
|
||||
|
||||
if (overlap > 0) {
|
||||
focusOverlap.adjustForOverlap(focused, this.edge, overlap);
|
||||
}
|
||||
};
|
||||
ScrollArea.prototype.destroy = function () {
|
||||
this.removeEvents();
|
||||
};
|
||||
|
||||
// Object collecting together methods for interacting with scrollareas
|
||||
var scrollAreas = {
|
||||
_scrollAreas: [],
|
||||
getAreaForEl: function (el) {
|
||||
var loopIdx = this._scrollAreas.length;
|
||||
|
||||
while(loopIdx--) {
|
||||
if (this._scrollAreas[loopIdx].hasEl(el)) {
|
||||
return this._scrollAreas[loopIdx];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
getAreaByEl: function (el) {
|
||||
var matches = $.grep(this._scrollAreas, function (area) {
|
||||
return $.inArray(el, area.els) !== -1;
|
||||
});
|
||||
|
||||
return matches[0] || false;
|
||||
},
|
||||
addEl: function (el, edge, selector) {
|
||||
var scrollArea = this.getAreaForEl(el);
|
||||
|
||||
if (!scrollArea) {
|
||||
this._scrollAreas.push(new ScrollArea(el, edge, selector));
|
||||
} else {
|
||||
scrollArea.addEl(el);
|
||||
}
|
||||
},
|
||||
syncEls: function (elsInDOM) {
|
||||
var self = this;
|
||||
var unusedAreas = [];
|
||||
|
||||
var getUsed = function (area) {
|
||||
var used = [];
|
||||
|
||||
$.each(elsInDOM, function (elIdx, el) {
|
||||
if (area.hasEl(el)) {
|
||||
used.push(el);
|
||||
}
|
||||
});
|
||||
|
||||
return used;
|
||||
};
|
||||
|
||||
var deleteUnused = function (idx, areaIdx) {
|
||||
// remove any events for overlap checking bound to the scrollArea
|
||||
self._scrollAreas[areaIdx].destroy();
|
||||
self._scrollAreas.splice(areaIdx, 1);
|
||||
};
|
||||
|
||||
// update any scroll areas with els still in the DOM and track any with none
|
||||
$.each(this._scrollAreas, function (areaIdx, area) {
|
||||
var used = getUsed(area);
|
||||
|
||||
if (!used.length) {
|
||||
unusedAreas.push(areaIdx);
|
||||
}
|
||||
|
||||
area.updateEls(used);
|
||||
});
|
||||
|
||||
// delete any scroll areas with no els still in DOM
|
||||
$.each(unusedAreas, deleteUnused);
|
||||
}
|
||||
};
|
||||
|
||||
// Object collecting together methods for stopping sticky overlapping focused elements
|
||||
var focusOverlap = {
|
||||
getOverlap: function (focused, edge, endOfFurthestEl) {
|
||||
if (!endOfFurthestEl) { return 0; }
|
||||
|
||||
if (edge === 'top') {
|
||||
return endOfFurthestEl - focused.top;
|
||||
} else {
|
||||
return focused.bottom - endOfFurthestEl;
|
||||
}
|
||||
},
|
||||
endOfFurthestEl: function (els, edge) {
|
||||
var stuckEls = $.grep(els, function (el) { return el.isStuck(); });
|
||||
var edgeOfEl;
|
||||
var offsets;
|
||||
|
||||
if (edge === 'bottom') {
|
||||
edgeOfEl = function (el) {
|
||||
return el.$fixedEl.offset().top;
|
||||
};
|
||||
} else {
|
||||
edgeOfEl = function (el) {
|
||||
return el.$fixedEl.offset().top + el.height;
|
||||
};
|
||||
}
|
||||
|
||||
if (!stuckEls.length) { return false; }
|
||||
|
||||
offsets = $.map(stuckEls, function (el) { return edgeOfEl(el); });
|
||||
|
||||
return offsets.reduce(function (accumulator, offset) {
|
||||
return (accumulator < offset) ? offset: accumulator;
|
||||
});
|
||||
},
|
||||
adjustForOverlap: function (focused, edge, overlap) {
|
||||
var scrollTop = $(window).scrollTop();
|
||||
|
||||
// scroll so element becomes visible
|
||||
if (edge === 'top') {
|
||||
$(window).scrollTop(scrollTop - overlap);
|
||||
} else {
|
||||
$(window).scrollTop(scrollTop + overlap);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Object collecting together methods for dealing with marking the edge of a sticky, or group of
|
||||
// sticky elements (as seen in dialog mode)
|
||||
var oppositeEdge = {
|
||||
_classes: {
|
||||
'top': 'content-fixed__top',
|
||||
'bottom': 'content-fixed__bottom'
|
||||
},
|
||||
_getClassForEdge: function (edge) {
|
||||
return this._classes[edge];
|
||||
},
|
||||
mark: function (sticky) {
|
||||
var edgeClass = this._getClassForEdge(sticky.edge);
|
||||
var els;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
els = [dialog.getElementAtOppositeEnd(sticky)];
|
||||
} else {
|
||||
els = sticky._els;
|
||||
}
|
||||
|
||||
els = $.grep(els, function (el) { return el.isStuck(); });
|
||||
|
||||
$.each(els, function (i, el) {
|
||||
el.$fixedEl.addClass(edgeClass);
|
||||
});
|
||||
},
|
||||
unmark: function (sticky) {
|
||||
var edgeClass = this._getClassForEdge(sticky.edge);
|
||||
|
||||
$.each(sticky._els, function (i, el) {
|
||||
el.$fixedEl.removeClass(edgeClass);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Constructor for objects holding data for each element to have sticky behaviour
|
||||
var StickyElement = function ($el, sticky) {
|
||||
this._sticky = sticky;
|
||||
this.$fixedEl = $el;
|
||||
this._initialFixedClass = 'content-fixed-onload';
|
||||
this._fixedClass = 'content-fixed';
|
||||
this._appliedClass = null;
|
||||
this._$shim = null;
|
||||
this._stopped = false;
|
||||
this._hasLoaded = false;
|
||||
this._canBeStuck = true;
|
||||
this.verticalMargins = {
|
||||
'top': parseInt(this.$fixedEl.css('margin-top'), 10),
|
||||
'bottom': parseInt(this.$fixedEl.css('margin-bottom'), 10),
|
||||
};
|
||||
};
|
||||
StickyElement.prototype._getShimCSS = function () {
|
||||
return {
|
||||
'width': this.horizontalSpace + 'px',
|
||||
'height': this.height + 'px',
|
||||
'margin-top': this.verticalMargins.top + 'px',
|
||||
'margin-bottom': this.verticalMargins.bottom + 'px'
|
||||
};
|
||||
};
|
||||
StickyElement.prototype.stickyClass = function () {
|
||||
return (this._sticky._initialPositionsSet) ? this._fixedClass : this._initialFixedClass;
|
||||
};
|
||||
StickyElement.prototype.appliedClass = function () {
|
||||
return this._appliedClass;
|
||||
};
|
||||
StickyElement.prototype.removeStickyClasses = function (sticky) {
|
||||
this.$fixedEl.removeClass([
|
||||
this._initialFixedClass,
|
||||
this._fixedClass
|
||||
].join(' '));
|
||||
};
|
||||
StickyElement.prototype.isStuck = function () {
|
||||
return this._appliedClass !== null;
|
||||
};
|
||||
StickyElement.prototype.stick = function (sticky) {
|
||||
this._appliedClass = this.stickyClass();
|
||||
this.$fixedEl.addClass(this._appliedClass);
|
||||
this._hasBeenCalled = true;
|
||||
};
|
||||
StickyElement.prototype.release = function (sticky) {
|
||||
this._appliedClass = null;
|
||||
this.removeStickyClasses(sticky);
|
||||
this._hasBeenCalled = true;
|
||||
};
|
||||
// When a sticky element is moved into the 'stuck' state, a shim is inserted into the
|
||||
// page to preserve the space the element occupies in the flow.
|
||||
StickyElement.prototype.addShim = function (position) {
|
||||
this._$shim = $('<div class="shim"> </div>');
|
||||
this._$shim.css(this._getShimCSS());
|
||||
this.$fixedEl[position](this._$shim);
|
||||
};
|
||||
StickyElement.prototype.removeShim = function () {
|
||||
if (this._$shim !== null) {
|
||||
this._$shim.remove();
|
||||
this._$shim = null;
|
||||
}
|
||||
};
|
||||
// Changes to the dimensions of a sticky element with a shim need to be passed on to the shim
|
||||
StickyElement.prototype.updateShim = function () {
|
||||
if (this._$shim) {
|
||||
this._$shim.css(this._getShimCSS());
|
||||
}
|
||||
};
|
||||
StickyElement.prototype.stop = function () {
|
||||
this._stopped = true;
|
||||
};
|
||||
StickyElement.prototype.unstop = function () {
|
||||
this._stopped = false;
|
||||
};
|
||||
StickyElement.prototype.isStopped = function () {
|
||||
return this._stopped;
|
||||
};
|
||||
StickyElement.prototype.isInPage = function () {
|
||||
var node = this.$fixedEl.get(0);
|
||||
|
||||
return (node === document.body) ? false : document.body.contains(node);
|
||||
};
|
||||
StickyElement.prototype.canBeStuck = function (val) {
|
||||
if (val !== undefined) {
|
||||
this._canBeStuck = val;
|
||||
} else {
|
||||
return this._canBeStuck;
|
||||
}
|
||||
};
|
||||
StickyElement.prototype.hasLoaded = function (val) {
|
||||
if (val !== undefined) {
|
||||
this._hasLoaded = val;
|
||||
} else {
|
||||
return this._hasLoaded;
|
||||
}
|
||||
};
|
||||
|
||||
// Object collecting together methods for treating sticky elements as if they
|
||||
// were wrapped by a dialog component
|
||||
var dialog = {
|
||||
hasResized: false,
|
||||
spaceBetweenStickys: 40,
|
||||
// we add padding of 20px around each sticky to give some space between it and the rest of the page
|
||||
// this shouldn't apply between stickys in a stack
|
||||
// (the in-page CSS handles this by each subsequent sticky in a sequence having margin: -40px)
|
||||
_getPaddingBetweenEls: function (els) {
|
||||
if (els.length <= 1) { return 0; }
|
||||
|
||||
return (els.length - 1) * this.spaceBetweenStickys;
|
||||
},
|
||||
_getTotalHeight: function (els) {
|
||||
var reducer = function (accumulator, currentValue) {
|
||||
return accumulator + currentValue;
|
||||
};
|
||||
var combinedHeight = $.map(els, function (el) { return el.height; }).reduce(reducer);
|
||||
|
||||
return combinedHeight - this._getPaddingBetweenEls(els);
|
||||
},
|
||||
_elsThatCanBeStuck: function (els) {
|
||||
return $.grep(els, function (el) { return el.canBeStuck(); });
|
||||
},
|
||||
getOffsetFromEdge: function (el, sticky) {
|
||||
var els = this._elsThatCanBeStuck(sticky._els).slice();
|
||||
var elIdx;
|
||||
|
||||
// els must be arranged furtherest from window edge is stuck to first
|
||||
// default direction is order in document
|
||||
if (sticky.edge === 'top') {
|
||||
els.reverse();
|
||||
}
|
||||
|
||||
elIdx = els.indexOf(el);
|
||||
|
||||
// if next to window edge the dialog is stuck to, no offset
|
||||
if (elIdx === (els.length - 1)) { return 0; }
|
||||
|
||||
// make els all those from this one to the window edge
|
||||
els = els.slice(elIdx + 1);
|
||||
|
||||
// remove the space between those els and the one on the edge
|
||||
return this._getTotalHeight(els) - this.spaceBetweenStickys;
|
||||
},
|
||||
getOffsetFromEnd: function (el, sticky) {
|
||||
var els = this._elsThatCanBeStuck(sticky._els).slice();
|
||||
var elIdx;
|
||||
|
||||
// els must be arranged furtherest from window edge is stuck to first
|
||||
// default direction is order in document
|
||||
if (sticky.edge === 'bottom') {
|
||||
els.reverse();
|
||||
}
|
||||
|
||||
elIdx = els.indexOf(el);
|
||||
|
||||
// if next to opposite edge to the one the dialog is stuck to, no offset
|
||||
if (elIdx === (els.length - 1)) { return 0; }
|
||||
|
||||
// make els all those from this one to the window edge
|
||||
els = els.slice(elIdx + 1);
|
||||
|
||||
return this._getTotalHeight(els) - this.spaceBetweenStickys;
|
||||
},
|
||||
// checks total height of all this._sticky elements against a height
|
||||
// unsticks each that won't fit and marks them as unstickable
|
||||
fitToHeight: function (sticky) {
|
||||
var self = this;
|
||||
var els = sticky._els.slice();
|
||||
var height = sticky.getWindowDimensions().height;
|
||||
var totalStickyHeight = function () {
|
||||
return self._getTotalHeight(self._elsThatCanBeStuck(els));
|
||||
};
|
||||
var dialogFitsHeight = function () {
|
||||
return totalStickyHeight() <= height;
|
||||
};
|
||||
|
||||
// els must be arranged furtherest from window edge is stuck to first
|
||||
// default direction is order in document
|
||||
if (sticky.edge === 'top') {
|
||||
els.reverse();
|
||||
}
|
||||
|
||||
// reset elements
|
||||
$.each(els, function (i, el) { el.canBeStuck(true); });
|
||||
|
||||
while (self._elsThatCanBeStuck(els).length && !dialogFitsHeight()) {
|
||||
var currentEl = self._elsThatCanBeStuck(els)[0];
|
||||
|
||||
sticky.reset(currentEl);
|
||||
currentEl.canBeStuck(false);
|
||||
|
||||
if (!self.hasResized) { self.hasResized = true; }
|
||||
}
|
||||
},
|
||||
getElementAtStickyEdge: function (sticky) {
|
||||
var els = this._elsThatCanBeStuck(sticky._els);
|
||||
var idx = (sticky.edge === 'top') ? 0 : els.length - 1;
|
||||
|
||||
return els[idx];
|
||||
},
|
||||
// get element at the end opposite the sticky edge
|
||||
getElementAtOppositeEnd: function (sticky) {
|
||||
var els = this._elsThatCanBeStuck(sticky._els);
|
||||
var idx = (sticky.edge === 'top') ? els.length - 1 : 0;
|
||||
|
||||
return els[idx];
|
||||
},
|
||||
getInPageEdgePosition: function (sticky) {
|
||||
return this.getElementAtStickyEdge(sticky).inPageEdgePosition;
|
||||
},
|
||||
getHeight: function (els) {
|
||||
return this._getTotalHeight(this._elsThatCanBeStuck(els));
|
||||
},
|
||||
adjustForResize: function (sticky) {
|
||||
var windowHeight = sticky.getWindowDimensions().height;
|
||||
|
||||
if (sticky.edge === 'top') {
|
||||
$(window).scrollTop(this.getInPageEdgePosition(sticky));
|
||||
} else {
|
||||
$(window).scrollTop(this.getInPageEdgePosition(sticky) - windowHeight);
|
||||
}
|
||||
|
||||
this.hasResized = false;
|
||||
},
|
||||
releaseEl: function (el, sticky) {
|
||||
el.$fixedEl.css(sticky.edge, '');
|
||||
}
|
||||
};
|
||||
|
||||
// Constructor for objects collecting together all generic behaviour for controlling the state of
|
||||
// sticky elements
|
||||
var Sticky = function (selector) {
|
||||
this._hasScrolled = false;
|
||||
this._scrollTimeout = false;
|
||||
this._windowHasResized = false;
|
||||
this._resizeTimeout = false;
|
||||
this._elsLoaded = false;
|
||||
this._initialPositionsSet = false;
|
||||
this._els = [];
|
||||
|
||||
this.CSS_SELECTOR = selector;
|
||||
this.STOP_PADDING = 10;
|
||||
};
|
||||
Sticky.prototype.setMode = function (mode) {
|
||||
_mode = mode;
|
||||
};
|
||||
Sticky.prototype.getWindowDimensions = function () {
|
||||
return {
|
||||
height: $(global).height(),
|
||||
width: $(global).width()
|
||||
};
|
||||
};
|
||||
Sticky.prototype.getWindowPositions = function () {
|
||||
return {
|
||||
scrollTop: $(global).scrollTop()
|
||||
};
|
||||
};
|
||||
// Change state of sticky elements based on their position relative to the window
|
||||
Sticky.prototype.setElementPositions = function () {
|
||||
var self = this,
|
||||
windowDimensions = self.getWindowDimensions(),
|
||||
windowTop = self.getWindowPositions().scrollTop,
|
||||
windowPositions = {
|
||||
'top': windowTop,
|
||||
'bottom': windowTop + windowDimensions.height
|
||||
};
|
||||
|
||||
var _setElementPosition = function (el) {
|
||||
if (self.viewportIsWideEnough(windowDimensions.width)) {
|
||||
|
||||
if (self.windowNotPastScrolledFrom(windowPositions, self.getScrolledFrom(el))) {
|
||||
self.reset(el);
|
||||
} else { // past the point it sits in the document
|
||||
if (self.windowNotPastScrollingTo(windowPositions, self.getScrollingTo(el))) {
|
||||
self.stick(el);
|
||||
if (el.isStopped()) {
|
||||
self.unstop(el);
|
||||
}
|
||||
} else { // window past scrollingTo position
|
||||
if (!el.isStuck()) {
|
||||
self.stick(el);
|
||||
}
|
||||
self.stop(el);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
self.reset(el);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
// clean up any existing styles marking the edges of sticky elements
|
||||
oppositeEdge.unmark(self);
|
||||
|
||||
$.each(self._els, function (i, el) {
|
||||
if (el.canBeStuck()) {
|
||||
_setElementPosition(el);
|
||||
}
|
||||
});
|
||||
|
||||
// add styles to mark the edge of sticky elements opposite to that stuck to the window
|
||||
oppositeEdge.mark(self);
|
||||
|
||||
if (self._initialPositionsSet === false) { self._initialPositionsSet = true; }
|
||||
};
|
||||
// Store all the dimensions for a sticky element to limit DOM queries
|
||||
Sticky.prototype.setElementDimensions = function (el, callback) {
|
||||
var self = this;
|
||||
var $el = el.$fixedEl;
|
||||
var onHeightSet = function () {
|
||||
// if element is shim'ed, pass changes in dimension on to the shim
|
||||
if (el._$shim) {
|
||||
el.updateShim();
|
||||
}
|
||||
if (callback !== undefined) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
this.setElWidth(el);
|
||||
this.setElHeight(el, onHeightSet);
|
||||
};
|
||||
// Reset element to original state in the page
|
||||
Sticky.prototype.reset = function (el) {
|
||||
if (el.isStopped()) {
|
||||
this.unstop(el);
|
||||
}
|
||||
if (el.isStuck()) {
|
||||
this.release(el);
|
||||
}
|
||||
};
|
||||
// Recalculate stored dimensions for all sticky elements
|
||||
Sticky.prototype.recalculate = function () {
|
||||
var self = this;
|
||||
var onSyncComplete = function () {
|
||||
scrollAreas.syncEls(self._els);
|
||||
self.setEvents();
|
||||
if (_mode === 'dialog') {
|
||||
dialog.fitToHeight(self);
|
||||
if (dialog.hasResized) {
|
||||
dialog.adjustForResize(self);
|
||||
}
|
||||
}
|
||||
self.setElementPositions();
|
||||
};
|
||||
|
||||
this.syncWithDOM(onSyncComplete);
|
||||
};
|
||||
// Public method to scroll so an element isn't covered by the sticky nav
|
||||
Sticky.prototype.scrollToRevealElement = function (el) {
|
||||
var $el = $(el);
|
||||
var scrollAreaNode = $el.closest('.sticky-scroll-area').get(0);
|
||||
var matches = $.grep(scrollAreas._scrollAreas, function (scrollArea) {
|
||||
return scrollArea.node === scrollAreaNode;
|
||||
});
|
||||
|
||||
if (matches.length) {
|
||||
matches[0].scrollToRevealElement($el);
|
||||
}
|
||||
};
|
||||
Sticky.prototype.setElWidth = function (el) {
|
||||
var $el = el.$fixedEl;
|
||||
var scrollArea = scrollAreas.getAreaByEl(el);
|
||||
var width = $(scrollArea.node).width();
|
||||
|
||||
el.horizontalSpace = width;
|
||||
// if stuck, element won't inherit width from parent so set explicitly
|
||||
if (el._$shim) {
|
||||
$el.width(width);
|
||||
}
|
||||
};
|
||||
Sticky.prototype.setElHeight = function (el, callback) {
|
||||
var self = this;
|
||||
var $el = el.$fixedEl;
|
||||
var $img = $el.find('img');
|
||||
var onload = function () {
|
||||
el.height = $el.outerHeight();
|
||||
// if element has a shim, the shim's offset represents the element's in-page position
|
||||
if (el._$shim) {
|
||||
el.inPageEdgePosition = self.getInPageEdgePosition(el._$shim);
|
||||
} else {
|
||||
el.inPageEdgePosition = self.getInPageEdgePosition($el);
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
if ((!el.hasLoaded()) && ($img.length > 0)) {
|
||||
var image = new window.Image();
|
||||
image.onload = function () {
|
||||
onload();
|
||||
};
|
||||
image.src = $img.attr('src');
|
||||
} else {
|
||||
onload();
|
||||
}
|
||||
};
|
||||
Sticky.prototype.allElementsLoaded = function (totalEls) {
|
||||
return this._els.length === totalEls;
|
||||
};
|
||||
Sticky.prototype.getElForNode = function (node) {
|
||||
var matches = $.grep(this._els, function (el) { return el.$fixedEl.is(node); });
|
||||
|
||||
return !!matches.length ? matches[0] : false;
|
||||
};
|
||||
Sticky.prototype.add = function (el, setPositions, cb) {
|
||||
var self = this;
|
||||
var $el = $(el);
|
||||
var onDimensionsSet;
|
||||
var elObj = this.getElForNode(el);
|
||||
var exists = !!elObj;
|
||||
|
||||
onDimensionsSet = function () {
|
||||
elObj.hasLoaded(true);
|
||||
|
||||
// guard against adding elements already stored
|
||||
if (!exists) {
|
||||
self._els.push(elObj);
|
||||
}
|
||||
|
||||
if (setPositions) {
|
||||
self.setElementPositions();
|
||||
}
|
||||
|
||||
if (cb !== undefined) {
|
||||
cb();
|
||||
}
|
||||
};
|
||||
|
||||
if (!exists) {
|
||||
elObj = new StickyElement($el, self);
|
||||
scrollAreas.addEl(elObj, self.edge, self.CSS_SELECTOR);
|
||||
}
|
||||
|
||||
self.setElementDimensions(elObj, onDimensionsSet);
|
||||
};
|
||||
Sticky.prototype.remove = function (el) {
|
||||
if ($.inArray(el, this._els) !== -1) {
|
||||
|
||||
// reset DOM node to original state
|
||||
this.reset(el);
|
||||
|
||||
// remove sticky element object
|
||||
this._els = $.grep(this._els, function (_el) { return _el !== el; });
|
||||
}
|
||||
};
|
||||
// gets all sticky elements in the DOM and removes any in this._els no longer in attached to it
|
||||
Sticky.prototype.syncWithDOM = function (callback) {
|
||||
var self = this;
|
||||
var $els = $(self.CSS_SELECTOR);
|
||||
var numOfEls = $els.length;
|
||||
var onLoaded;
|
||||
|
||||
onLoaded = function () {
|
||||
if (self._els.length === numOfEls) {
|
||||
self.endOfScrollArea = self.getEndOfScrollArea();
|
||||
if (callback !== undefined) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// remove any els no longer in the DOM
|
||||
if (this._els.length) {
|
||||
$.each(this._els, function (i, el) {
|
||||
if (!el.isInPage()) {
|
||||
self.remove(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (numOfEls) {
|
||||
// reset flag marking page load
|
||||
this._initialPositionsSet = false;
|
||||
|
||||
$els.each(function (i, el) {
|
||||
// delay setting position until all stickys are loaded
|
||||
self.add(el, false, onLoaded);
|
||||
});
|
||||
}
|
||||
};
|
||||
Sticky.prototype.init = function () {
|
||||
this.recalculate();
|
||||
};
|
||||
Sticky.prototype.setEvents = function () {
|
||||
this._scrollEvent = this.onScroll.bind(this);
|
||||
this._resizeEvent = this.onResize.bind(this);
|
||||
|
||||
// flag when scrolling takes place and check (and re-position) sticky elements relative to
|
||||
// window position
|
||||
if (this._scrollTimeout === false) {
|
||||
$(global).scroll(this._scrollEvent);
|
||||
this._scrollTimeout = window.setInterval(this.checkScroll.bind(this), 50);
|
||||
}
|
||||
|
||||
// Recalculate all dimensions when the window resizes
|
||||
if (this._resizeTimeout === false) {
|
||||
$(global).resize(this._resizeEvent);
|
||||
this._resizeTimeout = window.setInterval(this.checkResize.bind(this), 50);
|
||||
}
|
||||
};
|
||||
Sticky.prototype.clearEvents = function () {
|
||||
if (this._scrollTimeout !== false) {
|
||||
$(global).off('scroll', this._scrollEvent);
|
||||
window.clearInterval(this._scrollTimeout);
|
||||
this._scrollTimeout = false;
|
||||
}
|
||||
|
||||
if (this._resizeTimeout !== false) {
|
||||
$(global).off('resize', this._resizeEvent);
|
||||
window.clearInterval(this._resizeTimeout);
|
||||
this._resizeTimeout = false;
|
||||
}
|
||||
|
||||
};
|
||||
Sticky.prototype.viewportIsWideEnough = function (windowWidth) {
|
||||
return windowWidth > 768;
|
||||
};
|
||||
Sticky.prototype.onScroll = function () {
|
||||
this._hasScrolled = true;
|
||||
};
|
||||
Sticky.prototype.onResize = function () {
|
||||
this._windowHasResized = true;
|
||||
};
|
||||
Sticky.prototype.checkScroll = function () {
|
||||
var self = this;
|
||||
|
||||
if (self._hasScrolled === true) {
|
||||
self._hasScrolled = false;
|
||||
self.setElementPositions();
|
||||
}
|
||||
};
|
||||
Sticky.prototype.checkResize = function () {
|
||||
var self = this,
|
||||
windowWidth = self.getWindowDimensions().width;
|
||||
|
||||
if (self._windowHasResized === true) {
|
||||
self._windowHasResized = false;
|
||||
|
||||
$.each(self._els, function (i, el) {
|
||||
if (!self.viewportIsWideEnough(windowWidth)) {
|
||||
self.reset(el);
|
||||
} else {
|
||||
self.setElementDimensions(el);
|
||||
}
|
||||
});
|
||||
|
||||
if (self.viewportIsWideEnough(windowWidth)) {
|
||||
if (_mode === 'dialog') {
|
||||
dialog.fitToHeight(self);
|
||||
if (dialog.hasResized) {
|
||||
dialog.adjustForResize(self);
|
||||
}
|
||||
}
|
||||
self.setElementPositions();
|
||||
}
|
||||
}
|
||||
};
|
||||
Sticky.prototype.release = function (el) {
|
||||
if (el.isStuck()) {
|
||||
var $el = el.$fixedEl;
|
||||
|
||||
el.removeStickyClasses(this);
|
||||
$el.css('width', '');
|
||||
// clear styles from any elements stuck while in a dialog mode
|
||||
dialog.releaseEl(el, this);
|
||||
el.removeShim();
|
||||
el.release(this);
|
||||
}
|
||||
};
|
||||
|
||||
// Extension of sticky object to add behaviours specific to sticking to top of window
|
||||
var stickAtTop = new Sticky('.js-stick-at-top-when-scrolling');
|
||||
stickAtTop.edge = 'top';
|
||||
// Store furthest point sticky elements are allowed
|
||||
stickAtTop.getEndOfScrollArea = function () {
|
||||
var footer = $('.js-footer:eq(0)');
|
||||
if (footer.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return footer.offset().top - this.STOP_PADDING;
|
||||
};
|
||||
// position of the bottom edge when in the page flow
|
||||
stickAtTop.getInPageEdgePosition = function ($el) {
|
||||
return $el.offset().top;
|
||||
};
|
||||
stickAtTop.getScrolledFrom = function (el) {
|
||||
if (_mode === 'dialog') {
|
||||
return dialog.getInPageEdgePosition(this);
|
||||
} else {
|
||||
return el.inPageEdgePosition;
|
||||
}
|
||||
};
|
||||
stickAtTop.getScrollingTo = function (el) {
|
||||
var height = el.height;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
height = dialog.getHeight(this._els);
|
||||
}
|
||||
|
||||
return this.endOfScrollArea - height;
|
||||
};
|
||||
stickAtTop.getStoppingPosition = function (el) {
|
||||
var offset = 0;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
offset = dialog.getOffsetFromEnd(el, this);
|
||||
}
|
||||
|
||||
return (this.endOfScrollArea - offset) - el.height;
|
||||
};
|
||||
stickAtTop.windowNotPastScrolledFrom = function (windowPositions, scrolledFrom) {
|
||||
return scrolledFrom > windowPositions.top;
|
||||
};
|
||||
stickAtTop.windowNotPastScrollingTo = function (windowPositions, scrollingTo) {
|
||||
return windowPositions.top < scrollingTo;
|
||||
};
|
||||
stickAtTop.stick = function (el) {
|
||||
if (!el.isStuck()) {
|
||||
var $el = el.$fixedEl;
|
||||
var offset = 0;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
offset = dialog.getOffsetFromEdge(el, this);
|
||||
}
|
||||
|
||||
el.addShim('before');
|
||||
$el.css({
|
||||
// element will be absolutely positioned so cannot rely on parent element for width
|
||||
'width': $el.width() + 'px',
|
||||
'top': offset + 'px'
|
||||
});
|
||||
el.stick(this);
|
||||
}
|
||||
};
|
||||
stickAtTop.stop = function (el) {
|
||||
if (!el.isStopped()) {
|
||||
el.$fixedEl.css({
|
||||
'position': 'absolute',
|
||||
'top': this.getStoppingPosition(el)
|
||||
});
|
||||
el.stop();
|
||||
}
|
||||
};
|
||||
stickAtTop.unstop = function (el) {
|
||||
var offset = 0;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
offset = dialog.getOffsetFromEdge(el, this);
|
||||
}
|
||||
|
||||
el.$fixedEl.css({
|
||||
'position': '',
|
||||
'top': offset + 'px'
|
||||
});
|
||||
el.unstop();
|
||||
};
|
||||
|
||||
// Extension of sticky object to add behaviours specific to sticking to bottom of window
|
||||
var stickAtBottom = new Sticky('.js-stick-at-bottom-when-scrolling');
|
||||
stickAtBottom.edge = 'bottom';
|
||||
// Store furthest point sticky elements are allowed
|
||||
stickAtBottom.getEndOfScrollArea = function () {
|
||||
var header = $('.js-header:eq(0)');
|
||||
if (header.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return (header.offset().top + header.outerHeight()) + this.STOP_PADDING;
|
||||
};
|
||||
// position of the bottom edge when in the page flow
|
||||
stickAtBottom.getInPageEdgePosition = function ($el) {
|
||||
return $el.offset().top + $el.outerHeight();
|
||||
};
|
||||
stickAtBottom.getScrolledFrom = function (el) {
|
||||
if (_mode === 'dialog') {
|
||||
return dialog.getInPageEdgePosition(this);
|
||||
} else {
|
||||
return el.inPageEdgePosition;
|
||||
}
|
||||
};
|
||||
stickAtBottom.getScrollingTo = function (el) {
|
||||
var height = el.height;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
height = dialog.getHeight(this._els);
|
||||
}
|
||||
|
||||
return this.endOfScrollArea + height;
|
||||
};
|
||||
stickAtBottom.getStoppingPosition = function (el) {
|
||||
var offset = 0;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
offset = dialog.getOffsetFromEnd(el, this);
|
||||
}
|
||||
|
||||
return this.endOfScrollArea + offset;
|
||||
};
|
||||
stickAtBottom.windowNotPastScrolledFrom = function (windowPositions, scrolledFrom) {
|
||||
return scrolledFrom < windowPositions.bottom;
|
||||
};
|
||||
stickAtBottom.windowNotPastScrollingTo = function (windowPositions, scrollingTo) {
|
||||
return windowPositions.bottom > scrollingTo;
|
||||
};
|
||||
stickAtBottom.stick = function (el) {
|
||||
if (!el.isStuck()) {
|
||||
var $el = el.$fixedEl;
|
||||
var offset = 0;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
offset = dialog.getOffsetFromEdge(el, this);
|
||||
}
|
||||
|
||||
el.addShim('after');
|
||||
$el.css({
|
||||
// element will be absolutely positioned so cannot rely on parent element for width
|
||||
'width': $el.width() + 'px',
|
||||
'bottom': offset + 'px'
|
||||
});
|
||||
el.stick(this);
|
||||
}
|
||||
};
|
||||
stickAtBottom.stop = function (el) {
|
||||
if (!el.isStopped()) {
|
||||
el.$fixedEl.css({
|
||||
'position': 'absolute',
|
||||
'top': this.getStoppingPosition(el),
|
||||
'bottom': 'auto'
|
||||
});
|
||||
el.stop();
|
||||
}
|
||||
};
|
||||
stickAtBottom.unstop = function (el) {
|
||||
var offset = 0;
|
||||
|
||||
if (_mode === 'dialog') {
|
||||
offset = dialog.getOffsetFromEdge(el, this);
|
||||
}
|
||||
|
||||
el.$fixedEl.css({
|
||||
'position': '',
|
||||
'top': '',
|
||||
'bottom': offset + 'px'
|
||||
});
|
||||
el.unstop();
|
||||
};
|
||||
|
||||
NotifyModules.stickAtTopWhenScrolling = stickAtTop;
|
||||
NotifyModules.stickAtBottomWhenScrolling = stickAtBottom;
|
||||
window.NotifyModules = NotifyModules;
|
||||
})(window);
|
||||
@@ -1,66 +1,98 @@
|
||||
(function(window) {
|
||||
"use strict";
|
||||
|
||||
// HTML escaping utility to prevent XSS
|
||||
const escapeHtml = (unsafe) => {
|
||||
if (!unsafe) return '';
|
||||
return String(unsafe)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
window.NotifyModules['template-folder-form'] = function() {
|
||||
|
||||
this.start = function(templateFolderForm) {
|
||||
this.$form = $(templateFolderForm);
|
||||
this.form = templateFolderForm;
|
||||
|
||||
// remove the hidden unknown button - if you've got JS enabled then the action you want to do is implied by
|
||||
// which field is visible.
|
||||
this.$form.find('button[value=unknown]').remove();
|
||||
const unknownButton = this.form.querySelector('button[value=unknown]');
|
||||
if (unknownButton) {
|
||||
unknownButton.remove();
|
||||
}
|
||||
|
||||
this.$liveRegionCounter = this.$form.find('.selection-counter');
|
||||
this.liveRegionCounter = this.form.querySelector('.selection-counter');
|
||||
|
||||
// Critical: Verify live region counter exists before proceeding
|
||||
if (!this.liveRegionCounter) {
|
||||
console.error('templateFolderForm: .selection-counter element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get single channel data from DOM (must happen after DOM is ready)
|
||||
const addNewTemplateForm = document.querySelector('div[id=add_new_template_form]');
|
||||
this.$singleNotificationChannel = addNewTemplateForm ? addNewTemplateForm.getAttribute("data-channel") : null;
|
||||
this.$singleChannelService = addNewTemplateForm ? addNewTemplateForm.getAttribute("data-service") : null;
|
||||
this.singleNotificationChannel = addNewTemplateForm ? addNewTemplateForm.getAttribute("data-channel") : null;
|
||||
this.singleChannelService = addNewTemplateForm ? addNewTemplateForm.getAttribute("data-service") : null;
|
||||
|
||||
this.$liveRegionCounter.before(this.nothingSelectedButtons);
|
||||
this.$liveRegionCounter.before(this.itemsSelectedButtons);
|
||||
this.liveRegionCounter.insertAdjacentElement('beforebegin', this.nothingSelectedButtons);
|
||||
this.liveRegionCounter.insertAdjacentElement('beforebegin', this.itemsSelectedButtons);
|
||||
|
||||
// all the diff states that we want to show or hide
|
||||
// all the diff states that we want to show or hide - using Map for better performance
|
||||
this.states = [
|
||||
{
|
||||
key: 'nothing-selected-buttons',
|
||||
$el: this.$form.find('#nothing_selected'),
|
||||
el: this.form.querySelector('#nothing_selected'),
|
||||
cancellable: false
|
||||
},
|
||||
{
|
||||
key: 'items-selected-buttons',
|
||||
$el: this.$form.find('#items_selected'),
|
||||
el: this.form.querySelector('#items_selected'),
|
||||
cancellable: false
|
||||
},
|
||||
{
|
||||
key: 'move-to-existing-folder',
|
||||
$el: this.$form.find('#move_to_folder_radios'),
|
||||
el: this.form.querySelector('#move_to_folder_radios'),
|
||||
cancellable: true,
|
||||
setFocus: () => $('#move_to_folder_radios').focus(),
|
||||
setFocus: () => {
|
||||
const el = document.getElementById('move_to_folder_radios');
|
||||
if (el) el.focus();
|
||||
},
|
||||
action: 'move to folder',
|
||||
description: 'Press move to confirm or cancel to close'
|
||||
},
|
||||
{
|
||||
key: 'move-to-new-folder',
|
||||
$el: this.$form.find('#move_to_new_folder_form'),
|
||||
el: this.form.querySelector('#move_to_new_folder_form'),
|
||||
cancellable: true,
|
||||
setFocus: () => $('#move_to_new_folder_form').focus(),
|
||||
setFocus: () => {
|
||||
const el = document.getElementById('move_to_new_folder_form');
|
||||
if (el) el.focus();
|
||||
},
|
||||
action: 'move to new folder',
|
||||
description: 'Press add to new folder to confirm name or cancel to close'
|
||||
},
|
||||
{
|
||||
key: 'add-new-folder',
|
||||
$el: this.$form.find('#add_new_folder_form'),
|
||||
el: this.form.querySelector('#add_new_folder_form'),
|
||||
cancellable: true,
|
||||
setFocus: () => $('#add_new_folder_form').focus(),
|
||||
setFocus: () => {
|
||||
const el = document.getElementById('add_new_folder_form');
|
||||
if (el) el.focus();
|
||||
},
|
||||
action: 'new folder',
|
||||
description: 'Press add new folder to confirm name or cancel to close'
|
||||
},
|
||||
{
|
||||
key: 'add-new-template',
|
||||
$el: this.$form.find('#add_new_template_form'),
|
||||
el: this.form.querySelector('#add_new_template_form'),
|
||||
cancellable: true,
|
||||
setFocus: () => $('#add_new_template_form').focus(),
|
||||
setFocus: () => {
|
||||
const el = document.getElementById('add_new_template_form');
|
||||
if (el) el.focus();
|
||||
},
|
||||
action: 'new template',
|
||||
description: 'Press continue to confirm selection or cancel to close'
|
||||
}
|
||||
@@ -70,8 +102,12 @@
|
||||
this.states.filter(state => state.cancellable).forEach((x) => this.addCancelButton(x));
|
||||
this.states.filter(state => state.key === 'items-selected-buttons').forEach(x => this.addClearButton(x));
|
||||
|
||||
// make elements focusabled
|
||||
this.states.filter(state => state.setFocus).forEach(x => x.$el.attr('tabindex', '0'));
|
||||
// make elements focusable
|
||||
this.states.filter(state => state.setFocus).forEach(x => {
|
||||
if (x.el) {
|
||||
x.el.setAttribute('tabindex', '0');
|
||||
}
|
||||
});
|
||||
|
||||
this.addDescriptionsToStates();
|
||||
|
||||
@@ -79,7 +115,7 @@
|
||||
this.activateStickyElements();
|
||||
|
||||
// first off show the new template / new folder buttons
|
||||
this._lastState = this.$form.data('prev-state');
|
||||
this._lastState = this.form.dataset.prevState;
|
||||
if (this._lastState === undefined) {
|
||||
this.selectActionButtons();
|
||||
} else {
|
||||
@@ -87,43 +123,55 @@
|
||||
this.render();
|
||||
}
|
||||
|
||||
this.$form.on('click', 'button.usa-button', (event) => this.actionButtonClicked(event));
|
||||
this.$form.on('change', 'input[type=checkbox]', () => this.templateFolderCheckboxChanged());
|
||||
this.$form.on('change', 'input[name="add_template_by_template_type"]', () => this.templateTypeChanged());
|
||||
this.form.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button.usa-button');
|
||||
if (button) {
|
||||
this.actionButtonClicked(event);
|
||||
}
|
||||
});
|
||||
this.form.addEventListener('change', (event) => {
|
||||
if (event.target.matches('input[type=checkbox]')) {
|
||||
this.templateFolderCheckboxChanged();
|
||||
} else if (event.target.matches('input[name="add_template_by_template_type"]')) {
|
||||
this.templateTypeChanged();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.addDescriptionsToStates = function () {
|
||||
let id, description;
|
||||
|
||||
$.each(this.states.filter(state => 'description' in state), (_, state) => {
|
||||
id = `${state.key}__description`;
|
||||
description = `<p class="usa-sr-only" id="${id}">${state.description}</p>`;
|
||||
state.$el
|
||||
.prepend(description)
|
||||
.attr('aria-describedby', id);
|
||||
this.states.filter(state => 'description' in state).forEach(state => {
|
||||
const id = `${escapeHtml(state.key)}__description`;
|
||||
const description = `<p class="usa-sr-only" id="${id}">${escapeHtml(state.description)}</p>`;
|
||||
if (state.el) {
|
||||
state.el.insertAdjacentHTML('afterbegin', description);
|
||||
state.el.setAttribute('aria-describedby', id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.activateStickyElements = function() {
|
||||
var oldClass = 'js-will-stick-at-bottom-when-scrolling';
|
||||
var newClass = 'js-stick-at-bottom-when-scrolling';
|
||||
const oldClass = 'js-will-stick-at-bottom-when-scrolling';
|
||||
const newClass = 'js-stick-at-bottom-when-scrolling';
|
||||
|
||||
this.states.forEach(state => {
|
||||
state.$el
|
||||
.find('.' + oldClass)
|
||||
.removeClass(oldClass)
|
||||
.addClass(newClass);
|
||||
if (state.el) {
|
||||
state.el.querySelectorAll('.' + oldClass).forEach(el => {
|
||||
el.classList.remove(oldClass);
|
||||
el.classList.add(newClass);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.addCancelButton = function(state) {
|
||||
let selector = `[value=${state.key}]`;
|
||||
let $cancel = this.makeButton('Cancel', {
|
||||
const selector = `[value=${state.key}]`;
|
||||
const cancel = this.makeButton('Cancel', {
|
||||
'onclick': () => {
|
||||
|
||||
// clear existing data
|
||||
state.$el.find('input:radio').prop('checked', false);
|
||||
state.$el.find('input:text').val('');
|
||||
if (state.el) {
|
||||
state.el.querySelectorAll('input[type="radio"]').forEach(input => input.checked = false);
|
||||
state.el.querySelectorAll('input[type="text"]').forEach(input => input.value = '');
|
||||
}
|
||||
|
||||
// go back to action buttons
|
||||
this.selectActionButtons(selector);
|
||||
@@ -132,16 +180,20 @@
|
||||
'nonvisualText': state.action
|
||||
});
|
||||
|
||||
state.$el.find('[type=submit]').after($cancel);
|
||||
if (state.el) {
|
||||
const submitButton = state.el.querySelector('[type=submit]');
|
||||
if (submitButton) {
|
||||
submitButton.insertAdjacentElement('afterend', cancel);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.addClearButton = function(state) {
|
||||
let selector = 'button[value=add-new-template]';
|
||||
let $clear = this.makeButton('Clear', {
|
||||
const selector = 'button[value=add-new-template]';
|
||||
const clear = this.makeButton('Clear', {
|
||||
'onclick': () => {
|
||||
|
||||
// uncheck all templates and folders
|
||||
this.$form.find('input:checkbox').prop('checked', false);
|
||||
this.form.querySelectorAll('input[type="checkbox"]').forEach(input => input.checked = false);
|
||||
|
||||
// go back to action buttons
|
||||
this.selectActionButtons(selector);
|
||||
@@ -149,29 +201,47 @@
|
||||
'nonvisualText': "selection"
|
||||
});
|
||||
|
||||
state.$el.find('.template-list-selected-counter').append($clear);
|
||||
if (state.el) {
|
||||
const counter = state.el.querySelector('.template-list-selected-counter');
|
||||
if (counter) {
|
||||
counter.appendChild(clear);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.makeButton = (text, opts) => {
|
||||
let $btn = $('<a href=""></a>')
|
||||
.html(text)
|
||||
.addClass('usa-link js-cancel')
|
||||
// isn't set if cancelSelector is undefined
|
||||
.data('target', opts.cancelSelector || undefined)
|
||||
.attr('tabindex', '0')
|
||||
.on('click keydown', event => {
|
||||
// space, enter or no keyCode (must be mouse input)
|
||||
if ([13, 32, undefined].indexOf(event.keyCode) > -1) {
|
||||
event.preventDefault();
|
||||
if (opts.hasOwnProperty('onclick')) { opts.onclick(); }
|
||||
}
|
||||
});
|
||||
const btn = document.createElement('a');
|
||||
btn.href = '';
|
||||
btn.textContent = text;
|
||||
btn.classList.add('usa-link', 'js-cancel');
|
||||
|
||||
if (opts.hasOwnProperty('nonvisualText')) {
|
||||
$btn.append(`<span class="usa-sr-only"> ${opts.nonvisualText}</span>`);
|
||||
// isn't set if cancelSelector is undefined
|
||||
if (opts.cancelSelector) {
|
||||
btn.dataset.target = opts.cancelSelector;
|
||||
}
|
||||
btn.setAttribute('tabindex', '0');
|
||||
|
||||
const handler = event => {
|
||||
// space, enter or no keyCode (must be mouse input)
|
||||
if ([13, 32, undefined].indexOf(event.keyCode) > -1) {
|
||||
event.preventDefault();
|
||||
if (opts.hasOwnProperty('onclick')) {
|
||||
opts.onclick();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return $btn;
|
||||
btn.addEventListener('click', handler);
|
||||
btn.addEventListener('keydown', handler);
|
||||
|
||||
if (opts.hasOwnProperty('nonvisualText')) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'usa-sr-only';
|
||||
span.textContent = ' ' + opts.nonvisualText;
|
||||
btn.appendChild(span);
|
||||
}
|
||||
|
||||
return btn;
|
||||
};
|
||||
|
||||
this.selectActionButtons = function (targetSelector) {
|
||||
@@ -181,7 +251,10 @@
|
||||
this.currentState = 'nothing-selected-buttons';
|
||||
this.templateFolderCheckboxChanged();
|
||||
if (targetSelector) {
|
||||
$(targetSelector).focus();
|
||||
const target = document.querySelector(targetSelector);
|
||||
if (target) {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,14 +267,16 @@
|
||||
};
|
||||
|
||||
this.actionButtonClicked = function(event) {
|
||||
this.currentState = $(event.currentTarget).val();
|
||||
const button = event.target.closest('button.usa-button') || event.target;
|
||||
this.currentState = button.value;
|
||||
|
||||
if (event.currentTarget.value === 'add-new-template' && this.$singleNotificationChannel) {
|
||||
if (this.currentState === 'add-new-template' && this.singleNotificationChannel) {
|
||||
event.preventDefault();
|
||||
window.location = "/services/" + this.$singleChannelService + "/templates/add-" + this.$singleNotificationChannel;
|
||||
window.location = "/services/" + encodeURIComponent(this.singleChannelService) + "/templates/add-" + encodeURIComponent(this.singleNotificationChannel);
|
||||
} else if (this.currentState === 'add-new-template') {
|
||||
// Check if a template type is selected
|
||||
const selectedTemplateType = this.$form.find('input[name="add_template_by_template_type"]:checked').val();
|
||||
const selectedInput = this.form.querySelector('input[name="add_template_by_template_type"]:checked');
|
||||
const selectedTemplateType = selectedInput ? selectedInput.value : null;
|
||||
|
||||
if (selectedTemplateType) {
|
||||
// Template type is selected, let the form submit normally
|
||||
@@ -209,7 +284,7 @@
|
||||
} else {
|
||||
// No template type selected, show the selection UI
|
||||
event.preventDefault();
|
||||
this.$form.find('input[type=checkbox]').prop('checked', false);
|
||||
this.form.querySelectorAll('input[type=checkbox]').forEach(input => input.checked = false);
|
||||
this.selectionStatus.update({ total: 0, templates: 0, folders: 0 });
|
||||
|
||||
if (this.stateChanged()) {
|
||||
@@ -254,15 +329,19 @@
|
||||
return results.join(', ') + ' selected';
|
||||
},
|
||||
'update': numSelected => {
|
||||
let message = (numSelected.total > 0) ? this.selectionStatus.selected(numSelected) : this.selectionStatus.default;
|
||||
const message = (numSelected.total > 0) ? this.selectionStatus.selected(numSelected) : this.selectionStatus.default;
|
||||
|
||||
$('.template-list-selected-counter__count').html(message);
|
||||
this.$liveRegionCounter.html(message);
|
||||
const counters = document.querySelectorAll('.template-list-selected-counter__count');
|
||||
counters.forEach(counter => counter.textContent = message);
|
||||
|
||||
if (this.liveRegionCounter) {
|
||||
this.liveRegionCounter.textContent = message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.templateFolderCheckboxChanged = function() {
|
||||
let numSelected = this.countSelectedCheckboxes();
|
||||
const numSelected = this.countSelectedCheckboxes();
|
||||
|
||||
if (this.currentState === 'nothing-selected-buttons' && numSelected.total !== 0) {
|
||||
// user has just selected first item
|
||||
@@ -278,8 +357,11 @@
|
||||
|
||||
this.selectionStatus.update(numSelected);
|
||||
|
||||
$('.template-list-selected-counter').toggle(this.hasCheckboxes());
|
||||
|
||||
const counters = document.querySelectorAll('.template-list-selected-counter');
|
||||
const shouldShow = this.hasCheckboxes();
|
||||
counters.forEach(counter => {
|
||||
counter.style.display = shouldShow ? '' : 'none';
|
||||
});
|
||||
};
|
||||
|
||||
this.templateTypeChanged = function() {
|
||||
@@ -287,24 +369,31 @@
|
||||
};
|
||||
|
||||
this.updateContinueButtonState = function() {
|
||||
const selectedTemplateType = this.$form.find('input[name="add_template_by_template_type"]:checked').val();
|
||||
const continueButton = this.$form.find('#add_new_template_form button[value="add-new-template"]');
|
||||
const selectedInput = this.form.querySelector('input[name="add_template_by_template_type"]:checked');
|
||||
const selectedTemplateType = selectedInput ? selectedInput.value : null;
|
||||
const continueButton = this.form.querySelector('#add_new_template_form button[value="add-new-template"]');
|
||||
|
||||
if (selectedTemplateType) {
|
||||
continueButton.prop('disabled', false);
|
||||
} else {
|
||||
continueButton.prop('disabled', true);
|
||||
if (continueButton) {
|
||||
continueButton.disabled = !selectedTemplateType;
|
||||
}
|
||||
};
|
||||
|
||||
this.hasCheckboxes = function() {
|
||||
return !!this.$form.find('input:checkbox').length;
|
||||
return this.form.querySelectorAll('input[type="checkbox"]').length > 0;
|
||||
};
|
||||
|
||||
this.countSelectedCheckboxes = function() {
|
||||
const allSelected = this.$form.find('input:checkbox:checked');
|
||||
const templates = allSelected.filter((_, el) => $(el).siblings('.template-list-template').length > 0).length;
|
||||
const folders = allSelected.filter((_, el) => $(el).siblings('.template-list-folder').length > 0).length;
|
||||
const allSelected = Array.from(this.form.querySelectorAll('input[type="checkbox"]:checked'));
|
||||
// Check for sibling elements to determine if checkbox is for template or folder
|
||||
// This matches the original jQuery logic: $(el).siblings('.template-list-template')
|
||||
const templates = allSelected.filter(el => {
|
||||
if (!el.parentElement) return false;
|
||||
return el.parentElement.querySelector('.template-list-template') !== null;
|
||||
}).length;
|
||||
const folders = allSelected.filter(el => {
|
||||
if (!el.parentElement) return false;
|
||||
return el.parentElement.querySelector('.template-list-folder') !== null;
|
||||
}).length;
|
||||
const results = {
|
||||
'templates': templates,
|
||||
'folders': folders,
|
||||
@@ -314,48 +403,79 @@
|
||||
};
|
||||
|
||||
this.render = function() {
|
||||
let currentStateObj = this.states.filter(state => { return (state.key === this.currentState); })[0];
|
||||
const currentStateObj = this.states.find(state => state.key === this.currentState);
|
||||
let scrollTop;
|
||||
|
||||
// detach everything, unless they are the currentState
|
||||
this.states.forEach(
|
||||
state => (state.key === this.currentState ? this.$liveRegionCounter.before(state.$el) : state.$el.detach())
|
||||
);
|
||||
this.states.forEach(state => {
|
||||
if (state.key === this.currentState) {
|
||||
if (state.el) {
|
||||
this.liveRegionCounter.insertAdjacentElement('beforebegin', state.el);
|
||||
}
|
||||
} else {
|
||||
if (state.el && state.el.parentElement) {
|
||||
state.el.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (this.currentState === 'add-new-template') {
|
||||
this.$form.find('.template-list-item').addClass('js-hidden');
|
||||
$('.live-search').addClass('js-hidden');
|
||||
$('#breadcrumb-template-folders').addClass('js-hidden');
|
||||
$('#template-list').addClass('js-hidden');
|
||||
this.$form.find('input[type=checkbox]').prop('checked', false);
|
||||
this.form.querySelectorAll('.template-list-item').forEach(el => el.classList.add('js-hidden'));
|
||||
|
||||
const liveSearch = document.querySelector('.live-search');
|
||||
if (liveSearch) liveSearch.classList.add('js-hidden');
|
||||
|
||||
const breadcrumb = document.getElementById('breadcrumb-template-folders');
|
||||
if (breadcrumb) breadcrumb.classList.add('js-hidden');
|
||||
|
||||
const templateList = document.getElementById('template-list');
|
||||
if (templateList) templateList.classList.add('js-hidden');
|
||||
|
||||
this.form.querySelectorAll('input[type=checkbox]').forEach(input => input.checked = false);
|
||||
this.selectionStatus.update({ total: 0, templates: 0, folders: 0 });
|
||||
|
||||
$('#page-title').text('New Template');
|
||||
$('#page-description').text('Every message starts with a template. Choose to start with a blank template or copy an existing template.');
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
if (pageTitle) pageTitle.textContent = 'New Template';
|
||||
|
||||
const pageDescription = document.getElementById('page-description');
|
||||
if (pageDescription) pageDescription.textContent = 'Every message starts with a template. Choose to start with a blank template or copy an existing template.';
|
||||
|
||||
document.title = 'New Templates';
|
||||
|
||||
// Disable Continue button initially and update based on selection
|
||||
this.updateContinueButtonState();
|
||||
} else {
|
||||
this.$form.find('.template-list-item').removeClass('js-hidden');
|
||||
$('.live-search').removeClass('js-hidden');
|
||||
$('#breadcrumb-template-folders').removeClass('js-hidden');
|
||||
$('#template-list').removeClass('js-hidden');
|
||||
this.form.querySelectorAll('.template-list-item').forEach(el => el.classList.remove('js-hidden'));
|
||||
|
||||
const liveSearch = document.querySelector('.live-search');
|
||||
if (liveSearch) liveSearch.classList.remove('js-hidden');
|
||||
|
||||
const breadcrumb = document.getElementById('breadcrumb-template-folders');
|
||||
if (breadcrumb) breadcrumb.classList.remove('js-hidden');
|
||||
|
||||
const templateList = document.getElementById('template-list');
|
||||
if (templateList) templateList.classList.remove('js-hidden');
|
||||
|
||||
const pageTitle = document.getElementById('page-title');
|
||||
if (pageTitle) pageTitle.textContent = 'Select or create a template';
|
||||
|
||||
const pageDescription = document.getElementById('page-description');
|
||||
if (pageDescription) pageDescription.textContent = 'Every message starts with a template. To send, choose or create a template.';
|
||||
|
||||
$('#page-title').text('Select or create a template');
|
||||
$('#page-description').text('Every message starts with a template. To send, choose or create a template.');
|
||||
document.title = 'Select or create a template';
|
||||
}
|
||||
|
||||
if (currentStateObj && 'setFocus' in currentStateObj) {
|
||||
scrollTop = $(window).scrollTop();
|
||||
scrollTop = window.scrollY;
|
||||
currentStateObj.setFocus();
|
||||
$(window).scrollTop(scrollTop);
|
||||
window.scrollTo(window.scrollX, scrollTop);
|
||||
}
|
||||
};
|
||||
|
||||
this.nothingSelectedButtons = $(`
|
||||
<div id="nothing_selected">
|
||||
const createNothingSelectedButtons = () => {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'nothing_selected';
|
||||
div.innerHTML = `
|
||||
<div class="js-stick-at-bottom-when-scrolling">
|
||||
<div class="usa-button-group">
|
||||
<button class="usa-button" value="add-new-template" aria-expanded="false" role="button">
|
||||
@@ -371,11 +491,14 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).get(0);
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
|
||||
this.itemsSelectedButtons = $(`
|
||||
<div id="items_selected">
|
||||
const createItemsSelectedButtons = () => {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'items_selected';
|
||||
div.innerHTML = `
|
||||
<div class="js-stick-at-bottom-when-scrolling">
|
||||
<div class="usa-button-group">
|
||||
<button class="usa-button" value="move-to-existing-folder" aria-expanded="false" role="button">
|
||||
@@ -387,12 +510,16 @@
|
||||
</div>
|
||||
<div class="template-list-selected-counter" aria-hidden="true">
|
||||
<span class="template-list-selected-counter__count text-base" aria-hidden="true">
|
||||
${this.selectionStatus.selected(1)}
|
||||
${this.selectionStatus.selected({ templates: 1, folders: 0, total: 1 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).get(0);
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
|
||||
this.nothingSelectedButtons = createNothingSelectedButtons();
|
||||
this.itemsSelectedButtons = createItemsSelectedButtons();
|
||||
};
|
||||
|
||||
})(window);
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
window.NotifyModules['update-status'] = function() {
|
||||
|
||||
const getRenderer = $component => response => $component.html(
|
||||
response.html
|
||||
);
|
||||
const getRenderer = component => response => {
|
||||
component.innerHTML = response.html;
|
||||
};
|
||||
|
||||
const throttle = (func, limit) => {
|
||||
|
||||
@@ -41,39 +41,38 @@
|
||||
|
||||
let id = 'update-status';
|
||||
|
||||
this.$component = $(component);
|
||||
this.$textbox = $('#' + this.$component.data('target'));
|
||||
this.component = component;
|
||||
this.textbox = document.getElementById(this.component.dataset.target);
|
||||
|
||||
this.$component
|
||||
.attr('id', id);
|
||||
this.component.setAttribute('id', id);
|
||||
|
||||
this.$textbox
|
||||
.attr(
|
||||
'aria-describedby',
|
||||
(
|
||||
this.$textbox.attr('aria-describedby') || ''
|
||||
) + (
|
||||
this.$textbox.attr('aria-describedby') ? ' ' : ''
|
||||
) + id
|
||||
)
|
||||
.on('input', throttle(this.update, 150))
|
||||
.trigger('input');
|
||||
const currentAriaDescribedBy = this.textbox.getAttribute('aria-describedby') || '';
|
||||
const newAriaDescribedBy = currentAriaDescribedBy + (currentAriaDescribedBy ? ' ' : '') + id;
|
||||
this.textbox.setAttribute('aria-describedby', newAriaDescribedBy);
|
||||
|
||||
this.textbox.addEventListener('input', throttle(this.update, 150));
|
||||
this.textbox.dispatchEvent(new Event('input'));
|
||||
|
||||
};
|
||||
|
||||
this.update = () => {
|
||||
|
||||
$.ajax(
|
||||
this.$component.data('updates-url'),
|
||||
{
|
||||
'method': 'post',
|
||||
'data': this.$textbox.parents('form').serialize()
|
||||
}
|
||||
).done(
|
||||
getRenderer(this.$component)
|
||||
).fail(
|
||||
() => {}
|
||||
);
|
||||
const form = this.textbox.closest('form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch(this.component.dataset.updatesUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP error');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(getRenderer(this.component))
|
||||
.catch(() => {});
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (window.uswds && typeof window.uswds.init === 'function') {
|
||||
console.log("Calling USWDS init");
|
||||
window.uswds.init();
|
||||
} else {
|
||||
console.error("USWDS not found or init is not a function");
|
||||
}
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/* eslint-disable no-var */
|
||||
(function uswdsInit() {
|
||||
"use strict";
|
||||
|
||||
var loadingClass = "usa-js-loading";
|
||||
var fallback;
|
||||
|
||||
document.documentElement.classList.add(loadingClass);
|
||||
function revertClass() {
|
||||
document.documentElement.classList.remove(loadingClass);
|
||||
}
|
||||
|
||||
fallback = setTimeout(revertClass, 8000);
|
||||
|
||||
function verifyLoaded() {
|
||||
if (window.uswdsPresent) {
|
||||
clearTimeout(fallback);
|
||||
revertClass();
|
||||
window.removeEventListener("load", verifyLoaded, true);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("load", verifyLoaded, true);
|
||||
})();
|
||||
2
app/assets/js/uswds-init.min.js
vendored
2
app/assets/js/uswds-init.min.js
vendored
@@ -1,2 +0,0 @@
|
||||
!function(){"use strict";var n,e="usa-js-loading";function t(){document.documentElement.classList.remove(e)}document.documentElement.classList.add(e),n=setTimeout(t,8e3),window.addEventListener("load",function e(){window.uswdsPresent&&(clearTimeout(n),t(),window.removeEventListener("load",e,!0))},!0)}();
|
||||
//# sourceMappingURL=uswds-init.min.js.map
|
||||
@@ -14,8 +14,8 @@ Learn how to [personalize messages](/using-notify/how-to) to increase response.
|
||||
Learn about message _parts_ and [how limits are calculated](/using-notify/pricing).
|
||||
|
||||
5. ## Start sending messages
|
||||
To remove the restrictions of Trial Mode and begin sending messages to clients complete the <a class="usa-link usa-link--external" href="https://docs.google.com/forms/d/1fnaBtxuGf3q-OdGVyt2LqBKvp9_P21kmKJa0yIK8rWM/edit">Go-Live Form</a>.
|
||||
We’ll respond within one business day.
|
||||
To remove the restrictions of Trial Mode and begin sending messages to people complete the <a class="usa-link usa-link--external" href="https://docs.google.com/forms/d/1fnaBtxuGf3q-OdGVyt2LqBKvp9_P21kmKJa0yIK8rWM/edit">Go-Live Form</a>.
|
||||
We'll respond within one business day.
|
||||
|
||||
### Questions?
|
||||
[Contact the Notify team](/support)
|
||||
|
||||
@@ -80,14 +80,14 @@
|
||||
document submission, and maintenance reminders.
|
||||
</p>
|
||||
<h3>Appointment reminders</h3>
|
||||
<p class="text-light">Benchmark: Clients were <span class="text-bold">79%</span> more
|
||||
<p class="text-light">Benchmark: People were <span class="text-bold">79%</span> more
|
||||
likely to keep their appointment after receiving a text reminder.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<p>You will likely see more completed appointments.</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-light">Benchmark: Clients were <span class="text-bold">55%</span> more
|
||||
<p class="text-light">Benchmark: People were <span class="text-bold">55%</span> more
|
||||
likely to complete an interview after receiving an interview reminder</p>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
<h3>Document submission</h3>
|
||||
<p class="text-light">
|
||||
Benchmark: Clients were <span class="text-bold">6%</span> more likely to complete document submission after
|
||||
Benchmark: People were <span class="text-bold">6%</span> more likely to complete document submission after
|
||||
receiving a customized list of required documents via text
|
||||
</p>
|
||||
<ul>
|
||||
|
||||
@@ -189,7 +189,7 @@
|
||||
"image_src": asset_url('images/project-management.svg'),
|
||||
"card_heading": "Case management systems",
|
||||
"alt_text": "Graphic representing project management",
|
||||
"p_text": "When it makes sense, include information about texts being sent to specific clients on individual splash
|
||||
"p_text": "When it makes sense, include information about texts being sent to specific people on individual splash
|
||||
pages or within case management notes.",
|
||||
},
|
||||
] %}
|
||||
|
||||
Reference in New Issue
Block a user