Repository: CrowdStrike/glide-core
Pull request #773: Align Input's pattern attribute with native
Author: login: ynotdraw
Created: 2025-03-17T17:32:54Z | Merged: 2025-03-18T11:34:18Z

## 🚀 Description

Input's `pattern` validation now matches native behavior:

- Requires a full string `value` match instead of a partial match.
- Empty values are considered valid when `pattern` is present.

## 📋 Checklist

<!-- Do the following before adding reviewers: -->

- I have followed the [Contributing Guidelines](https://github.com/crowdstrike/glide-core/blob/main/CONTRIBUTING.md).
- I have added tests to cover new or updated functionality.
- I have added or updated Storybook stories.
- I have [localized](https://github.com/CrowdStrike/glide-core/blob/main/CONTRIBUTING.md#translations-and-static-strings) new strings.
- I have followed the [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/patterns/) or met with the Accessibility Team.
- I have included a [changeset](https://github.com/CrowdStrike/glide-core/blob/main/CONTRIBUTING.md#versioning-a-package).
- I have scheduled a design review.
- I have reviewed the Storybook and Visual Test Report links below.

## 🔬 Testing

- Navigate to `Input`'s [Storybook](https://glide-core.crowdstrike-ux.workers.dev/input-pattern?path=/docs/input--overview)
- Set a pattern like `[A-Za-z_\s][A-Za-z_0-9\s]*`
- Enter `hello` into the Input
- Check `glide-core-input`'s validity with `$0.validity.valid`
- Verify it returns `true`
- Update the value to be `!hello`
- Verify `$0.validity.valid` returns `false`
- Delete the entire value from the input
- Verify `$0.validity.valid` returns `true`

## 📸 Images and videos

N/A



Diff:
diff --git a/.changeset/dirty-pumas-search.md b/.changeset/dirty-pumas-search.md
new file mode 100644
index 000000000..53e9c287b
--- /dev/null
+++ b/.changeset/dirty-pumas-search.md
@@ -0,0 +1,8 @@
+---
+'@crowdstrike/glide-core': minor
+---
+
+Input's `pattern` validation now matches native behavior:
+
+- Requires a full string `value` match instead of a partial match.
+- Empty values are considered valid when `pattern` is present.
diff --git a/custom-elements.json b/custom-elements.json
index a76575b90..7492a04a3 100644
--- a/custom-elements.json
+++ b/custom-elements.json
@@ -3601,6 +3601,7 @@
               "type": {
                 "text": "string | undefined"
               },
+              "default": "''",
               "attribute": "pattern",
               "reflects": true
             },
@@ -3905,6 +3906,7 @@
               "type": {
                 "text": "string | undefined"
               },
+              "default": "''",
               "fieldName": "pattern"
             },
             {
diff --git a/src/input.test.forms.ts b/src/input.test.forms.ts
index 7f05f8e5c..36b312dec 100644
--- a/src/input.test.forms.ts
+++ b/src/input.test.forms.ts
@@ -330,7 +330,7 @@ it('is valid when the `value` attribute matches `pattern`', async () => {
   const host = await fixture<GlideCoreInput>(
     html`<glide-core-input
       label="Label"
-      pattern="[a-z]{4,8}"
+      pattern="[A-Za-z_s][A-Za-z_0-9s]*"
       value="value"
     ></glide-core-input>`,
   );
@@ -367,12 +367,30 @@ it('is valid when `value` matches `pattern` after being set programmatically', a
   ).to.equal('false');
 });
 
-it('is invalid when `value` does not match `pattern`', async () => {
+it('is valid when `pattern` is set and `value` is empty', async () => {
   const host = await fixture<GlideCoreInput>(
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
-      value="1234"
+    ></glide-core-input>`,
+  );
+
+  expect(host.validity?.valid).to.be.true;
+  expect(host.validity?.patternMismatch).to.be.false;
+  expect(host.checkValidity()).to.be.true;
+  expect(host.reportValidity()).to.be.true;
+
+  expect(
+    host.shadowRoot?.querySelector('[data-test="input"]')?.ariaInvalid,
+  ).to.equal('false');
+});
+
+it('is invalid when `value` does not match `pattern`', async () => {
+  const host = await fixture<GlideCoreInput>(
+    html`<glide-core-input
+      label="Label"
+      pattern="[A-Za-z_s][A-Za-z_0-9s]*"
+      value="!value"
     ></glide-core-input>`,
   );
 
@@ -415,12 +433,26 @@ it('is invalid when `required` and `value` does not match `pattern`', async () =
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
       required
     ></glide-core-input>`,
   );
 
   expect(host.validity?.valid).to.be.false;
   expect(host.validity?.patternMismatch).to.be.true;
+});
+
+it('is invalid when `required`, has an empty `value`, and a `pattern`', async () => {
+  const host = await fixture<GlideCoreInput>(
+    html`<glide-core-input
+      label="Label"
+      pattern="[a-z]{4,8}"
+      required
+    ></glide-core-input>`,
+  );
+
+  expect(host.validity?.valid).to.be.false;
+  expect(host.validity?.patternMismatch).to.be.false;
   expect(host.validity?.valueMissing).to.be.true;
 });
 
@@ -429,6 +461,7 @@ it('is valid when `pattern` is programmatically removed', async () => {
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
     ></glide-core-input>`,
   );
 
@@ -557,10 +590,12 @@ it('is valid when `setValidity()` is called', async () => {
 });
 
 it('retains existing validity state when `setCustomValidity()` is called', async () => {
+  // `value` does not match `pattern` intentionally to force an invalid state
   const host = await fixture<GlideCoreInput>(
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
       required
     ></glide-core-input>`,
   );
@@ -570,7 +605,7 @@ it('retains existing validity state when `setCustomValidity()` is called', async
   expect(host.validity?.valid).to.be.false;
   expect(host.validity?.customError).to.be.true;
   expect(host.validity?.patternMismatch).to.be.true;
-  expect(host.validity?.valueMissing).to.be.true;
+  expect(host.validity?.valueMissing).to.be.false;
 });
 
 it('removes its validity feedback but retains its validity state when `resetValidityFeedback()` is called', async () => {
diff --git a/src/input.ts b/src/input.ts
index 12ed669d6..1be011494 100644
--- a/src/input.ts
+++ b/src/input.ts
@@ -35,7 +35,7 @@ declare global {
  * @attr {string} [name='']
  * @attr {'horizontal'|'vertical'} [orientation='horizontal']
  * @attr {boolean} [password-toggle=false] - For 'password' type, whether to show a button to toggle the password's visibility
- * @attr {string} [pattern]
+ * @attr {string} [pattern='']
  * @attr {string} [placeholder]
  * @attr {boolean} [readonly=false]
  * @attr {boolean} [required=false]
@@ -122,7 +122,7 @@ export default class GlideCoreInput extends LitElement implements FormControl {
   orientation: 'horizontal' | 'vertical' = 'horizontal';
 
   @property({ reflect: true })
-  pattern?: string;
+  pattern: string | undefined = '';
 
   @property({ reflect: true })
   placeholder?: string;
@@ -183,13 +183,26 @@ export default class GlideCoreInput extends LitElement implements FormControl {
   }
 
   get validity(): ValidityState {
-    if (this.pattern) {
+    if (this.pattern && this.pattern.length > 0) {
       // A validation message is required but unused because we disable native validation feedback.
       // And an empty string isn't allowed. Thus a single space.
       this.#internals.setValidity(
         {
           customError: Boolean(this.validityMessage),
-          patternMismatch: !new RegExp(this.pattern).test(this.value),
+          // Empty values do not trigger a pattern mismatch error to align
+          // with native behavior¹.
+          //
+          // When checking pattern validity like the native input element, we
+          // need to ensure it matches the entire string, not just a portion of it¹.
+          //
+          // The regex we use internally matches native's according to the documentation².
+          //
+          // 1: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern#constraint_validation
+          // 2: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/pattern#overview
+          patternMismatch: Boolean(
+            this.value &&
+              !new RegExp(`^(?:${this.pattern})$`, 'v').test(this.value),
+          ),
           valueMissing: Boolean(this.required && !this.value),
         },
         ' ',
@@ -288,7 +301,7 @@ export default class GlideCoreInput extends LitElement implements FormControl {
           slot="control"
         >
           <slot name="prefix-icon">
-            <!-- 
+            <!--
               An icon before the input field
               @type {Element}
             -->
@@ -378,8 +391,8 @@ export default class GlideCoreInput extends LitElement implements FormControl {
             })}
             name="description"
           >
-            <!-- 
-              Additional information or context 
+            <!--
+              Additional information or context
               @type {Element | string}
             -->
           </slot>


Reviews:
user: login: ynotdraw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T17:44:19Z
user: login: clintcs
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-17T17:53:12Z
user: login: ynotdraw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T18:00:13Z
user: login: ynotdraw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T18:00:33Z
user: login: ynotdraw
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T18:00:44Z
user: login: clintcs
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-17T18:05:29Z
user: login: danwenzel
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-17T18:58:04Z

Review comments:
diff_hunk: @@ -330,7 +330,7 @@ it('is valid when the `value` attribute matches `pattern`', async () => {
   const host = await fixture<GlideCoreInput>(
     html`<glide-core-input
       label="Label"
-      pattern="[a-z]{4,8}"
+      pattern="[A-Za-z_s][A-Za-z_0-9s]*"
path: src/input.test.forms.ts
user: login: ynotdraw
body: Updated the regex in a few tests to account for the partial match case
created_at: 2025-03-17T17:43:11Z
author_association: COLLABORATOR
diff_hunk: @@ -415,12 +433,26 @@ it('is invalid when `required` and `value` does not match `pattern`', async () =
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
path: src/input.test.forms.ts
user: login: clintcs
body: ```suggestion
      value="value"
```
created_at: 2025-03-17T17:51:42Z
author_association: COLLABORATOR
diff_hunk: @@ -429,6 +461,7 @@ it('is valid when `pattern` is programmatically removed', async () => {
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
path: src/input.test.forms.ts
user: login: clintcs
body: ```suggestion
      value="value"
```
created_at: 2025-03-17T17:51:57Z
author_association: COLLABORATOR
diff_hunk: @@ -378,8 +391,8 @@ export default class GlideCoreInput extends LitElement implements FormControl {
             })}
             name="description"
           >
-            <!-- 
-              Additional information or context 
+            <!--
+              Additional information or context
path: src/input.ts
user: login: clintcs
body: 😁
created_at: 2025-03-17T17:52:30Z
author_association: COLLABORATOR
diff_hunk: @@ -415,12 +433,26 @@ it('is invalid when `required` and `value` does not match `pattern`', async () =
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
path: src/input.test.forms.ts
user: login: ynotdraw
body: Based on the regex and since we want to trigger an **invalid** state, we need `val` here or something _under_ 4 characters. Maybe comment worthy? Or could use numbers instead? Or a different regex altogether?
created_at: 2025-03-17T18:00:13Z
author_association: COLLABORATOR
diff_hunk: @@ -429,6 +461,7 @@ it('is valid when `pattern` is programmatically removed', async () => {
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
path: src/input.test.forms.ts
user: login: ynotdraw
body: (Same comment here as [above](https://github.com/CrowdStrike/glide-core/pull/773#discussion_r1999348254))
created_at: 2025-03-17T18:00:33Z
author_association: COLLABORATOR
diff_hunk: @@ -557,10 +590,12 @@ it('is valid when `setValidity()` is called', async () => {
 });
 
 it('retains existing validity state when `setCustomValidity()` is called', async () => {
+  // `value` does not match `pattern` intentionally to force an invalid state
   const host = await fixture<GlideCoreInput>(
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
path: src/input.test.forms.ts
user: login: ynotdraw
body: (Same comment here as [above](https://github.com/CrowdStrike/glide-core/pull/773#discussion_r1999348254))
created_at: 2025-03-17T18:00:44Z
author_association: COLLABORATOR
diff_hunk: @@ -415,12 +433,26 @@ it('is invalid when `required` and `value` does not match `pattern`', async () =
     html`<glide-core-input
       label="Label"
       pattern="[a-z]{4,8}"
+      value="val"
path: src/input.test.forms.ts
user: login: clintcs
body: Derp. Carry on!
created_at: 2025-03-17T18:05:29Z
author_association: COLLABORATOR

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-17T17:32:57Z
body: ###  🦋  Changeset detected

Latest commit: 53f93b4e9c82de0685152b5e3f3818ff7a73a3d8

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name                    | Type  |
| ----------------------- | ----- |
| @crowdstrike/glide-core | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/CrowdStrike/glide-core/new/input-pattern?filename=.changeset/sharp-walls-lay.md&value=---%0A%22%40crowdstrike%2Fglide-core%22%3A%20patch%0A---%0A%0AAlign%20Input's%20pattern%20attribute%20with%20native%0A)


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-17T17:34:29Z
body: ## Storybook
https://glide-core.crowdstrike-ux.workers.dev/input-pattern
<!-- Sticky Pull Request Commentstorybook -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-17T17:36:01Z
body: ## Visual Test Report
https://glide-core.crowdstrike-ux.workers.dev/input-pattern/visual-test-report
<!-- Sticky Pull Request Commentvisual-test-report -->
author_association: CONTRIBUTOR