Repository: internetarchive/iaux-reviews
Pull request #22: WEBDEV-7287 Add character counter to review form
Author: login: rebecca-shoptaw
Created: 2025-03-24T17:17:26Z | Merged: 2025-03-27T14:48:32Z

Builds out a live character counter for the review form, including disabling the submit button if the review exceeds the count.


Diff:
diff --git a/demo/app-root.ts b/demo/app-root.ts
index 302495f..68e4be2 100644
--- a/demo/app-root.ts
+++ b/demo/app-root.ts
@@ -44,6 +44,9 @@ export class AppRoot extends LitElement {
   @state()
   private showErrors: boolean = false;
 
+  @state()
+  private useCharCounts: boolean = true;
+
   render() {
     return html`${!this.recaptchaManager
         ? html`<button
@@ -61,7 +64,10 @@ export class AppRoot extends LitElement {
         ${!this.bypassRecaptcha ? 'Bypass' : 'Enable'} ReCaptcha
       </button>
       <button @click=${() => (this.showErrors = !this.showErrors)}>
-        ${this.showErrors ? 'Hide' : 'Show'} Pre-filled Errors
+        ${this.showErrors ? 'Hide' : 'Show'} pre-filled errors
+      </button>
+      <button @click=${() => (this.useCharCounts = !this.useCharCounts)}>
+        ${this.useCharCounts ? 'Remove' : 'Use'} char count limits
       </button>
       <div class="container">
         <ia-review-form
@@ -69,6 +75,8 @@ export class AppRoot extends LitElement {
           .oldReview=${this.mockOldReview}
           .recaptchaManager=${this.recaptchaManager}
           .prefilledErrors=${this.showErrors ? this.errors : []}
+          .maxSubjectLength=${this.useCharCounts ? 100 : undefined}
+          .maxBodyLength=${this.useCharCounts ? 1000 : undefined}
           ?bypassRecaptcha=${this.bypassRecaptcha}
         ></ia-review-form>
       </div>`;
@@ -78,6 +86,7 @@ export class AppRoot extends LitElement {
     .container {
       max-width: 750px;
       margin: 10px auto;
+      font-size: 1.4rem;
     }
   `;
 }
diff --git a/package-lock.json b/package-lock.json
index 1a06767..af34a66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
       "version": "1.0.0",
       "license": "AGPL-3.0-only",
       "dependencies": {
+        "@internetarchive/ia-styles": "^1.0.0",
         "@internetarchive/metadata-service": "^1.0.3",
         "@internetarchive/recaptcha-manager": "^1.0.0",
         "@lit/localize": "^0.12.2",
@@ -1066,6 +1067,15 @@
       "integrity": "sha512-J5nhMsIU91eXnfoApa6SE0PffPbwK1Cd3YTLX/HjgayzMbXVkQvfebfVKSaoPmzHRRttbtY3Sg9FjviCwBq6qg==",
       "license": "AGPL-3.0-only"
     },
+    "node_modules/@internetarchive/ia-styles": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@internetarchive/ia-styles/-/ia-styles-1.0.0.tgz",
+      "integrity": "sha512-7xvfROl5sUasM3cB+bCwOrO/Qyy08Tk50V7NAGgPKjmHr8LBUk4Q/Qj/ExKcoaiROQE3JlVy9TDANEl8WSLJCw==",
+      "license": "AGPL-3.0-only",
+      "dependencies": {
+        "lit": "^2.8.0"
+      }
+    },
     "node_modules/@internetarchive/iaux-item-metadata": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/@internetarchive/iaux-item-metadata/-/iaux-item-metadata-1.0.4.tgz",
diff --git a/package.json b/package.json
index f968a5f..466e5e1 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
     ]
   },
   "dependencies": {
+    "@internetarchive/ia-styles": "^1.0.0",
     "@internetarchive/metadata-service": "^1.0.3",
     "@internetarchive/recaptcha-manager": "^1.0.0",
     "@lit/localize": "^0.12.2",
diff --git a/src/review-form.ts b/src/review-form.ts
index 96bac42..2f00c0a 100644
--- a/src/review-form.ts
+++ b/src/review-form.ts
@@ -12,8 +12,8 @@ import { msg } from '@lit/localize';
 
 import starSelected from './assets/star-selected';
 import starUnselected from './assets/star-unselected';
-import { IAButtonStyles } from './styles/ia-buttons';
 
+import { iaButtonStyles } from '@internetarchive/ia-styles';
 import type { Review } from '@internetarchive/metadata-service';
 import type {
   RecaptchaManagerInterface,
@@ -29,7 +29,7 @@ export class ReviewForm extends LitElement {
   @property({ type: String }) identifier?: string;
 
   /* The token for the review edit */
-  @property({ type: String }) token?: string;
+  @property({ type: String }) token: string = '';
 
   /* The host for archive endpoints and data */
   @property({ type: String }) baseHost: string = 'https://archive.org';
@@ -43,6 +43,12 @@ export class ReviewForm extends LitElement {
   /* Errors to add to the form on first render, if any */
   @property({ type: Array }) prefilledErrors: string[] = [];
 
+  /* Optional max length for review subject */
+  @property({ type: Number }) maxSubjectLength?: number;
+
+  /* Optional max length for review body */
+  @property({ type: Number }) maxBodyLength?: number;
+
   /* Service for activating the recaptcha challenge */
   @property({ type: Object }) recaptchaManager?: RecaptchaManagerInterface;
 
@@ -64,6 +70,18 @@ export class ReviewForm extends LitElement {
   @state()
   private currentStars: number = 0;
 
+  /* Length of review subject currently filled in */
+  @state()
+  private currentSubjectLength: number = 0;
+
+  /* Length of review body currently filled in */
+  @state()
+  private currentBodyLength: number = 0;
+
+  /* Whether to enable the submit button */
+  @state()
+  private formCanSubmit: boolean = false;
+
   /* The form to be submitted */
   @query('#review-form')
   private reviewForm!: HTMLFormElement;
@@ -75,9 +93,9 @@ export class ReviewForm extends LitElement {
       method="post"
     >
       ${this.prefilledErrors.length
-        ? html`<div class="errors prefilled-errors">
-            ${this.prefilledErrors.join(' ')}
-          </div>`
+        ? this.prefilledErrors.map(
+            err => html`<div class="errors prefilled-error">${err}</div>`,
+          )
         : nothing}
       ${this.recaptchaError
         ? html`<div class="errors recaptcha-error">
@@ -90,9 +108,17 @@ export class ReviewForm extends LitElement {
     </form>`;
   }
 
+  protected firstUpdated(): void {
+    this.formCanSubmit = this.checkSubmissionAllowed();
+  }
+
   protected updated(changed: PropertyValues): void {
-    if (changed.has('oldReview') && this.oldReview?.stars) {
-      this.currentStars = this.oldReview.stars;
+    if (changed.has('oldReview') && this.oldReview) {
+      if (this.oldReview.stars) this.currentStars = this.oldReview.stars;
+      if (this.oldReview.reviewtitle)
+        this.currentSubjectLength = this.oldReview.reviewtitle.length;
+      if (this.oldReview.reviewbody)
+        this.currentBodyLength = this.oldReview.reviewbody.length;
     }
 
     if (
@@ -102,6 +128,13 @@ export class ReviewForm extends LitElement {
     ) {
       this.setupRecaptcha();
     }
+
+    if (
+      changed.has('currentSubjectLength') ||
+      changed.has('currentBodyLength')
+    ) {
+      this.formCanSubmit = this.checkSubmissionAllowed();
+    }
   }
 
   private get starsInputTemplate(): HTMLTemplateResult {
@@ -113,7 +146,7 @@ export class ReviewForm extends LitElement {
         type="hidden"
         name="field_stars"
         id="stars-input"
-        .value=${this.currentStars}
+        .value=${this.currentStars.toString()}
         required
       />
       <div class="stars">
@@ -126,30 +159,73 @@ export class ReviewForm extends LitElement {
   }
 
   private get subjectInputTemplate(): HTMLTemplateResult {
-    return html`<div class="form-heading">
-        <label for="subject-input">${msg('Subject')}</label>
+    return html`<span id="subject-input" class="input-box ${
+      this.maxSubjectLength && this.currentSubjectLength > this.maxSubjectLength
+        ? 'error'
+        : ''
+    }"
+      ><div class="form-heading">
+        <label for="field_reviewtitle">${msg('Subject')}</label>
+        ${
+          this.maxSubjectLength
+            ? html`<div class="char-count subject">
+                ${this.currentSubjectLength}/${this.maxSubjectLength}
+              </div>`
+            : nothing
+        }
       </div>
       <input
         type="text"
         name="field_reviewtitle"
-        id="subject-input"
+        id="field_reviewtitle"
         .value=${this.oldReview?.reviewtitle ?? ''}
+        @input=${this.handleSubjectChanged}
         required
-      />`;
+    />${
+      this.maxSubjectLength
+        ? html`
+            <div class="input-error">
+              ${msg(
+                `Subject may only have ${this.maxSubjectLength} characters`,
+              )}
+            </div>
+          `
+        : nothing
+    }</div></span>`;
   }
 
   private get bodyInputTemplate(): HTMLTemplateResult {
-    return html`<div class="form-heading">
-        <label for="body-input">${msg('Review')}</label>
+    return html`<span
+      id="body-input"
+      class="input-box ${this.maxBodyLength &&
+      this.currentBodyLength > this.maxBodyLength
+        ? 'error'
+        : ''}"
+      ><div class="form-heading">
+        <label for="field_reviewbody">${msg('Review')}</label>
+        ${this.maxBodyLength
+          ? html`<div class="char-count body">
+              ${this.currentBodyLength}/${this.maxBodyLength}
+            </div>`
+          : nothing}
       </div>
       <textarea
         name="field_reviewbody"
-        id="body-input"
+        id="field_reviewbody"
         .value=${this.oldReview?.reviewbody ?? ''}
         rows="10"
         cols="50"
         required
-      ></textarea>`;
+        @input=${this.handleBodyChanged}
+      ></textarea>
+      ${this.maxBodyLength
+        ? html`
+            <div class="input-error">
+              ${msg(`Review may only have ${this.maxBodyLength} characters`)}
+            </div>
+          `
+        : nothing}
+    </span>`;
   }
 
   /* Renders all the hidden inputs we use to store other information for form submission */
@@ -191,6 +267,7 @@ export class ReviewForm extends LitElement {
         type="submit"
         class="ia-button primary"
         name="submit"
+        ?disabled=${!this.formCanSubmit}
         @click=${this.handleSubmit}
       >
         ${msg('Submit review')}
@@ -265,9 +342,44 @@ export class ReviewForm extends LitElement {
     this.currentStars = num === this.currentStars ? 0 : num;
   }
 
+  /* Updates subject input length */
+  private handleSubjectChanged(e: Event): void {
+    const subjectInput = e.target as HTMLInputElement;
+    this.currentSubjectLength = subjectInput.value.length;
+  }
+
+  /* Updates body input length */
+  private handleBodyChanged(e: Event): void {
+    const bodyInput = e.target as HTMLInputElement;
+    this.currentBodyLength = bodyInput.value.length;
+  }
+
+  /* Checks if submission should be allowed */
+  private checkSubmissionAllowed(): boolean {
+    // Subject and body must not be empty
+    if (!this.currentBodyLength || !this.currentSubjectLength) {
+      return false;
+    }
+
+    // Subject must be correct length
+    if (
+      !!this.maxSubjectLength &&
+      this.currentSubjectLength > this.maxSubjectLength
+    ) {
+      return false;
+    }
+
+    // Body must be correct length
+    if (!!this.maxBodyLength && this.currentBodyLength > this.maxBodyLength) {
+      return false;
+    }
+
+    return true;
+  }
+
   static get styles(): CSSResultGroup {
     return [
-      IAButtonStyles,
+      iaButtonStyles,
       css`
         :host {
           font-family: var(
@@ -279,13 +391,17 @@ export class ReviewForm extends LitElement {
           );
 
           color: var(--ia-text-color, #2c2c2c);
+          --ia-theme-error-color: #ff0000;
         }
 
         .form-heading {
           display: flex;
           flex-direction: row;
           justify-content: space-between;
-          padding-top: 1.5rem;
+          padding-top: 15px;
+        }
+
+        .form-heading label {
           font-size: 1.6rem;
           font-weight: bold;
         }
@@ -297,21 +413,44 @@ export class ReviewForm extends LitElement {
 
         textarea,
         input[type='text'] {
-          padding: 0.5rem;
-          width: calc(100% - 1rem);
+          padding: 5px;
+          width: calc(100% - 10px);
           font-family: inherit;
+          border-radius: 5px;
+          border: 1px solid #999999;
+        }
+
+        .input-box.error input,
+        .input-box.error textarea {
+          border: 2px solid var(--ia-theme-error-color, #ff0000);
+        }
+
+        .input-box.error .char-count,
+        .input-error {
+          color: var(--ia-theme-error-color, #ff0000);
+        }
+
+        .input-error {
+          display: none;
+        }
+
+        .input-box.error .input-error {
+          display: block;
+          text-align: right;
+          padding-top: 5px;
         }
 
         .stars {
           display: flex;
           flex-direction: row;
-          gap: 0.2rem;
+          gap: 2px;
           align-items: center;
         }
 
         .star {
           all: unset;
-          width: 3rem;
+          height: 30px;
+          width: 30px;
         }
 
         .star:hover {
@@ -319,13 +458,13 @@ export class ReviewForm extends LitElement {
         }
 
         .clear-stars-btn {
-          padding: 0 0.5rem;
+          padding: 0 5px;
           color: var(--ia-link-color, #4b64ff);
           font-family: inherit;
           border: none;
           background: transparent;
           display: inline-block;
-          padding-top: 0.5rem;
+          padding-top: 5px;
         }
 
         .clear-stars-btn:hover {
@@ -337,8 +476,8 @@ export class ReviewForm extends LitElement {
           width: 100%;
           display: flex;
           justify-content: flex-end;
-          gap: 1rem;
-          padding-top: 1.5rem;
+          gap: 10px;
+          padding-top: 15px;
         }
 
         .ia-button:disabled {
@@ -350,8 +489,7 @@ export class ReviewForm extends LitElement {
         }
 
         .errors {
-          font-size: 1.4rem;
-          padding: 1.5rem;
+          padding: 15px;
           border: 1px solid #ebccd1;
           color: #a94442;
           background-color: #f2dede;
diff --git a/src/styles/ia-buttons.ts b/src/styles/ia-buttons.ts
deleted file mode 100644
index 286736e..0000000
--- a/src/styles/ia-buttons.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { css } from 'lit';
-
-const whiteColor = css`var(--white, #fff)`;
-const disabledButtonFillColor = css`var(--primaryDisableCTAFill, #767676)`;
-const disabledButtonBorderColor = css`var(--secondaryCTABorder, #999)`;
-
-const primaryButtonFillColor = css`var(--primaryCTAFill, #194880)`;
-const primaryButtonFillColorRGB = css`var(--primaryCTAFillRGB, 25, 72, 128)`; // RBG format of --primaryCTAFill
-const primaryButtonBorderColor = css`var(--primaryCTABorder, #c5d1df)`;
-
-const dangerButtonFillColor = css`var(--primaryErrorCTAFill, #d9534f)`;
-const dangerButtonFillColorRGB = css`var(--primaryErrorCTAFillRGB, 229, 28, 38)`; // RBG format of --primaryErrorCTAFill
-const dangerButtonBorderColor = css`var(--primaryErrorCTABorder, #d43f3a)`;
-
-const darkButtonFillColor = css`var(--secondaryCTAFill, #333)`;
-const darkButtonFillColorRGB = css`var(--secondaryCTAFillRGB, 51, 51, 51)`; // RBG format of --secondaryCTAFill
-const darkButtonBorderColor = css`var(--primaryCTABorder, #979797)`;
-
-const warningButtonBGColor = css`#ee8950`;
-const warningButtonBorderColor = css`#ec7939`;
-
-export const IAButtonStyles = css`
-  .ia-button {
-    height: 3.5rem;
-    min-height: 3rem;
-    cursor: pointer;
-    color: ${whiteColor};
-    line-height: normal;
-    border-radius: 0.4rem;
-    font-size: 1.4rem;
-    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
-    border: 1px solid transparent;
-    white-space: nowrap;
-    appearance: auto;
-    box-sizing: border-box;
-    display: flex;
-    align-items: center;
-    transition: all 0.1s ease 0s;
-    vertical-align: middle;
-    padding: 0 1rem;
-    outline-color: ${whiteColor};
-    outline-offset: -4px;
-    user-select: none;
-    text-decoration: none;
-    width: fit-content;
-    -webkit-user-select: none;
-    -moz-user-select: none;
-    -ms-user-select: none;
-    -o-user-select: none;
-  }
-  .ia-button:focus-visible {
-    outline-style: double;
-  }
-  .ia-button:disabled {
-    cursor: not-allowed;
-    pointer-events: none;
-    background-color: ${disabledButtonFillColor};
-    border: 1px solid ${disabledButtonBorderColor};
-  }
-  .ia-button.transparent {
-    background-color: transparent;
-  }
-  .ia-button.warning {
-    background-color: ${warningButtonBGColor}
-    border-color: ${warningButtonBorderColor};
-  }
-
-  .ia-button.primary {
-    background-color: ${primaryButtonFillColor};
-    border-color: ${primaryButtonBorderColor};
-  }
-  .ia-button.primary:hover {
-    background-color: rgba(${primaryButtonFillColorRGB}, 0.9);
-  }
-  .ia-button.primary:focus-visible {
-    background-color: rgba(${primaryButtonFillColorRGB}, 0.8);
-  }
-  .ia-button.primary:active {
-    background-color: rgba(${primaryButtonFillColorRGB}, 0.7);
-  }
-
-  .ia-button.danger {
-    background-color: ${dangerButtonFillColor};
-    border-color: ${dangerButtonBorderColor};
-  }
-  .ia-button.danger:hover {
-    background-color: rgba(${dangerButtonFillColorRGB}, 0.9);
-  }
-  .ia-button.danger:focus-visible {
-    background-color: rgba(${dangerButtonFillColorRGB}, 0.8);
-  }
-  .ia-button.danger:active {
-    background-color: rgba(${dangerButtonFillColorRGB}, 0.7);
-  }
-
-  .ia-button.dark {
-    background-color: ${darkButtonFillColor};
-    border-color: ${darkButtonBorderColor};
-  }
-  .ia-button.dark:hover {
-    background-color: rgba(${darkButtonFillColorRGB}, 0.9);
-  }
-  .ia-button.dark:focus-visible {
-    background-color: rgba(${darkButtonFillColorRGB}, 0.8);
-  }
-  .ia-button.dark:active {
-    background-color: rgba(${darkButtonFillColorRGB}, 0.7);
-  }
-`;
diff --git a/test/review-form.test.ts b/test/review-form.test.ts
index 8423142..1036054 100644
--- a/test/review-form.test.ts
+++ b/test/review-form.test.ts
@@ -94,12 +94,12 @@ describe('ReviewForm', () => {
       ></ia-review-form>`,
     );
 
-    const errors = el.shadowRoot?.querySelector(
-      '.prefilled-errors',
-    ) as HTMLDivElement;
-    expect(errors?.innerText).to.equal(
-      'Too good of a review. Please make it worse.',
-    );
+    const errors = el.shadowRoot?.querySelectorAll(
+      '.prefilled-error',
+    ) as NodeListOf<HTMLDivElement>;
+    expect(errors.length).to.equal(2);
+    expect(errors[0].innerText).to.equal('Too good of a review.');
+    expect(errors[1].innerText).to.equal('Please make it worse.');
   });
 
   it('does not render the error div if no errors are passed in', async () => {
@@ -400,4 +400,129 @@ describe('ReviewForm', () => {
     const recaptchaErrorDiv = el.shadowRoot?.querySelector('.recaptcha-error');
     expect(recaptchaErrorDiv).not.to.exist;
   });*/
+
+  it('displays a character counter for the subject if max length specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxSubjectLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const subjectCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.subject',
+    ) as HTMLDivElement;
+    expect(subjectCharCounter).to.exist;
+    expect(subjectCharCounter?.innerText).to.equal('17/100');
+  });
+
+  it('does not display a subject char counter if no max specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form .oldReview=${mockOldReview}></ia-review-form>`,
+    );
+
+    const subjectCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.subject',
+    ) as HTMLDivElement;
+    expect(subjectCharCounter).to.be.null;
+  });
+
+  it('shows the correct error states if the subject is too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxSubjectLength=${10}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const subjectCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.subject',
+    ) as HTMLDivElement;
+    expect(subjectCharCounter?.innerText).to.equal('17/10');
+
+    const subjectInputBox = el.shadowRoot?.getElementById('subject-input');
+    expect(subjectInputBox?.className).to.contain('error');
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"');
+    expect(submitBtn?.getAttribute('disabled')).to.exist;
+  });
+
+  it('does not show the error states if the subject is not too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxSubjectLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const subjectInputBox = el.shadowRoot?.getElementById('subject-input');
+    expect(subjectInputBox?.className).not.to.contain('error');
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"');
+    expect(submitBtn?.getAttribute('disabled')).not.to.exist;
+  });
+
+  it('displays a character counter for the body if max length specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxBodyLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const bodyCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.body',
+    ) as HTMLDivElement;
+    expect(bodyCharCounter).to.exist;
+    expect(bodyCharCounter?.innerText).to.equal('11/100');
+  });
+
+  it('does not display a body char counter if no max specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form .oldReview=${mockOldReview}></ia-review-form>`,
+    );
+
+    const bodyCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.body',
+    ) as HTMLDivElement;
+    expect(bodyCharCounter).to.be.null;
+  });
+
+  it('shows the correct error states if the body is too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxBodyLength=${10}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const bodyCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.body',
+    ) as HTMLDivElement;
+    expect(bodyCharCounter?.innerText).to.equal('11/10');
+
+    const bodyInputBox = el.shadowRoot?.getElementById('body-input');
+    expect(bodyInputBox?.className).to.contain('error');
+
+    const errorMsg = bodyInputBox?.querySelector('.input-error');
+    expect(errorMsg).to.exist;
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"]');
+    expect(submitBtn?.getAttribute('disabled')).to.exist;
+  });
+
+  it('does not show the error states if the body is not too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxBodyLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const bodyInputBox = el.shadowRoot?.getElementById('body-input');
+    expect(bodyInputBox?.className).not.to.contain('error');
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"');
+    expect(submitBtn?.getAttribute('disabled')).not.to.exist;
+  });
 });


Reviews:
user: login: iisa
body: looks good, just some questions, suggestions.
state: APPROVED
author_association: NONE
submitted_at: 2025-03-25T16:52:58Z
user: login: rebecca-shoptaw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T18:01:43Z
user: login: rebecca-shoptaw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T18:03:02Z
user: login: rebecca-shoptaw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T18:23:46Z

Review comments:
diff_hunk: @@ -90,9 +108,17 @@ export class ReviewForm extends LitElement {
     </form>`;
   }
 
+  protected firstUpdated(): void {
+    this.formCanSubmit = this.checkSubmissionAllowed();
+  }
+
   protected updated(changed: PropertyValues): void {
-    if (changed.has('oldReview') && this.oldReview?.stars) {
-      this.currentStars = this.oldReview.stars;
+    if (changed.has('oldReview') && this.oldReview) {
+      if (this.oldReview.stars) this.currentStars = this.oldReview.stars;
+      if (this.oldReview.reviewtitle)
+        this.currentSubjectLength = this.oldReview.reviewtitle.length;
+      if (this.oldReview.reviewbody)
+        this.currentBodyLength = this.oldReview.reviewbody.length;
path: src/review-form.ts
user: login: iisa
body: ok,
created_at: 2025-03-24T20:21:23Z
author_association: NONE
diff_hunk: @@ -113,7 +147,7 @@ export class ReviewForm extends LitElement {
         type="hidden"
         name="field_stars"
         id="stars-input"
-        .value=${this.currentStars}
+        .value=${this.currentStars.toString()}
path: src/review-form.ts
user: login: iisa
body: always a number?
created_at: 2025-03-24T20:21:50Z
author_association: NONE
diff_hunk: @@ -126,38 +159,81 @@ export class ReviewForm extends LitElement {
   }
 
   private get subjectInputTemplate(): HTMLTemplateResult {
-    return html`<div class="form-heading">
-        <label for="subject-input">${msg('Subject')}</label>
+    return html`<span id="subject-input" class="input-box ${
+      this.maxSubjectLength && this.currentSubjectLength > this.maxSubjectLength
+        ? 'error'
+        : ''
+    }"
+      ><div class="form-heading">
+        <label for="field_reviewtitle">${msg('Subject')}</label>
+        ${
+          this.maxSubjectLength
+            ? html`<div class="char-count subject">
+                ${this.currentSubjectLength}/${this.maxSubjectLength}
+              </div>`
+            : nothing
+        }
       </div>
       <input
         type="text"
         name="field_reviewtitle"
-        id="subject-input"
+        id="field_reviewtitle"
         .value=${this.oldReview?.reviewtitle ?? ''}
+        @input=${this.handleSubjectChanged}
         required
-      />`;
+    />${
+      this.maxSubjectLength
+        ? html`
+            <div class="input-error">
+              ${msg(
+                `Subject may only have ${this.maxSubjectLength} characters`,
+              )}
+            </div>
+          `
+        : nothing
+    }</div></span>`;
   }
 
   private get bodyInputTemplate(): HTMLTemplateResult {
-    return html`<div class="form-heading">
-        <label for="body-input">${msg('Review')}</label>
+    return html`<span
+      id="body-input"
+      class="input-box ${this.maxBodyLength &&
+      this.currentBodyLength > this.maxBodyLength
+        ? 'error'
+        : ''}"
+      ><div class="form-heading">
+        <label for="field_reviewbody">${msg('Review')}</label>
+        ${this.maxBodyLength
+          ? html`<div class="char-count body">
+              ${this.currentBodyLength}/${this.maxBodyLength}
+            </div>`
+          : nothing}
       </div>
       <textarea
         name="field_reviewbody"
-        id="body-input"
+        id="field_reviewbody"
         .value=${this.oldReview?.reviewbody ?? ''}
         rows="10"
         cols="50"
         required
-      ></textarea>`;
+        @input=${this.handleBodyChanged}
+      ></textarea>
+      ${this.maxBodyLength
+        ? html`
+            <div class="input-error">
+              ${msg(`Review may only have ${this.maxBodyLength} characters`)}
+            </div>
+          `
+        : nothing}
+    </span>`;
   }
 
   /* Renders all the hidden inputs we use to store other information for form submission */
   private get hiddenInputsTemplate(): HTMLTemplateResult {
     return html`<input
         type="hidden"
         name="field_reviewtoken"
-        .value=${this.token}
+        .value=${this.token ?? ''}
path: src/review-form.ts
user: login: iisa
body: q: can empty string be the default value?
created_at: 2025-03-25T16:49:37Z
author_association: NONE
diff_hunk: @@ -279,13 +391,18 @@ export class ReviewForm extends LitElement {
           );
 
           color: var(--ia-text-color, #2c2c2c);
+          font-size: 1.4rem;
path: src/review-form.ts
user: login: iisa
body: this should be set at page level, do we need a certain size outside of our norm?
created_at: 2025-03-25T16:50:52Z
author_association: NONE
diff_hunk: @@ -396,4 +396,129 @@ describe('ReviewForm', () => {
     const recaptchaErrorDiv = el.shadowRoot?.querySelector('.recaptcha-error');
     expect(recaptchaErrorDiv).not.to.exist;
   });
+
+  it('displays a character counter for the subject if max length specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxSubjectLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const subjectCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.subject',
+    ) as HTMLDivElement;
+    expect(subjectCharCounter).to.exist;
+    expect(subjectCharCounter?.innerText).to.equal('17/100');
+  });
+
+  it('does not display a subject char counter if no max specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form .oldReview=${mockOldReview}></ia-review-form>`,
+    );
+
+    const subjectCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.subject',
+    ) as HTMLDivElement;
+    expect(subjectCharCounter).to.be.null;
+  });
+
+  it('shows the correct error states if the subject is too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxSubjectLength=${10}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const subjectCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.subject',
+    ) as HTMLDivElement;
+    expect(subjectCharCounter?.innerText).to.equal('17/10');
+
+    const subjectInputBox = el.shadowRoot?.getElementById('subject-input');
+    expect(subjectInputBox?.className).to.contain('error');
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"');
+    expect(submitBtn?.getAttribute('disabled')).to.exist;
+  });
+
+  it('does not show the error states if the subject is not too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxSubjectLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const subjectInputBox = el.shadowRoot?.getElementById('subject-input');
+    expect(subjectInputBox?.className).not.to.contain('error');
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"');
+    expect(submitBtn?.getAttribute('disabled')).not.to.exist;
+  });
+
+  it('displays a character counter for the body if max length specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxBodyLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const bodyCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.body',
+    ) as HTMLDivElement;
+    expect(bodyCharCounter).to.exist;
+    expect(bodyCharCounter?.innerText).to.equal('11/100');
+  });
+
+  it('does not display a body char counter if no max specified', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form .oldReview=${mockOldReview}></ia-review-form>`,
+    );
+
+    const bodyCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.body',
+    ) as HTMLDivElement;
+    expect(bodyCharCounter).to.be.null;
+  });
+
+  it('shows the correct error states if the body is too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxBodyLength=${10}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const bodyCharCounter = el.shadowRoot?.querySelector(
+      '.char-count.body',
+    ) as HTMLDivElement;
+    expect(bodyCharCounter?.innerText).to.equal('11/10');
+
+    const bodyInputBox = el.shadowRoot?.getElementById('body-input');
+    expect(bodyInputBox?.className).to.contain('error');
+
+    const errorMsg = bodyInputBox?.querySelector('.input-error');
+    expect(errorMsg).to.exist;
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"]');
+    expect(submitBtn?.getAttribute('disabled')).to.exist;
+  });
+
+  it('does not show the error states if the body is not too long', async () => {
+    const el = await fixture<ReviewForm>(
+      html`<ia-review-form
+        .maxBodyLength=${100}
+        .oldReview=${mockOldReview}
+      ></ia-review-form>`,
+    );
+
+    const bodyInputBox = el.shadowRoot?.getElementById('body-input');
+    expect(bodyInputBox?.className).not.to.contain('error');
+
+    const submitBtn = el.shadowRoot?.querySelector('button[type="submit"');
+    expect(submitBtn?.getAttribute('disabled')).not.to.exist;
+  });
path: test/review-form.test.ts
user: login: iisa
body: 👏  nice tests
created_at: 2025-03-25T16:52:45Z
author_association: NONE
diff_hunk: @@ -113,7 +147,7 @@ export class ReviewForm extends LitElement {
         type="hidden"
         name="field_stars"
         id="stars-input"
-        .value=${this.currentStars}
+        .value=${this.currentStars.toString()}
path: src/review-form.ts
user: login: rebecca-shoptaw
body: Yep! Defined up top:
```
  @state()
  private currentStars: number = 0;
```
TS hints requested a conversion to string before passing to value
created_at: 2025-03-25T18:01:42Z
author_association: COLLABORATOR
diff_hunk: @@ -126,38 +159,81 @@ export class ReviewForm extends LitElement {
   }
 
   private get subjectInputTemplate(): HTMLTemplateResult {
-    return html`<div class="form-heading">
-        <label for="subject-input">${msg('Subject')}</label>
+    return html`<span id="subject-input" class="input-box ${
+      this.maxSubjectLength && this.currentSubjectLength > this.maxSubjectLength
+        ? 'error'
+        : ''
+    }"
+      ><div class="form-heading">
+        <label for="field_reviewtitle">${msg('Subject')}</label>
+        ${
+          this.maxSubjectLength
+            ? html`<div class="char-count subject">
+                ${this.currentSubjectLength}/${this.maxSubjectLength}
+              </div>`
+            : nothing
+        }
       </div>
       <input
         type="text"
         name="field_reviewtitle"
-        id="subject-input"
+        id="field_reviewtitle"
         .value=${this.oldReview?.reviewtitle ?? ''}
+        @input=${this.handleSubjectChanged}
         required
-      />`;
+    />${
+      this.maxSubjectLength
+        ? html`
+            <div class="input-error">
+              ${msg(
+                `Subject may only have ${this.maxSubjectLength} characters`,
+              )}
+            </div>
+          `
+        : nothing
+    }</div></span>`;
   }
 
   private get bodyInputTemplate(): HTMLTemplateResult {
-    return html`<div class="form-heading">
-        <label for="body-input">${msg('Review')}</label>
+    return html`<span
+      id="body-input"
+      class="input-box ${this.maxBodyLength &&
+      this.currentBodyLength > this.maxBodyLength
+        ? 'error'
+        : ''}"
+      ><div class="form-heading">
+        <label for="field_reviewbody">${msg('Review')}</label>
+        ${this.maxBodyLength
+          ? html`<div class="char-count body">
+              ${this.currentBodyLength}/${this.maxBodyLength}
+            </div>`
+          : nothing}
       </div>
       <textarea
         name="field_reviewbody"
-        id="body-input"
+        id="field_reviewbody"
         .value=${this.oldReview?.reviewbody ?? ''}
         rows="10"
         cols="50"
         required
-      ></textarea>`;
+        @input=${this.handleBodyChanged}
+      ></textarea>
+      ${this.maxBodyLength
+        ? html`
+            <div class="input-error">
+              ${msg(`Review may only have ${this.maxBodyLength} characters`)}
+            </div>
+          `
+        : nothing}
+    </span>`;
   }
 
   /* Renders all the hidden inputs we use to store other information for form submission */
   private get hiddenInputsTemplate(): HTMLTemplateResult {
     return html`<input
         type="hidden"
         name="field_reviewtoken"
-        .value=${this.token}
+        .value=${this.token ?? ''}
path: src/review-form.ts
user: login: rebecca-shoptaw
body: i.e. Make the `token` required and default it to an empty string in the definition? I'm fine with that.
created_at: 2025-03-25T18:03:01Z
author_association: COLLABORATOR
diff_hunk: @@ -279,13 +391,18 @@ export class ReviewForm extends LitElement {
           );
 
           color: var(--ia-text-color, #2c2c2c);
+          font-size: 1.4rem;
path: src/review-form.ts
user: login: rebecca-shoptaw
body: Good call! I'll move this into the demo app.
created_at: 2025-03-25T18:23:46Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-24T17:18:02Z
body: [PR Preview Action](https://github.com/rossjrw/pr-preview-action) v1.6.0
:---:
Preview removed because the pull request was closed.
2025-03-27 14:49 UTC
<!-- Sticky Pull Request Commentpr-preview -->
author_association: NONE
user: login: rebecca-shoptaw
created_at: 2025-03-25T13:08:47Z
body: Build failure confirmed unrelated and being worked on separately -- feel free to ignore that for CR purposes
author_association: COLLABORATOR
user: login: codecov-commenter
created_at: 2025-03-27T14:44:51Z
body: ## [Codecov](https://app.codecov.io/gh/internetarchive/iaux-reviews/pull/22?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive) Report
Attention: Patch coverage is `96.38554%` with `6 lines` in your changes missing coverage. Please review.
> Project coverage is 88.82%. Comparing base [(`9184f17`)](https://app.codecov.io/gh/internetarchive/iaux-reviews/commit/9184f17ec37a6a449a8fb4295e990af5558f2ef7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive) to head [(`20d29c6`)](https://app.codecov.io/gh/internetarchive/iaux-reviews/commit/20d29c646e1d694d756c34e1f507f46f3e43e54a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive).

| [Files with missing lines](https://app.codecov.io/gh/internetarchive/iaux-reviews/pull/22?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive) | Patch % | Lines |
|---|---|---|
| [src/review-form.ts](https://app.codecov.io/gh/internetarchive/iaux-reviews/pull/22?src=pr&el=tree&filepath=src%2Freview-form.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive#diff-c3JjL3Jldmlldy1mb3JtLnRz) | 96.38% | [6 Missing :warning: ](https://app.codecov.io/gh/internetarchive/iaux-reviews/pull/22?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #22      +/-   ##
==========================================
- Coverage   89.34%   88.82%   -0.52%     
==========================================
  Files           6        5       -1     
  Lines         535      564      +29     
  Branches       20       44      +24     
==========================================
+ Hits          478      501      +23     
- Misses         57       63       +6     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/internetarchive/iaux-reviews/pull/22?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=internetarchive).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE