From cec1011d7664de3a644b93087a911722abd7b7b3 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Tue, 28 Jul 2026 17:17:50 -0700 Subject: [PATCH 1/2] WEBDEV-8811: Migrate ia-clearable-text-input into elements Brings the component in as src/elements/ia-clearable-text-input/, the same shape as the ia-dropdown move, and points ia-dropdown-search-bar at the local copy so the external package can go. That was elements' last remaining @internetarchive dependency, so there are none left. The close icon is inlined as a Lit svg template rather than keeping @internetarchive/icon-close as a dependency, following the caret icons from the dropdown move. Dropping it also takes a second copy of Lit out of the tree: icon-close was pinned at ^1.3.4, which resolves its own lit and made the component's own test run log "Multiple versions of Lit loaded". The rendered result is unchanged, a 40px glyph on a dark circle, but the shadow DOM now holds a span and an svg instead of an element. Tests move from web-test-runner, @open-wc/testing and sinon over to vitest and vi.fn. The a11y audit that relied on chai-a11y-axe becomes a direct assertion that the label points at the input, since elements has no axe setup. Also added a test for the clear event carrying the pre-clear value, which nothing covered before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YWG9C3uwCAtAJUABguZnV9 --- package-lock.json | 60 ----- package.json | 1 - .../ia-clearable-text-input/assets/close.ts | 18 ++ .../ia-clearable-text-input-story.ts | 121 +++++++++ .../ia-clearable-text-input.test.ts | 170 ++++++++++++ .../ia-clearable-text-input.ts | 254 ++++++++++++++++++ .../ia-dropdown-search-bar.test.ts | 2 +- .../ia-dropdown-search-bar.ts | 4 +- src/elements/index.ts | 1 + 9 files changed, 567 insertions(+), 64 deletions(-) create mode 100644 src/elements/ia-clearable-text-input/assets/close.ts create mode 100644 src/elements/ia-clearable-text-input/ia-clearable-text-input-story.ts create mode 100644 src/elements/ia-clearable-text-input/ia-clearable-text-input.test.ts create mode 100644 src/elements/ia-clearable-text-input/ia-clearable-text-input.ts diff --git a/package-lock.json b/package-lock.json index 384f90d..0337a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.2.10", "license": "AGPL-3.0-only", "dependencies": { - "@internetarchive/ia-clearable-text-input": "^1.1.1", "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", @@ -763,65 +762,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@internetarchive/ia-clearable-text-input": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@internetarchive/ia-clearable-text-input/-/ia-clearable-text-input-1.1.1.tgz", - "integrity": "sha512-l3Z6gKfSRURvicHlAjL3V+q/yESYSev34fswfG7ESIiWuF7UuWDKjlLOyY3gFch8eAfqS5AQ6KurRw5JWGD4RA==", - "license": "AGPL-3.0-only", - "dependencies": { - "@internetarchive/icon-close": "^1.3.4", - "lit": "^2.2.7" - } - }, - "node_modules/@internetarchive/ia-clearable-text-input/node_modules/@lit/reactive-element": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", - "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.0.0" - } - }, - "node_modules/@internetarchive/ia-clearable-text-input/node_modules/lit": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", - "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit/reactive-element": "^1.6.0", - "lit-element": "^3.3.0", - "lit-html": "^2.8.0" - } - }, - "node_modules/@internetarchive/ia-clearable-text-input/node_modules/lit-element": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", - "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", - "license": "BSD-3-Clause", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.1.0", - "@lit/reactive-element": "^1.3.0", - "lit-html": "^2.8.0" - } - }, - "node_modules/@internetarchive/ia-clearable-text-input/node_modules/lit-html": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", - "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "@types/trusted-types": "^2.0.2" - } - }, - "node_modules/@internetarchive/icon-close": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@internetarchive/icon-close/-/icon-close-1.4.1.tgz", - "integrity": "sha512-nN1rGfScGssGFpfEew4azsrsMyUJtkDWqUDlckJi31PtyQnbZqUmZ07UO3Aw+wZIo3cU1MHxb5VxDy3Rmr8QGA==", - "license": "AGPL-3.0-only", - "dependencies": { - "lit": "^2.0.2 || ^3.0.0" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", diff --git a/package.json b/package.json index 446abf1..e5eb3b8 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,6 @@ "ghpages:build": "wireit" }, "dependencies": { - "@internetarchive/ia-clearable-text-input": "^1.1.1", "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", diff --git a/src/elements/ia-clearable-text-input/assets/close.ts b/src/elements/ia-clearable-text-input/assets/close.ts new file mode 100644 index 0000000..8e8179c --- /dev/null +++ b/src/elements/ia-clearable-text-input/assets/close.ts @@ -0,0 +1,18 @@ +/** + * @file Close Icon + * + * Kept as an inline Lit `svg` template rather than an imported `.svg` file so + * that the clear button can recolor it from the component's own styles, which + * is not possible on the contents of an ``. + */ +import { svg } from 'lit'; + +export default svg` + + + +`; diff --git a/src/elements/ia-clearable-text-input/ia-clearable-text-input-story.ts b/src/elements/ia-clearable-text-input/ia-clearable-text-input-story.ts new file mode 100644 index 0000000..7d7b12d --- /dev/null +++ b/src/elements/ia-clearable-text-input/ia-clearable-text-input-story.ts @@ -0,0 +1,121 @@ +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import type { PropInputSettings } from '@demo/story-components/story-prop-settings'; +import type { StyleInputSettings } from '@demo/story-components/story-styles-settings'; +import type { IaClearableTextInput } from './ia-clearable-text-input'; + +import './ia-clearable-text-input'; +import '@demo/story-template'; + +const styleInputSettings: StyleInputSettings[] = [ + { + label: 'Height', + cssVariable: '--input-height', + defaultValue: '3rem', + }, + { + label: 'Text color', + cssVariable: '--input-color', + defaultValue: '#555555', + inputType: 'color', + }, + { + label: 'Border color', + cssVariable: '--input-border-color', + defaultValue: '#cccccc', + inputType: 'color', + }, + { + label: 'Border radius', + cssVariable: '--input-border-radius', + defaultValue: '2rem', + }, + { + label: 'Font size', + cssVariable: '--input-font-size', + defaultValue: '1.7rem', + }, + { + label: 'Clear icon background', + cssVariable: '--clear-button-icon-background', + defaultValue: '#2c2c2c', + inputType: 'color', + }, + { + label: 'Clear icon color', + cssVariable: '--clear-button-icon-color', + defaultValue: '#ffffff', + inputType: 'color', + }, +]; + +const propInputSettings: PropInputSettings[] = [ + { + label: 'Value', + propertyName: 'value', + defaultValue: '', + }, + { + label: 'Placeholder', + propertyName: 'placeholder', + defaultValue: 'Search...', + }, + { + label: 'Screen reader label', + propertyName: 'screenReaderLabel', + defaultValue: 'Search the archive', + }, + { + label: 'Clear button screen reader label', + propertyName: 'clearButtonScreenReaderLabel', + defaultValue: 'Clear', + }, + { + label: 'Focus the field after clearing', + propertyName: 'focusOnClear', + defaultValue: true, + inputType: 'radio', + radioOptions: [true, false], + }, + { + label: 'Always show the clear button', + propertyName: 'forceClearButton', + defaultValue: false, + inputType: 'radio', + radioOptions: [true, false], + }, +]; + +@customElement('ia-clearable-text-input-story') +export class IaClearableTextInputStory extends LitElement { + render() { + return html` + + +
+

+ A text field with a clear button that appears once there's something + to clear. Set forceClearButton to keep the button + visible even when the field is empty. +

+

+ Emits input on every value change, including when the + clear button empties the field, clear carrying the + value the field held beforehand, and submit carrying + the current value when Enter is pressed. +

+

+ screenReaderLabel is what labels the field, so it needs + setting for the component to be accessible. +

+
+
+ `; + } +} diff --git a/src/elements/ia-clearable-text-input/ia-clearable-text-input.test.ts b/src/elements/ia-clearable-text-input/ia-clearable-text-input.test.ts new file mode 100644 index 0000000..710f677 --- /dev/null +++ b/src/elements/ia-clearable-text-input/ia-clearable-text-input.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test, vi } from 'vitest'; +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; + +import type { IaClearableTextInput } from './ia-clearable-text-input'; +import './ia-clearable-text-input'; + +/** The clear button inside the component's shadow root. */ +const clearButtonOf = (el: IaClearableTextInput): HTMLButtonElement => + el.shadowRoot?.querySelector('#clear-button') as HTMLButtonElement; + +/** The text field inside the component's shadow root. */ +const inputOf = (el: IaClearableTextInput): HTMLInputElement => + el.shadowRoot?.querySelector('#text-input') as HTMLInputElement; + +describe('IaClearableTextInput', () => { + test('has a clear button, initially hidden', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + const clearButton = clearButtonOf(el); + expect(clearButton).to.exist; + expect(clearButton.hidden).to.equal(true); + }); + + test('shows the clear button when forced by property, even without text', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + expect(clearButtonOf(el).hidden).to.equal(false); + }); + + test('shows the clear button when the input field has initial text', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + expect(clearButtonOf(el).hidden).to.equal(false); + }); + + test('shows the clear button when text is entered into the input field', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + const inputField = inputOf(el); + inputField.value = 'a'; + // Setting the input's value programmatically doesn't fire an input event. + // So to simulate real user input, we need to fire the event as well. + inputField.dispatchEvent(new Event('input')); + await el.updateComplete; + + expect(clearButtonOf(el).hidden).to.equal(false); + }); + + test('clears the text field when the clear button is clicked', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + expect(el.value).to.equal('a'); + expect(clearButtonOf(el).hidden).to.equal(false); + + clearButtonOf(el).click(); + await el.updateComplete; + + expect(el.value).to.equal(''); + expect(clearButtonOf(el).hidden).to.equal(true); + }); + + test('focuses the text field upon clearing', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + clearButtonOf(el).click(); + await el.updateComplete; + + expect(el.shadowRoot?.activeElement).to.equal(inputOf(el)); + }); + + test('does not focus the text field upon clearing if focusOnClear is false', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + clearButtonOf(el).click(); + await el.updateComplete; + + expect(el.shadowRoot?.activeElement).to.not.equal(inputOf(el)); + }); + + test('emits a clear event carrying the value it had before clearing', async () => { + const clearListener = vi.fn(); + const el = await fixture( + html``, + ); + await el.updateComplete; + + clearButtonOf(el).click(); + await el.updateComplete; + + expect(clearListener).toHaveBeenCalledOnce(); + expect(clearListener.mock.calls[0][0].detail).to.equal('a'); + }); + + test('blurs and emits submit event upon hitting enter', async () => { + const submitListener = vi.fn(); + const el = await fixture( + html``, + ); + + inputOf(el).dispatchEvent(new KeyboardEvent('keypress', { key: 'Enter' })); + await el.updateComplete; + + expect(submitListener).toHaveBeenCalledOnce(); + expect(el.shadowRoot?.activeElement).to.not.exist; // No focused element + }); + + test('accepts optional properties', async () => { + const placeholder = 'Search...'; + const clearSRText = 'Clear search field'; + + const el = await fixture( + html``, + ); + await el.updateComplete; + + expect(el).to.exist; + expect(inputOf(el).placeholder).to.equal(placeholder); + expect(clearButtonOf(el).textContent?.trim()).to.equal(clearSRText); + }); + + test('labels the text field with the given screen reader label', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + const label = el.shadowRoot?.querySelector('label'); + expect(label?.textContent?.trim()).to.equal('Enter your first name'); + // the label has to point at the input for it to be announced + expect(label?.getAttribute('for')).to.equal(inputOf(el).id); + }); +}); diff --git a/src/elements/ia-clearable-text-input/ia-clearable-text-input.ts b/src/elements/ia-clearable-text-input/ia-clearable-text-input.ts new file mode 100644 index 0000000..92e005e --- /dev/null +++ b/src/elements/ia-clearable-text-input/ia-clearable-text-input.ts @@ -0,0 +1,254 @@ +import { html, css, nothing, LitElement, TemplateResult } from 'lit'; +import { property, query, customElement } from 'lit/decorators.js'; + +import closeIcon from './assets/close'; + +@customElement('ia-clearable-text-input') +export class IaClearableTextInput extends LitElement { + /** + * The value shown in the text field. + */ + @property({ type: String }) value = ''; + + /** + * The placeholder value shown in the text field when it is empty. + */ + @property({ type: String }) placeholder?: string; + + /** + * The accessible label describing the text field for users of screen readers. + * Must be included to meet accessibility requirements. + */ + @property({ type: String }) screenReaderLabel?: string; + + /** + * The accessible label describing the clear button for users of screen + * readers (defaults to 'Clear'). + */ + @property({ type: String }) clearButtonScreenReaderLabel = 'Clear'; + + /** + * An element id specifying additional controls for the text field, for + * users of screen readers. + */ + @property({ type: String }) ariaControls?: string; + + /** + * Whether the text field should be automatically focused when the clear + * button is pressed. + */ + @property({ type: Boolean }) focusOnClear = true; + + /** + * If true, the clear button will be shown regardless of whether there is any + * text entered in the input field. + */ + @property({ type: Boolean, reflect: true }) forceClearButton = false; + + @query('#text-input') + private textInput!: HTMLInputElement; + + // Options to pass in when creating the shadow root. + // This ensures that focusing the component delegates focus to the search bar. + static shadowRootOptions = { + ...LitElement.shadowRootOptions, + delegatesFocus: true, + }; + + protected render(): TemplateResult { + const hideClearButton = !this.value && !this.forceClearButton; + + return html` +
+ + + + +
+ `; + } + + private onTextInput(): void { + // Update the component value to match the input element + this.value = this.textInput.value; + } + + private onKeyPress(e: KeyboardEvent): void { + // Dispatch a 'submit' event if Enter was pressed on the input field, simulating + // the behavior the input would have within a form. Since native submit events don't + // cross the Shadow DOM boundary, and since this component may or may not be inside a + // form element, we emit this event so that parent components don't need to listen for + // arbitrary key events just for this single use case. + if (e.key === 'Enter') { + // Blur the input field + this.textInput.blur(); + + const submitEvent = new CustomEvent('submit', { + detail: this.value, + }); + this.dispatchEvent(submitEvent); + } + } + + private clearButtonClicked(): void { + // Store the value of the text field before clearing it, to send along with the clear event + const preClearValue = this.textInput.value; + + // Empty the text field and focus it if desired + this.value = ''; + if (this.focusOnClear) this.textInput.focus(); + + // Dispatch a 'clear' event with the value the text field had prior to clearing + const clearEvent = new CustomEvent('clear', { + detail: preClearValue, + }); + this.dispatchEvent(clearEvent); + + // Also dispatch an 'input' event with the newly empty value upon clearing. + // Note that most ordinary input events are bubbled and retargeted across the Shadow DOM + // boundary automatically. We only need this here because the input field's value has been + // cleared programmatically rather than by user keyboard input. We want every value change + // to produce an input event for consistency. + const inputEvent = new InputEvent('input', { + inputType: 'deleteContentBackward', + }); + this.dispatchEvent(inputEvent); + } + + static styles = css` + :host { + --input-height: 3rem; + --clear-button-top: 0; + --clear-button-right: 2px; + } + + #container { + position: relative; + display: flex; + align-items: center; + width: 100%; + height: var(--input-height); + } + + #text-input { + width: 100%; + height: 100%; + padding: var(--input-padding, 0 1rem); + padding-inline-end: var(--input-height); + border: var(--input-border-width, 1px) var(--input-border-style, solid) + var(--input-border-color, #ccc); + border-radius: var(--input-border-radius, 2rem); + background-image: var(--input-background-image, none); + background-color: var(--input-background-color, transparent); + color: var(--input-color, #555); + font-size: var(--input-font-size, 1.7rem); + line-height: var(--input-line-height, 1.5); + box-shadow: var(--input-box-shadow, inset 0 1px 1px rgba(0, 0, 0, 0.075)); + -webkit-box-shadow: var( + --input-box-shadow, + inset 0 1px 1px rgba(0, 0, 0, 0.075) + ); + } + + #text-input:focus { + border-color: var(--input-focused-border-color, #66afe9); + outline: 0; + box-shadow: var( + --input-focused-box-shadow, + inset 0 1px 1px rgb(0 0 0 / 8%), + 0 0 8px rgb(102 175 233 / 60%) + ); + -webkit-box-shadow: var( + --input-focused-box-shadow, + inset 0 1px 1px rgb(0 0 0 / 8%), + 0 0 8px rgb(102 175 233 / 60%) + ); + } + + #clear-button { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + inset-block-start: var(--clear-button-top); + inset-inline-end: var(--clear-button-right); + height: var(--clear-button-height, var(--input-height)); + width: var(--clear-button-width, var(--input-height)); + padding: var(--clear-button-padding, 4px); + border: var(--clear-button-border, 0); + background: none; + appearance: none; + cursor: pointer; + } + + #clear-button[hidden] { + display: none; + } + + .clear-icon { + display: block; + width: 100%; + height: 100%; + background: var(--clear-button-icon-background, #2c2c2c); + border-radius: 50%; + pointer-events: none; + } + + .clear-icon svg { + display: block; + width: 100%; + height: 100%; + } + + .clear-icon .fill-color { + fill: var(--clear-button-icon-color, #fff); + } + + /* Fallback support for older browsers without newer bidirectional rules */ + @supports not ((inset-block-start: 0) and (padding-inline-end: 0)) { + #text-input { + padding-right: var(--input-height); + } + + #clear-button { + top: var(--clear-button-top); + right: var(--clear-button-right); + } + } + + .sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + padding: 0 !important; + border: 0 !important; + overflow: hidden !important; + white-space: nowrap !important; + clip: rect(1px, 1px, 1px, 1px) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + } + `; +} diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts index 24415cd..e68d259 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'; import { fixture } from '@open-wc/testing-helpers'; import { html } from 'lit'; -import type { IaClearableTextInput } from '@internetarchive/ia-clearable-text-input'; +import type { IaClearableTextInput } from '@src/elements/ia-clearable-text-input/ia-clearable-text-input'; import { IADropdownSearchBar } from './ia-dropdown-search-bar'; import type { SearchRequestedDetail } from './models'; diff --git a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts index 64e9e03..3074fdc 100644 --- a/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts +++ b/src/elements/ia-dropdown-search-bar/ia-dropdown-search-bar.ts @@ -8,7 +8,7 @@ import { TemplateResult, } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; -import type { IaClearableTextInput } from '@internetarchive/ia-clearable-text-input'; +import type { IaClearableTextInput } from '@src/elements/ia-clearable-text-input/ia-clearable-text-input'; import type { IADropdown, OptionInterface, @@ -18,7 +18,7 @@ import type { SearchCategory, SearchRequestedDetail } from './models'; import themeStyles from '@src/themes/theme-styles'; import searchIcon from './search.svg'; -import '@internetarchive/ia-clearable-text-input'; +import '@src/elements/ia-clearable-text-input/ia-clearable-text-input'; import '@src/elements/ia-dropdown/ia-dropdown'; import '@src/elements/ia-status-indicator/ia-status-indicator'; diff --git a/src/elements/index.ts b/src/elements/index.ts index a3b8f82..05736f7 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -1,4 +1,5 @@ export * from './ia-button/ia-button'; +export * from './ia-clearable-text-input/ia-clearable-text-input'; export * from './ia-combo-box/ia-combo-box'; export * from './ia-dropdown/ia-dropdown'; export * from './ia-dropdown/ia-icon-label'; From cb2887650eb2395c83855615b0c3335ae227945a Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Tue, 28 Jul 2026 17:19:13 -0700 Subject: [PATCH 2/2] WEBDEV-8811: Run App CI on pull requests against any branch The pull_request trigger was filtered to main, so this PR got no test run at all, only the preview deploy, because it's stacked on the ia-dropdown branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YWG9C3uwCAtAJUABguZnV9 --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d54c5c..a554fbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,9 @@ name: App CI on: push: branches: [ main ] + # every pull request, whatever it targets, so a PR stacked on another + # branch still gets its tests run pull_request: - branches: [ main ] jobs: build: