Skip to content
Merged
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
87 changes: 87 additions & 0 deletions __tests__/controllers/webchat_controller_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,8 @@ describe('WebchatController', () => {

describe('focusCompose', () => {
beforeEach(() => {
document.body.innerHTML = ''

const input = document.createElement('textarea')
document.body.appendChild(input)

Expand All @@ -1412,6 +1414,22 @@ describe('WebchatController', () => {
expect(event.defaultPrevented).toBe(true)
})

it('does not focus the compose input when automatic compose focus is disabled', () => {
const surface = document.createElement('section')
const event = new Event('pointerdown', { cancelable: true })

Object.defineProperty(controller, 'shouldAutofocusCompose', {
get: () => false,
configurable: true,
})
Object.defineProperty(event, 'target', { value: surface })

controller.focusCompose(event)

expect(document.activeElement).not.toBe(controller.inputTarget)
expect(event.defaultPrevented).toBe(false)
})

it('keeps focus inside the emoji picker instead of moving it to the compose input', () => {
const picker = document.createElement('em-emoji-picker')
const event = new Event('pointerdown', { cancelable: true })
Expand All @@ -1429,6 +1447,75 @@ describe('WebchatController', () => {
})
})

describe('shouldAutofocusCompose', () => {
let originalMatchMedia
let originalNavigatorDescriptors

const setNavigatorProperty = (property, value) => {
Object.defineProperty(window.navigator, property, {
value,
configurable: true,
})
}

beforeEach(() => {
originalMatchMedia = window.matchMedia
originalNavigatorDescriptors = ['userAgent', 'platform', 'maxTouchPoints', 'userAgentData'].reduce(
(descriptors, property) => ({
...descriptors,
[property]: Object.getOwnPropertyDescriptor(window.navigator, property),
}),
{},
)
controller.fullScreenThresholdValue = 1024
window.matchMedia = jest.fn(() => ({ matches: false }))
})

afterEach(() => {
window.matchMedia = originalMatchMedia

Object.entries(originalNavigatorDescriptors).forEach(([property, descriptor]) => {
if (descriptor) {
Object.defineProperty(window.navigator, property, descriptor)
} else {
delete window.navigator[property]
}
})
})

it('does not autofocus on Android devices', () => {
setNavigatorProperty(
'userAgent',
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile Safari/537.36',
)

expect(controller.shouldAutofocusCompose).toBe(false)
})

it('does not autofocus on iPadOS devices using desktop-style user agents', () => {
setNavigatorProperty(
'userAgent',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Safari/605.1.15',
)
setNavigatorProperty('platform', 'MacIntel')
setNavigatorProperty('maxTouchPoints', 5)

expect(controller.shouldAutofocusCompose).toBe(false)
})

it('does not autofocus on touch-only pointer devices', () => {
window.matchMedia = jest.fn(query => ({
matches: ['(pointer: coarse)', '(hover: none)'].includes(query),
}))

expect(controller.shouldAutofocusCompose).toBe(false)
})

it('allows autofocus on desktop devices', () => {
expect(controller.shouldAutofocusCompose).toBe(true)
})
})

describe('onScroll', () => {
let mockMessagesAPI
let mockMessageTemplate
Expand Down
2 changes: 1 addition & 1 deletion dist/hellotext.js

Large diffs are not rendered by default.

55 changes: 46 additions & 9 deletions lib/controllers/webchat_controller.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const MESSAGE_TIMESTAMP_FORMAT_OPTIONS = {
hour: 'numeric',
minute: '2-digit'
};
const MOBILE_USER_AGENT_PATTERN = /Android|iPhone|iPad|iPod/i;
const SCROLL_ISOLATION_EVENT_OPTIONS = {
capture: true,
passive: true
Expand Down Expand Up @@ -342,7 +343,7 @@ let _default = /*#__PURE__*/function (_Controller) {
this.popoverTarget.classList.remove(...this.fadeOutClasses);
(_this$dismissTeaserFo = this.dismissTeaserForSession) === null || _this$dismissTeaserFo === void 0 ? void 0 : _this$dismissTeaserFo.call(this);
if (!this.onMobile) {
this.inputTarget.focus();
this.focusComposeInput();
}
if (!this.scrolled) {
requestAnimationFrame(() => {
Expand Down Expand Up @@ -773,7 +774,7 @@ let _default = /*#__PURE__*/function (_Controller) {
this.attachmentContainerTarget.innerHTML = '';
this.attachmentContainerTarget.style.display = 'none';
this.errorMessageContainerTarget.style.display = 'none';
this.inputTarget.focus();
this.focusComposeInput();

// Set up optimistic typing indicator BEFORE making the API call
// This prevents race conditions with server responses
Expand Down Expand Up @@ -826,12 +827,10 @@ let _default = /*#__PURE__*/function (_Controller) {
} = event;
const ignoredSelector = ['button', 'a', 'input', 'textarea', 'select', 'label', '[role="button"]', 'em-emoji-picker', '[data-hellotext--webchat--emoji-target~="popover"]', '[data-controller~="hellotext--webchat--emoji"]'].join(', ');
if (!this.hasInputTarget || target.closest(ignoredSelector)) return;
if (!this.focusComposeInput({
moveCursorToEnd: true
})) return;
event.preventDefault();
this.inputTarget.focus();
if (typeof this.inputTarget.selectionStart === 'number') {
const position = this.inputTarget.value.length;
this.inputTarget.setSelectionRange(position, position);
}
}
}, {
key: "closePopoverFromHeader",
Expand Down Expand Up @@ -980,7 +979,7 @@ let _default = /*#__PURE__*/function (_Controller) {
this.files = [...this.files, ...newFiles];
this.errorMessageContainerTarget.innerText = '';
newFiles.forEach(file => this.createAttachmentElement(file));
this.inputTarget.focus();
this.focusComposeInput();
}
}, {
key: "createAttachmentElement",
Expand Down Expand Up @@ -1015,7 +1014,7 @@ let _default = /*#__PURE__*/function (_Controller) {
this.files = this.files.filter(file => file.name !== attachment.dataset.name);
this.attachmentInputTarget.value = '';
attachment.remove();
this.inputTarget.focus();
this.focusComposeInput();
}
}, {
key: "attachmentTargetDisconnected",
Expand Down Expand Up @@ -1044,7 +1043,22 @@ let _default = /*#__PURE__*/function (_Controller) {
const end = this.inputTarget.selectionEnd;
this.inputTarget.value = value.slice(0, start) + emoji + value.slice(end);
this.inputTarget.selectionStart = this.inputTarget.selectionEnd = start + emoji.length;
this.focusComposeInput();
}
}, {
key: "focusComposeInput",
value: function focusComposeInput({
moveCursorToEnd = false
} = {}) {
if (!this.shouldAutofocusCompose) return false;
if (this.hasInputTarget === false) return false;
if (this.hasInputTarget === undefined && !this.inputTarget) return false;
this.inputTarget.focus();
if (moveCursorToEnd && typeof this.inputTarget.selectionStart === 'number') {
const position = this.inputTarget.value.length;
this.inputTarget.setSelectionRange(position, position);
}
return true;
}
}, {
key: "byteToMegabyte",
Expand All @@ -1063,9 +1077,32 @@ let _default = /*#__PURE__*/function (_Controller) {
get: function () {
return localStorage.getItem(`hellotext--webchat--${this.idValue}`) === 'opened' && !this.onMobile;
}
}, {
key: "shouldAutofocusCompose",
get: function () {
return !this.usesVirtualKeyboard;
}
}, {
key: "usesVirtualKeyboard",
get: function () {
var _navigator$userAgentD;
if (typeof navigator === 'undefined') return false;
const userAgent = navigator.userAgent || '';
const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
const isMobileUserAgent = MOBILE_USER_AGENT_PATTERN.test(userAgent);
const isUserAgentDataMobile = ((_navigator$userAgentD = navigator.userAgentData) === null || _navigator$userAgentD === void 0 ? void 0 : _navigator$userAgentD.mobile) === true;
return isMobileUserAgent || isIPadOS || isUserAgentDataMobile || this.hasTouchOnlyPointer;
}
}, {
key: "hasTouchOnlyPointer",
get: function () {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(hover: none)').matches;
}
}, {
key: "onMobile",
get: function () {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(`(max-width: ${this.fullScreenThresholdValue}px)`).matches;
}
}], [{
Expand Down
56 changes: 47 additions & 9 deletions lib/controllers/webchat_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ var MESSAGE_TIMESTAMP_FORMAT_OPTIONS = {
hour: 'numeric',
minute: '2-digit'
};
var MOBILE_USER_AGENT_PATTERN = /Android|iPhone|iPad|iPod/i;
var SCROLL_ISOLATION_EVENT_OPTIONS = {
capture: true,
passive: true
Expand Down Expand Up @@ -346,7 +347,7 @@ var _default = /*#__PURE__*/function (_Controller) {
this.popoverTarget.classList.remove(...this.fadeOutClasses);
(_this$dismissTeaserFo = this.dismissTeaserForSession) === null || _this$dismissTeaserFo === void 0 ? void 0 : _this$dismissTeaserFo.call(this);
if (!this.onMobile) {
this.inputTarget.focus();
this.focusComposeInput();
}
if (!this.scrolled) {
requestAnimationFrame(() => {
Expand Down Expand Up @@ -797,7 +798,7 @@ var _default = /*#__PURE__*/function (_Controller) {
this.attachmentContainerTarget.innerHTML = '';
this.attachmentContainerTarget.style.display = 'none';
this.errorMessageContainerTarget.style.display = 'none';
this.inputTarget.focus();
this.focusComposeInput();

// Set up optimistic typing indicator BEFORE making the API call
// This prevents race conditions with server responses
Expand Down Expand Up @@ -855,12 +856,10 @@ var _default = /*#__PURE__*/function (_Controller) {
} = event;
var ignoredSelector = ['button', 'a', 'input', 'textarea', 'select', 'label', '[role="button"]', 'em-emoji-picker', '[data-hellotext--webchat--emoji-target~="popover"]', '[data-controller~="hellotext--webchat--emoji"]'].join(', ');
if (!this.hasInputTarget || target.closest(ignoredSelector)) return;
if (!this.focusComposeInput({
moveCursorToEnd: true
})) return;
event.preventDefault();
this.inputTarget.focus();
if (typeof this.inputTarget.selectionStart === 'number') {
var position = this.inputTarget.value.length;
this.inputTarget.setSelectionRange(position, position);
}
}
}, {
key: "closePopoverFromHeader",
Expand Down Expand Up @@ -1023,7 +1022,7 @@ var _default = /*#__PURE__*/function (_Controller) {
this.files = [...this.files, ...newFiles];
this.errorMessageContainerTarget.innerText = '';
newFiles.forEach(file => this.createAttachmentElement(file));
this.inputTarget.focus();
this.focusComposeInput();
}
}, {
key: "createAttachmentElement",
Expand Down Expand Up @@ -1059,7 +1058,7 @@ var _default = /*#__PURE__*/function (_Controller) {
this.files = this.files.filter(file => file.name !== attachment.dataset.name);
this.attachmentInputTarget.value = '';
attachment.remove();
this.inputTarget.focus();
this.focusComposeInput();
}
}, {
key: "attachmentTargetDisconnected",
Expand Down Expand Up @@ -1089,7 +1088,23 @@ var _default = /*#__PURE__*/function (_Controller) {
var end = this.inputTarget.selectionEnd;
this.inputTarget.value = value.slice(0, start) + emoji + value.slice(end);
this.inputTarget.selectionStart = this.inputTarget.selectionEnd = start + emoji.length;
this.focusComposeInput();
}
}, {
key: "focusComposeInput",
value: function focusComposeInput() {
var {
moveCursorToEnd = false
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
if (!this.shouldAutofocusCompose) return false;
if (this.hasInputTarget === false) return false;
if (this.hasInputTarget === undefined && !this.inputTarget) return false;
this.inputTarget.focus();
if (moveCursorToEnd && typeof this.inputTarget.selectionStart === 'number') {
var position = this.inputTarget.value.length;
this.inputTarget.setSelectionRange(position, position);
}
return true;
}
}, {
key: "byteToMegabyte",
Expand All @@ -1108,9 +1123,32 @@ var _default = /*#__PURE__*/function (_Controller) {
get: function get() {
return localStorage.getItem("hellotext--webchat--".concat(this.idValue)) === 'opened' && !this.onMobile;
}
}, {
key: "shouldAutofocusCompose",
get: function get() {
return !this.usesVirtualKeyboard;
}
}, {
key: "usesVirtualKeyboard",
get: function get() {
var _navigator$userAgentD;
if (typeof navigator === 'undefined') return false;
var userAgent = navigator.userAgent || '';
var isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
var isMobileUserAgent = MOBILE_USER_AGENT_PATTERN.test(userAgent);
var isUserAgentDataMobile = ((_navigator$userAgentD = navigator.userAgentData) === null || _navigator$userAgentD === void 0 ? void 0 : _navigator$userAgentD.mobile) === true;
return isMobileUserAgent || isIPadOS || isUserAgentDataMobile || this.hasTouchOnlyPointer;
}
}, {
key: "hasTouchOnlyPointer",
get: function get() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(hover: none)').matches;
}
}, {
key: "onMobile",
get: function get() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia("(max-width: ".concat(this.fullScreenThresholdValue, "px)")).matches;
}
}], [{
Expand Down
Loading