From 087b8d3f727d33e7ad2c7c3e9996d2a6ba3291c4 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Tue, 31 Dec 2024 15:59:54 +0000 Subject: [PATCH] Add method for generating Character count to DefaultComponentGenerator --- .../CharacterCountOptions.cs | 18 +++++ ...efaultComponentGenerator.CharacterCount.cs | 80 +++++++++++++++++++ .../DefaultComponentGenerator.Hint.cs | 9 ++- .../ComponentGeneration/HintOptions.cs | 4 +- .../ComponentGeneration/ComponentTests.cs | 7 ++ 5 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.CharacterCount.cs diff --git a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/CharacterCountOptions.cs b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/CharacterCountOptions.cs index a2c32e19..4da56923 100644 --- a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/CharacterCountOptions.cs +++ b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/CharacterCountOptions.cs @@ -28,6 +28,24 @@ public record CharacterCountOptions public CharacterCountOptionsLocalizedText? WordsUnderLimitText { get; set; } public IHtmlContent? WordsAtLimitText { get; set; } public CharacterCountOptionsLocalizedText? WordsOverLimitText { get; set; } + + internal void Validate() + { + if (Label is null) + { + throw new InvalidOptionsException(GetType(), $"{nameof(Label)} must be specified."); + } + + if (Id is null) + { + throw new InvalidOptionsException(GetType(), $"{nameof(Id)} must be specified."); + } + + if (Name is null) + { + throw new InvalidOptionsException(GetType(), $"{nameof(Name)} must be specified."); + } + } } public record CharacterCountCountOptionsMessage diff --git a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.CharacterCount.cs b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.CharacterCount.cs new file mode 100644 index 00000000..d9b71999 --- /dev/null +++ b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.CharacterCount.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.AspNetCore.Html; + +namespace GovUk.Frontend.AspNetCore.ComponentGeneration; + +public partial class DefaultComponentGenerator +{ + internal const string CharacterCountElement = "div"; + + /// + public virtual HtmlTagBuilder GenerateCharacterCount(CharacterCountOptions options) + { + ArgumentNullException.ThrowIfNull(options); + options.Validate(); + + var hasNoLimit = options.MaxLength is null && options.MaxWords is null; + + return new HtmlTagBuilder(CharacterCountElement) + .WithCssClass("govuk-character-count") + .WithAttribute("data-module", "govuk-character-count", encodeValue: false) + .WhenNotNull(options.MaxLength, + (maxLength, b) => b.WithAttribute("data-maxlength", maxLength.ToString()!, encodeValue: false)) + .WhenNotNull(options.Threshold, + (threshold, b) => b.WithAttribute("data-threshold", threshold.ToString()!, encodeValue: false)) + .WhenNotNull(options.MaxWords, + (maxWords, b) => b.WithAttribute("data-maxwords", maxWords.ToString()!, encodeValue: false)) + .When( + hasNoLimit && options.TextareaDescriptionText.NormalizeEmptyString() is not null, + b => b.WithAttribute("data-i18n.textarea-description.other", options.TextareaDescriptionText!)) + .WithAttributeWhenNotNull(options.CharactersUnderLimitText?.One, "data-i18n.characters-under-limit.one") + .WithAttributeWhenNotNull(options.CharactersUnderLimitText?.Other, "data-i18n.characters-under-limit.other") + .WithAttributeWhenNotNull(options.CharactersAtLimitText, "data-i18n.characters-at-limit") + .WithAttributeWhenNotNull(options.CharactersOverLimitText?.One, "data-i18n.characters-over-limit.one") + .WithAttributeWhenNotNull(options.CharactersOverLimitText?.Other, "data-i18n.characters-over-limit.other") + .WithAttributeWhenNotNull(options.WordsUnderLimitText?.One, "data-i18n.words-under-limit.one") + .WithAttributeWhenNotNull(options.WordsUnderLimitText?.Other, "data-i18n.words-under-limit.other") + .WithAttributeWhenNotNull(options.WordsAtLimitText, "data-i18n.words-at-limit") + .WithAttributeWhenNotNull(options.WordsOverLimitText?.One, "data-i18n.words-over-limit.one") + .WithAttributeWhenNotNull(options.WordsOverLimitText?.Other, "data-i18n.words-over-limit.other") + .WithAppendedHtml(GenerateTextarea(new TextareaOptions + { + Id = options.Id, + Name = options.Name, + DescribedBy = new HtmlString($"{options.Id!.ToHtmlString()}-info"), + Rows = options.Rows, + Spellcheck = options.Spellcheck, + Value = options.Value, + FormGroup = options.FormGroup, + Classes = new HtmlString($"govuk-js-character-count {options.Classes?.ToHtmlString()}".TrimEnd()), + Label = (options.Label ?? new LabelOptions()) with { For = options.Id }, + Hint = options.Hint, + ErrorMessage = options.ErrorMessage, + Attributes = options.Attributes + })) + .WithAppendedHtml(() => + { + IHtmlContent? content = null; + + if (!hasNoLimit) + { + var textareaDescriptionLength = options.MaxWords ?? options.MaxLength; + + content = new HtmlString( + (options.TextareaDescriptionText.NormalizeEmptyString()?.ToHtmlString() ?? + $"You can enter up to %{{count}} {(options.MaxWords is not null ? "words" : "characters")}") + .Replace("%{count}", textareaDescriptionLength.ToString())); + } + + return GenerateHint( + new HintOptions() + { + Html = content, + Id = new HtmlString($"{options.Id!.ToHtmlString()}-info"), + Classes = new HtmlString( + $"govuk-character-count__message {options.CountMessage?.Classes?.ToHtmlString()}".TrimEnd()) + }, + allowMissingContent: true); + }); + } +} diff --git a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.Hint.cs b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.Hint.cs index c2a9f88b..df758c2c 100644 --- a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.Hint.cs +++ b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.Hint.cs @@ -7,16 +7,19 @@ public partial class DefaultComponentGenerator internal const string HintElement = "div"; /// - public virtual HtmlTagBuilder GenerateHint(HintOptions options) + protected virtual HtmlTagBuilder GenerateHint(HintOptions options) => + GenerateHint(options, allowMissingContent: false); + + private HtmlTagBuilder GenerateHint(HintOptions options, bool allowMissingContent = false) { ArgumentNullException.ThrowIfNull(options); - options.Validate(); + options.Validate(allowMissingContent); return new HtmlTagBuilder(HintElement) .WithCssClass("govuk-hint") .WithCssClasses(ExplodeClasses(options.Classes?.ToHtmlString())) .WithAttributeWhenNotNull(options.Id, "id") .WithAttributes(options.Attributes) - .WithAppendedHtml(GetEncodedTextOrHtml(options.Text, options.Html)!); + .WhenNotNull(GetEncodedTextOrHtml(options.Text, options.Html), (content, b) => b.WithAppendedHtml(content)); } } diff --git a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/HintOptions.cs b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/HintOptions.cs index 6bc6843d..d37d712b 100644 --- a/src/GovUk.Frontend.AspNetCore/ComponentGeneration/HintOptions.cs +++ b/src/GovUk.Frontend.AspNetCore/ComponentGeneration/HintOptions.cs @@ -12,9 +12,9 @@ public record HintOptions public IHtmlContent? Classes { get; set; } public EncodedAttributesDictionary? Attributes { get; set; } - internal void Validate() + internal void Validate(bool allowMissingContent = false) { - if (Html.NormalizeEmptyString() is null && Text.NormalizeEmptyString() is null) + if (!allowMissingContent && Html.NormalizeEmptyString() is null && Text.NormalizeEmptyString() is null) { throw new InvalidOptionsException(GetType(), $"{nameof(Html)} or {nameof(Text)} must be specified."); } diff --git a/tests/GovUk.Frontend.AspNetCore.Tests/ComponentGeneration/ComponentTests.cs b/tests/GovUk.Frontend.AspNetCore.Tests/ComponentGeneration/ComponentTests.cs index 3ccd56c1..12f12566 100644 --- a/tests/GovUk.Frontend.AspNetCore.Tests/ComponentGeneration/ComponentTests.cs +++ b/tests/GovUk.Frontend.AspNetCore.Tests/ComponentGeneration/ComponentTests.cs @@ -15,6 +15,13 @@ public ComponentTests() _componentGenerator = new DefaultComponentGenerator(); } + [Theory] + [ComponentFixtureData("character-count", typeof(CharacterCountOptions))] + public void CharacterCount(ComponentTestCaseData data) => + CheckComponentHtmlMatchesExpectedHtml( + data, + (generator, options) => generator.GenerateCharacterCount(options).ToHtmlString()); + [Theory] [ComponentFixtureData("textarea", typeof(TextareaOptions))] public void Textarea(ComponentTestCaseData data) =>