Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import { CSSResultArray, html, TemplateResult } from 'lit';
import { CSSResultArray, html, type PropertyValues, TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';

import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
Expand All @@ -34,6 +34,9 @@ import styles from './prompt-field.css';
*
* @element swc-prompt-field
*
* @fires swc-prompt-send-ready - Fired when the send control becomes actionable (was not ready, now ready). Not emitted on first paint. `detail.value` is the current prompt string.
* @fires swc-prompt-send-idle - Fired when the send control stops being actionable (e.g. text cleared and `populated` is false, or `state` becomes `stop`).
*
* @slot artifact - Optional attachment preview; pair with `uploaded-artifact` for shell layout.
*/
export class PromptField extends SpectrumElement {
Expand All @@ -50,7 +53,10 @@ export class PromptField extends SpectrumElement {
@property({ type: String, reflect: true, attribute: 'uploaded-artifact' })
public uploadedArtifact: 'none' | 'card' | 'media' = 'none';

/** When `true`, the send button is enabled. Set this based on whether the textarea has content. */
/**
* When `true`, the send button is enabled even if the textarea is empty (e.g. attachment-only send).
* Otherwise send is enabled when `value` has non-whitespace content.
*/
@property({ type: Boolean, reflect: true })
public populated = false;

Expand All @@ -66,10 +72,58 @@ export class PromptField extends SpectrumElement {
@property({ type: String })
public value = '';

private _sendReady = false;

public static override get styles(): CSSResultArray {
return [styles];
}

/** Whether the send affordance is enabled (`state` is not `stop`, and there is text or `populated`). */
private _isSendReady(): boolean {
if (this.state === 'stop') {
return false;
}
return this.populated || this.value.trim().length > 0;
}

protected override firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._sendReady = this._isSendReady();
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
!changedProperties.has('value') &&
!changedProperties.has('populated') &&
!changedProperties.has('state')
) {
return;
}
const next = this._isSendReady();
if (next === this._sendReady) {
return;
}
if (next) {
this.dispatchEvent(
new CustomEvent('swc-prompt-send-ready', {
bubbles: true,
composed: true,
detail: { value: this.value },
})
);
} else {
this.dispatchEvent(
new CustomEvent('swc-prompt-send-idle', {
bubbles: true,
composed: true,
detail: { value: this.value },
})
);
}
this._sendReady = next;
}

private _handleInput(event: Event): void {
const textarea = event.target as HTMLTextAreaElement;
this.value = textarea.value;
Expand All @@ -83,7 +137,7 @@ export class PromptField extends SpectrumElement {
}

private _handleSendClick(): void {
if (!this.populated) {
if (!this._isSendReady()) {
return;
}
this.dispatchEvent(
Expand Down Expand Up @@ -128,7 +182,7 @@ export class PromptField extends SpectrumElement {
return html`
<button
class="swc-PromptField-send"
?disabled=${!this.populated}
?disabled=${!this._isSendReady()}
aria-label="Send"
@click=${this._handleSendClick}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,13 @@
transition: background 130ms ease;
}

.swc-PromptField-send:focus-visible,
.swc-PromptField-send:focus {
outline-width: 2px;
outline-color: token("focus-indicator-color");
outline-offset: 2px;
}

.swc-PromptField-send:disabled {
color: token("gray-400");
background: token("gray-100");
Expand All @@ -213,6 +220,16 @@
--swc-icon-block-size: 20px;
}

.swc-PromptField-stop swc-icon {
--swc-icon-inline-size: 20px;
--swc-icon-block-size: 20px;
}

.swc-PromptField-send:not(:disabled) swc-icon,
.swc-PromptField-stop:not(:disabled) swc-icon {
color: token("white");
}

/* Stop button (filled black circle, right side) */

.swc-PromptField-stop {
Expand All @@ -234,11 +251,6 @@
background: token("gray-800");
}

.swc-PromptField-stop swc-icon {
--swc-icon-inline-size: 20px;
--swc-icon-block-size: 20px;
}

/* ─────────────────────────────────────
Legal disclaimer
───────────────────────────────────── */
Expand Down Expand Up @@ -294,7 +306,6 @@
display: inline;
}


.swc-PromptField-disclaimer-link {
color: token("gray-700");
text-decoration: underline;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,7 @@ export const Anatomy: Story = {
/**
* The `state` attribute controls which action button appears on the right side of the action bar:
*
* - **`default`** — Send button is shown but disabled (no content yet)
* - **`send`** — Send button is enabled (content present); set via `populated` attribute
* - **`default`** / **`send`** — Send button is shown; enabled when the textarea has non-whitespace text or `populated` is set
* - **`stop`** — Stop button is shown while the AI is generating a response
*/
export const State: Story = {
Expand Down Expand Up @@ -268,7 +267,7 @@ export const UploadedArtifact: Story = {
*
* - The `<textarea>` has an `aria-label` matching the `label` property
* - All icon buttons carry descriptive `aria-label` attributes
* - The send button uses `disabled` natively when `populated` is false
* - The send button uses `disabled` natively when there is no non-whitespace `value` and `populated` is false
*
* ### Best practices
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,66 @@ export const EventsTest: Story = {
);
},
};

// ──────────────────────────────────────────────────────────────
// TEST: Send availability events
// ──────────────────────────────────────────────────────────────

export const SendAvailabilityTest: Story = {
...Overview,
args: {
...Overview.args,
state: 'default',
populated: false,
value: '',
},
play: async ({ canvasElement, step }) => {
const el = await getComponent<PromptField>(
canvasElement,
'swc-prompt-field'
);

await step(
'fires swc-prompt-send-ready when typing makes send actionable',
async () => {
let detailValue = '';
el.addEventListener(
'swc-prompt-send-ready',
((event: CustomEvent<{ value: string }>) => {
detailValue = event.detail.value;
}) as EventListener,
{ once: true }
);

const textarea = el.shadowRoot?.querySelector<HTMLTextAreaElement>(
'.swc-PromptField-textarea'
);
textarea!.value = 'hello';
textarea!.dispatchEvent(new Event('input', { bubbles: true }));

await el.updateComplete;
expect(detailValue).toBe('hello');
}
);

await step('fires swc-prompt-send-idle when text cleared', async () => {
let idleFired = false;
el.addEventListener(
'swc-prompt-send-idle',
() => {
idleFired = true;
},
{ once: true }
);

const textarea = el.shadowRoot?.querySelector<HTMLTextAreaElement>(
'.swc-PromptField-textarea'
);
textarea!.value = '';
textarea!.dispatchEvent(new Event('input', { bubbles: true }));

await el.updateComplete;
expect(idleFired).toBe(true);
});
},
};