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
58 changes: 58 additions & 0 deletions 1st-gen/packages/combobox/src/Combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,22 @@ export class Combobox extends Textfield {
@property({ type: String, attribute: 'pending-label' })
public pendingLabel = 'Pending';

/**
* When true, enables haptic feedback on supported platforms (e.g. iOS 18+ Safari)
* for accessibility. Uses the native `<input type="checkbox" switch>` haptic
* by toggling a hidden control on selection and when the list opens.
*
* @see https://webkit.org/blog/15865/webkit-features-in-safari-18-0/
*/
@property({ type: Boolean, attribute: 'haptic-feedback', reflect: true })
public hapticFeedback = false;

@query('slot:not([name])')
private optionSlot!: HTMLSlotElement;

@query('#haptic-trigger')
private hapticTriggerEl?: HTMLInputElement;

@state()
overlayOpen = false;

Expand Down Expand Up @@ -306,6 +319,7 @@ export class Combobox extends Textfield {
(item) => item.value === target?.value
);
this.value = selected?.itemText || '';
this.triggerHapticFeedback();
event.preventDefault();
this.open = false;
this._returnItems();
Expand All @@ -321,12 +335,36 @@ export class Combobox extends Textfield {
// Do stuff here?
}

/**
* Triggers haptic feedback when enabled. Uses Vibration API on Android when
* available; on iOS (no vibrate), programmatically clicks the hidden switch's
* label so the native switch haptic fires (Safari 18+).
*
* @see https://codepen.io/jh3y/pen/PwGPaZQ
*/
private triggerHapticFeedback(): void {
if (!this.hapticFeedback) {
return;
}
if ('vibrate' in navigator) {
navigator.vibrate(16);
return;
}
const label = this.shadowRoot?.getElementById('haptic-label');
if (label) {
label.click();
}
}

public toggleOpen(): void {
if (this.readonly || this.pending) {
this.open = false;
return;
}
this.open = !this.open;
if (this.open) {
this.triggerHapticFeedback();
}
this.inputElement.focus();
}

Expand Down Expand Up @@ -524,6 +562,23 @@ export class Combobox extends Textfield {
</sp-popover>
</sp-overlay>
${this.renderVisuallyHiddenLabels()}
${this.hapticFeedback
? html`
<label
id="haptic-label"
for="haptic-trigger"
class="visually-hidden"
aria-hidden="true"
></label>
<input
type="checkbox"
id="haptic-trigger"
class="visually-hidden"
aria-hidden="true"
tabindex="-1"
/>
`
: nothing}
<slot
aria-hidden="true"
name="tooltip"
Expand Down Expand Up @@ -567,6 +622,9 @@ export class Combobox extends Textfield {
}

protected override updated(changed: PropertyValues<this>): void {
if (this.hapticFeedback && this.hapticTriggerEl) {
this.hapticTriggerEl.setAttribute('switch', '');
}
if (changed.has('open') && !this.pending) {
this.manageListOverlay();
}
Expand Down
13 changes: 13 additions & 0 deletions 1st-gen/packages/combobox/stories/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,17 @@ export const argTypes = {
type: 'boolean',
},
},
hapticFeedback: {
name: 'hapticFeedback',
type: { name: 'boolean', required: false },
description:
'Enables haptic feedback on supported platforms (e.g. iOS 18+ Safari) for accessibility.',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: false },
},
control: {
type: 'boolean',
},
},
};
113 changes: 113 additions & 0 deletions 1st-gen/packages/combobox/stories/combobox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,119 @@ readonly.args = {
value: 'Solomon Islands',
};

export const webHaptics = (args: StoryArgs): TemplateResult => html`
<div class="web-haptics-story">
<div class="web-haptics-story__instructions">
<h3>Web Haptics (accessibility)</h3>
<p>
Test haptic feedback on
<strong>iOS 18+ Safari</strong>
(iPhone/iPad). Haptics fire when you open the list and when you select
an option.
</p>
<ul>
<li>
<strong>With haptics:</strong>
tap the first combobox — you should feel a tap when the list opens and
again when you pick an option.
</li>
<li>
<strong>Without haptics:</strong>
second combobox has haptics disabled for comparison.
</li>
</ul>
<p>
Uses the native
<code>&lt;input type="checkbox" switch&gt;</code>
haptic in Safari 18.
<a
href="https://webkit.org/blog/15865/webkit-features-in-safari-18-0/"
target="_blank"
rel="noopener noreferrer"
>
WebKit blog
</a>
.
</p>
</div>
<div class="web-haptics-story__demos">
<div class="web-haptics-story__demo">
<sp-field-label for="combobox-haptic-on">
With haptic feedback
</sp-field-label>
<sp-combobox
id="combobox-haptic-on"
.options=${fruits}
.value=${args.value ?? ''}
?haptic-feedback=${true}
></sp-combobox>
</div>
<div class="web-haptics-story__demo">
<sp-field-label for="combobox-haptic-off">
Without haptic feedback
</sp-field-label>
<sp-combobox
id="combobox-haptic-off"
.options=${fruits}
.value=${args.value ?? ''}
></sp-combobox>
</div>
</div>
</div>
<style>
.web-haptics-story {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-inline-size: 32rem;
}
.web-haptics-story__instructions {
padding: 1rem;
background: var(--spectrum-gray-100, #f5f5f5);
border-radius: 0.5rem;
}
.web-haptics-story__instructions h3 {
margin: 0 0 0.5rem 0;
font-size: 1.125rem;
}
.web-haptics-story__instructions p,
.web-haptics-story__instructions ul {
margin: 0.5rem 0 0 0;
font-size: 0.875rem;
}
.web-haptics-story__instructions code {
font-size: 0.8125rem;
padding: 0.125rem 0.25rem;
background: var(--spectrum-gray-200, #e5e5e5);
border-radius: 0.25rem;
}
.web-haptics-story__instructions a {
color: var(--spectrum-blue-600, #0d66d0);
}
.web-haptics-story__demos {
display: flex;
flex-direction: column;
gap: 1rem;
}
.web-haptics-story__demo {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
</style>
`;
webHaptics.args = {
value: '',
};
webHaptics.parameters = {
docs: {
description: {
story:
'Side-by-side comboboxes to test native haptic feedback on iOS 18+ Safari. Enable haptics for accessibility when users benefit from tactile confirmation (e.g. open list, selection).',
},
},
};

export const hasDisabledItems = (args: StoryArgs): TemplateResult => {
// let's create a new array from countries and set the disabled property to true if the value is in args.disabledItems
const countriesWithDisabledItems = countries.map((country) => ({
Expand Down
1 change: 1 addition & 0 deletions 1st-gen/packages/combobox/stories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type StoryArgs = {
value?: string;
disabledItems?: string[];
autocomplete?: 'list' | 'none';
hapticFeedback?: boolean;
size?: ElementSize;
onChange?: (val: string) => void;
onInput?: (val: string) => void;
Expand Down
46 changes: 46 additions & 0 deletions 1st-gen/packages/number-field/src/NumberField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ export class NumberField extends TextfieldBase {
@property({ type: Number, reflect: true, attribute: 'step-modifier' })
public stepModifier = 10;

/**
* When true, triggers haptic feedback on value commit (stepper release, input
* change, keyboard step). Same pattern as combobox/slider/picker-button.
*/
@property({ type: Boolean, attribute: 'haptic-feedback', reflect: true })
public hapticFeedback = false;

@query('#haptic-trigger')
private hapticTriggerEl?: HTMLInputElement;

@property({ type: Number })
public override set value(rawValue: number) {
const value = this.validateInput(rawValue);
Expand Down Expand Up @@ -193,10 +203,25 @@ export class NumberField extends TextfieldBase {
}

this.lastCommitedValue = this.value;
this.triggerHapticFeedback();

this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
}

public triggerHapticFeedback(): void {
if (!this.hapticFeedback) {
return;
}
if ('vibrate' in navigator) {
navigator.vibrate(16);
return;
}
const label = this.shadowRoot?.getElementById('haptic-label');
if (label) {
label.click();
}
}

/**
* Retreive the value of the element parsed to a Number.
*/
Expand Down Expand Up @@ -728,6 +753,24 @@ export class NumberField extends TextfieldBase {
this.autocomplete = 'off';
return html`
${super.renderField()}
${this.hapticFeedback
? html`
<label
id="haptic-label"
for="haptic-trigger"
class="visually-hidden"
aria-hidden="true"
></label>
<input
style="display: none;"
type="checkbox"
id="haptic-trigger"
class="visually-hidden"
aria-hidden="true"
tabindex="-1"
/>
`
: nothing}
${this.hideStepper
? nothing
: html`
Expand Down Expand Up @@ -826,6 +869,9 @@ export class NumberField extends TextfieldBase {
}

protected override updated(changes: PropertyValues<this>): void {
if (this.hapticFeedback && this.hapticTriggerEl) {
this.hapticTriggerEl.setAttribute('switch', '');
}
if (!this.inputElement || !this.isConnected) {
// Prevent race conditions if inputElement is removed from DOM while a queued update is still running.
return;
Expand Down
Loading
Loading