Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UHF-9216 Character counter #264

Merged
merged 16 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .stylelintrc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ plugins:
rules:
# scss/at-import-no-partial-leading-underscore: null
string-quotes: single # We use single quotes in SCSS
declaration-block-no-redundant-longhand-properties: null # We prefer lonhand properties for clarity in SCSS
selector-class-pattern: null # We're not too stict about selector class pattern
max-line-length: null # Lets not limit line length at this point
declaration-block-no-redundant-longhand-properties: null # We prefer longhand properties for clarity in SCSS
selector-class-pattern: null # We're not too strict about selector class pattern
max-line-length: null # Let's not limit line length at this point
declaration-empty-line-before: never # No need for empty line before declaration
order/properties-alphabetical-order: true # We're following alphabetical order in properties
custom-property-pattern: "^([a-z][a-z0-9]*)(--?[a-z0-9]+)*$" # kebab-case pattern with allowed bem-like double --
max-nesting-depth: 4 # We're not using normal nesting rule of 3, as admin theme is all about overriding styles
overrides:
- files:
- '**/ckeditor.scss'
Expand Down
4 changes: 2 additions & 2 deletions dist/css/styles.min.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions dist/js/characterCounter.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/js/heroToggle.min.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions hdbt_admin.libraries.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
character-counter:
js:
dist/js/characterCounter.min.js: {}
version: 1.0.0
dependencies:
- core/drupal
- core/once

language-switcher:
js:
dist/js/languageSwitcher.min.js: {}
Expand Down
37 changes: 37 additions & 0 deletions hdbt_admin.theme
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ function _hdbt_admin_form_template_suggestions(array &$suggestions, array &$vari
if (in_array('koro', $variables['element']['#parents'], TRUE)) {
$suggestions[] = $variables['theme_hook_original'] . '__koro';
}
if (
in_array('field_hero_title', $variables['element']['#parents'], TRUE) ||
in_array('field_hero_desc', $variables['element']['#parents'], TRUE)
) {
$suggestions[] = $variables['theme_hook_original'] . '__character_count';
}
}

/**
Expand Down Expand Up @@ -449,3 +455,34 @@ function hdbt_admin_preprocess_field_multiple_value_form(&$variables) {
}
}
}

/**
* Implements hook_preprocess_HOOK().
*/
function hdbt_admin_preprocess_form_element(array &$variables) {
// Map the counter input tag, step value and total value to variables.
$counter_elements = [
'field_hero_title' => [
'counter_input_tag' => 'input',
'counter_step' => 0,
'counter_total' => 55,
],
'field_hero_desc' => [
'counter_input_tag' => 'textarea',
'counter_step' => 160,
'counter_total' => 200,
],
];

// Set the mapped values to corresponding fields.
foreach ($counter_elements as $counter_element => $counter_values) {
if (
array_key_exists('name', $variables) &&
str_contains($variables['name'], $counter_element)
) {
foreach ($counter_values as $key => $value) {
$variables[$key] = $value;
}
}
}
}
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"eslint-plugin-import": "^2.25.3",
"expose-loader": "^4.0.0",
"glob": "^10.0.0",
"hds-design-tokens": "^2.0.0",
"hds-design-tokens": "^3.0.0",
"html-loader": "^4.0.0",
"html-webpack-plugin": "^5.3.2",
"husky": "^8.0.0",
Expand Down
150 changes: 150 additions & 0 deletions src/js/characterCounter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
'use strict';

((Drupal, once) => {

const WARNING_GREEN = 0;
const WARNING_YELLOW = 1;
const WARNING_RED = 2;

// Translate character count texts.
const characterCounter = (count, total) => {
return Drupal.t('Characters: @counted/@total', {
'@counted': count,
'@total': total
}, {context: 'Character counter'});
};

// Determine the warning type based on character count.
const processWarningType = (count, step, total) => {
return (count >= total) ? WARNING_RED : (count > step && step > 0) ? WARNING_YELLOW : WARNING_GREEN;
};

// Translate warning texts based on warning type.
const characterWarning = (type, step, total, input, element) => {

// The maximum length has been reached, show warning.
if (type === WARNING_RED) {
// Show the warning text.
element.classList.remove('is-hidden');

// Use different translations for input and textarea fields.
if (input === 'input') {
return Drupal.t('The recommended maximum length for the title is @total characters.', {
'@total': total
}, {context: 'Character counter'});
} else {
return Drupal.t('The recommended maximum length for the lead is @total characters.', {
'@total': total
}, {context: 'Character counter'});
}
}

// The character length is more than 160 chars, show warning.
if (type === WARNING_YELLOW) {
element.classList.remove('is-hidden');
return Drupal.t('Consider shortening. A lead under @step characters works best for search engines.', {
'@step': step
}, {context: 'Character counter'});
}

// Remove warnings.
element.classList.add('is-hidden');
return WARNING_GREEN;
};

// Convert HTML tags to single spaces.
const convertHtmlTags = (data) => {
return data
// Replace all HTML tags with a single space.
.replace(/<[^>]*>/g, ' ')
// Replace all consecutive whitespace characters with a single space.
.replace(/\s+/g, ' ')
// Replace HTML character entities with a single space.
.replace(/&#?[a-z0-9]+;/i, ' ')
// Remove leading and trailing whitespace and return the length.
.trim().length;
};

Drupal.behaviors.characterCounter = {
attach: function attach(context) {
// Get all character counter instances using once().
const counterInstances = once('character-counter', '[data-character-counter]', context);

if (!counterInstances) {
return;
}

// Loop through all character counter instances.
counterInstances.forEach((counterInstance) => {
const counterCounter = counterInstance.dataset.characterCounter;
const counterInputTag = counterInstance.dataset.counterInputTag;
const counterTotalChars = counterInstance.dataset.counterTotal;
const counterStepChars = counterInstance.dataset.counterStep;
const counterWarning = counterInstance.querySelector('.character-counter__warning');
const formItem = context.querySelector(`.${counterCounter}`);

if (!formItem) {
return;
}

const charCounter = formItem.querySelector('[data-counter-id]');
const charWarning = formItem.querySelector('[data-warning-id]');
const textInput = formItem.querySelector(counterInputTag);

if (!textInput) {
return;
}

// Set initial warning type.
let warningType = WARNING_GREEN;

// The textarea counter needs to be inserted after the form item.
// Otherwise, it will be shown in a wrong position in the DOM.
if (
counterInputTag === 'textarea' &&
formItem.parentElement.classList.contains('form-item') &&
formItem.parentElement.querySelector('.form-item__description')
) {
formItem.parentElement
.querySelector('.form-item__description')
.insertAdjacentElement('afterend', counterInstance);
}

// Set initial value for the character counter.
if (textInput.value.length > 0) {
warningType = processWarningType(textInput.value.length, counterStepChars, counterTotalChars);
charCounter.textContent = characterCounter(textInput.value.length, counterTotalChars);
charWarning.textContent = characterWarning(warningType, counterStepChars, counterTotalChars, counterInputTag, counterWarning);
}

// Handle input tag and textarea tags separately.
if (counterInputTag === 'input') {
// Add event listener to the input tag and process
// the charCounter and charWarning.
textInput.addEventListener('input', function () {
warningType = processWarningType(textInput.value.length, counterStepChars, counterTotalChars);
charCounter.textContent = characterCounter(textInput.value.length, counterTotalChars);
charWarning.textContent = characterWarning(warningType, counterStepChars, counterTotalChars, counterInputTag, counterWarning);
});
} else {
setTimeout(function () {
const ckeditorEditable = textInput.parentElement.querySelector('.ck-editor__editable');

// Add event listener to the textarea tag (CKEditor) and process
// the charCounter and charWarning.
if (ckeditorEditable && ckeditorEditable.ckeditorInstance) {
const editor = ckeditorEditable.ckeditorInstance;
editor.model.document.on('change:data', () => {
// Output the number of words to the counter.
warningType = processWarningType(convertHtmlTags(editor.getData()), counterStepChars, counterTotalChars);
charCounter.textContent = characterCounter(convertHtmlTags(editor.getData()), counterTotalChars);
charWarning.textContent = characterWarning(warningType, counterStepChars, counterTotalChars, counterInputTag, counterWarning);
});
}
});
}
});
},
};

})(Drupal, once);
9 changes: 4 additions & 5 deletions src/js/heroToggle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Functionality that adds a hero to an article if the hero-checkbox is checked.
* Functionality that adds a hero to entity if the hero-checkbox is checked.
*/

// Boolean checkbox field that determines if there is hero added or not.
Expand All @@ -8,10 +8,9 @@ const heroCheckbox = document.querySelector(
);

// Helper function to trigger event
function triggerEvent(el, type) {
var e = document.createEvent('Event');
e.initEvent(type, false, true);
el.dispatchEvent(e);
function triggerEvent(element, type) {
const event = new Event(type);
element.dispatchEvent(event);
}

if (heroCheckbox) {
Expand Down
2 changes: 2 additions & 0 deletions src/scss/06_components/forms/__index.scss
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
@import 'character-counter';
@import 'dropbutton';
@import 'form-items';
26 changes: 26 additions & 0 deletions src/scss/06_components/forms/_character-counter.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
.character-counter {
margin-top: -$spacing-quarter;
}

.character-counter__count {
color: $color-black-70;
font-size: remify(14px);
}

.character-counter__warning {
align-items: center;
background: $color-alert-light;
border-left: 10px solid $color-alert-dark;
display: flex;
margin-top: $spacing-half;
padding: $spacing-half;
}

.character-counter__icon {
color: $color-alert-dark;
}

.character-counter__warning-text {
color: $color-black;
margin-inline-start: $spacing-half;
}
5 changes: 5 additions & 0 deletions src/scss/06_components/forms/_form-items.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.hdbt-admin .form-item__description {
color: $color-black-70;
font-size: remify(14px);
max-width: none;
}
1 change: 0 additions & 1 deletion src/scss/06_components/layout/_page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
}
}

ul.paragraph-selection.dropbutton,
ul.paragraph-selection.dropbutton {
@media (min-width: $breakpoint-s) {
width: 500px;
Expand Down
Loading