Skip to content

Commit c5d5b80

Browse files
authored
Make license field easier to use in browsers (#69)
Chrome, Firefox, and Safari have small differences when it comes to behaviour of datalist. This PR makes the behaviour more predictable and more convenient for a user to select licenses: - Can click on list and immediately confirm the selection of the license (without having to hit Enter again, as it normally be on Firefox) - Can type license ID, in any casing, and hit Tab/Enter one time to confirm the selection of the license Tested with Chrome, Firefox, and Safari
1 parent faa712f commit c5d5b80

File tree

3 files changed

+207
-14
lines changed

3 files changed

+207
-14
lines changed

cypress/integration/interface.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Copyright (C) 2025 Arthit Suriyawongkul
3+
* See the AUTHORS file at the top-level directory of this distribution
4+
* License: GNU Affero General Public License version 3, or any later version
5+
* See top-level LICENSE file for more information
6+
*/
7+
8+
/*
9+
* Tests the user interface.
10+
*/
11+
12+
"use strict";
13+
14+
describe('License input field', function () {
15+
beforeEach(function () {
16+
cy.window().then((win) => {
17+
win.sessionStorage.clear();
18+
win.document.getElementById('selected-licenses').innerHTML = '';
19+
})
20+
cy.visit('./index.html');
21+
// Wait for the license list to be loaded asynchronously before running
22+
// license field-related tests. This prevents races where tests trigger
23+
// input/change events before SPDX_LICENSE_IDS is available.
24+
cy.window().its('SPDX_LICENSE_IDS.length').should('be.gt', 0);
25+
cy.get('#license').should('exist');
26+
cy.get('#selected-licenses').should('exist');
27+
cy.get('#selected-licenses').children().should('have.length', 0);
28+
});
29+
30+
it('can add a license by typing', function () {
31+
cy.get('#license').type('MIT{enter}');
32+
cy.get('#selected-licenses .license-id').should('contain', 'MIT');
33+
cy.get('#license').should('have.value', '');
34+
});
35+
36+
it('can add a license by typing non-strictly with spaces', function () {
37+
// " MIT " -> "MIT"
38+
cy.get('#license').type(' MIT {enter}');
39+
cy.get('#selected-licenses .license-id').should('contain', 'MIT');
40+
cy.get('#license').should('have.value', '');
41+
});
42+
43+
it('can add a license by typing non-strictly to original casing', function () {
44+
// "mit" -> "MIT"
45+
cy.get('#license').type('mit{enter}');
46+
cy.get('#selected-licenses .license-id').should('contain', 'MIT');
47+
cy.get('#license').should('have.value', '');
48+
});
49+
50+
it('can add a license by clicking on the pop-up list', function () {
51+
// inputType "insertReplacementText" typically appears when the browser
52+
// inserts/replaces the input value as a single operation
53+
// (for example when the user picks an item from a datalist with the
54+
// mouse or when autocompletion replaces the current text)
55+
cy.get('#license')
56+
.invoke('val', 'MIT')
57+
.trigger('input', { inputType: 'insertReplacementText' });
58+
cy.get('#selected-licenses .license-id', { timeout: 5000 })
59+
.should('contain', 'MIT');
60+
cy.get('#license').should('have.value', '');
61+
});
62+
63+
it('can add a license on "change" event (e.g., selecting via keyboard)', function () {
64+
cy.get('#license').invoke('val', 'MIT').trigger('change');
65+
cy.get('#selected-licenses .license-id', { timeout: 5000 })
66+
.should('contain', 'MIT');
67+
cy.get('#license').should('have.value', '');
68+
});
69+
70+
it('should not insert a duplicated license', function () {
71+
cy.get('#license').type('MIT{enter}');
72+
cy.get('#selected-licenses .license-id').should('have.length', 1);
73+
cy.get('#license').type('MIT{enter}');
74+
cy.get('#selected-licenses .license-id').should('have.length', 1);
75+
cy.get('#license').should('have.value', '');
76+
});
77+
78+
it('should not insert an invalid license', function () {
79+
cy.get('#license').type('INVALID_LICENSE00{enter}');
80+
cy.get('#selected-licenses .license-id').should('not.exist');
81+
cy.get('#license:invalid').should('exist');
82+
});
83+
84+
it('should not insert a shorter match while typing a longer id with same prefix', function () {
85+
// "MIT-0" vs "MIT"
86+
cy.get('#license').type('MIT-0{enter}');
87+
cy.get('#selected-licenses .license-id').should('contain', 'MIT-0');
88+
cy.get('#license').should('have.value', '');
89+
});
90+
91+
it('should not insert until user confirms with Enter', function () {
92+
// Type "MIT" but do not confirm with Enter yet
93+
cy.get('#license').type('MIT');
94+
cy.get('#selected-licenses .license-id').should('not.exist');
95+
// Press ArrowRight - which is not a confirmation
96+
cy.get('#license').trigger('keydown', { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, which: 39 });
97+
cy.get('#selected-licenses .license-id').should('not.exist');
98+
});
99+
100+
it('should not insert when edits produce a matching ID', function () {
101+
// Note the space after "MIT"
102+
cy.get('#license').type('MIT -0');
103+
cy.get('#selected-licenses .license-id').should('not.exist');
104+
// Remove the space, to produce "MIT-0"
105+
cy.get('#license').type('{leftarrow}{leftarrow}{backspace}');
106+
cy.get('#selected-licenses .license-id').should('not.exist');
107+
// Confirm with Enter
108+
cy.get('#license').type('{enter}');
109+
cy.get('#selected-licenses .license-id').should('contain', 'MIT-0');
110+
cy.get('#license').should('have.value', '');
111+
});
112+
113+
it('should insert when user confirms with Enter', function () {
114+
// Type "MIT" but do not confirm with Enter yet
115+
cy.get('#license').type('MIT');
116+
cy.get('#selected-licenses .license-id').should('not.exist');
117+
// Simulate Enter keydown to confirm
118+
cy.get('#license').trigger('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 });
119+
cy.get('#selected-licenses .license-id').should('contain', 'MIT');
120+
cy.get('#license').should('have.value', '');
121+
});
122+
123+
it('should insert when user press Tab', function () {
124+
// Type "MIT" but do not press Tab yet
125+
cy.get('#license').type('MIT');
126+
cy.get('#selected-licenses .license-id').should('not.exist');
127+
// Simulate Tab keydown to confirm
128+
cy.get('#license').trigger('keydown', { key: 'Tab', code: 'Tab', keyCode: 9, which: 9 });
129+
cy.get('#selected-licenses .license-id').should('contain', 'MIT');
130+
cy.get('#license').should('have.value', '');
131+
});
132+
});

js/dynamic_form.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright (C) 2019 The Software Heritage developers
2+
* Copyright (C) 2019-2025 The Software Heritage developers
33
* See the AUTHORS file at the top-level directory of this distribution
44
* License: GNU Affero General Public License version 3, or any later version
55
* See top-level LICENSE file for more information
@@ -199,8 +199,21 @@ function fieldToLower(event) {
199199
}
200200

201201
function initCallbacks() {
202+
// To make sure the selection of a license from the datalist
203+
// works more predictably across browsers, we listen to
204+
// 'input', 'change', and 'keydown' events for the license field.
205+
// This should work with Firefox, Safari, and Chrome-based browsers.
206+
207+
// In Firefox datalist selection without Enter press does not trigger
208+
// 'change' event, so we need to listen to 'input' event to catch
209+
// a selection with mouse click.
210+
document.querySelector('#license')
211+
.addEventListener('input', validateLicense);
202212
document.querySelector('#license')
203213
.addEventListener('change', validateLicense);
214+
// Safari needs 'keydown' to catch Enter press when datalist is shown
215+
document.querySelector('#license')
216+
.addEventListener('keydown', validateLicense);
204217

205218
document.querySelector('#generateCodemetaV2').disabled = false;
206219
document.querySelector('#generateCodemetaV2')

js/fields_data.js

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright (C) 2019 The Software Heritage developers
2+
* Copyright (C) 2019-2025 The Software Heritage developers
33
* See the AUTHORS file at the top-level directory of this distribution
44
* License: GNU Affero General Public License version 3, or any later version
55
* See top-level LICENSE file for more information
@@ -42,25 +42,73 @@ function insertLicenseElement(licenseId) {
4242
}
4343

4444
function validateLicense(e) {
45-
// continue only if Enter/Tab key is pressed
46-
if (e.keyCode && e.keyCode !== 13 && e.keyCode !== 9) {
45+
var licenseField = document.getElementById('license');
46+
licenseField.setCustomValidity(''); // Reset previous message
47+
48+
var license = licenseField.value.trim();
49+
if (!license || !SPDX_LICENSE_IDS) {
4750
return;
4851
}
49-
// Note: For some reason e.keyCode is undefined when Enter/Tab key is pressed.
50-
// Maybe it's because of the datalist. But the above condition should
51-
// work in either case.
5252

53-
var licenseField = document.getElementById('license');
54-
var license = licenseField.value;
55-
if (SPDX_LICENSE_IDS !== null && SPDX_LICENSE_IDS.indexOf(license) == -1) {
53+
// Only perform validation/insertion when the user explicitly confirms
54+
// their choice (change event, or keydown Enter/Tab).
55+
// Some browsers/selection actions can trigger an 'input' event that is
56+
// not a simple text insertion (e.g. a datalist selection via mouse can
57+
// trigger input of type 'insertReplacementText');
58+
// we treat those as confirmation too.
59+
if (e.type === "change") {
60+
// proceed
61+
} else if (e.type === "keydown" && (e.key === "Enter" || e.key === "Tab")) {
62+
// proceed
63+
} else if (e.type === "input") {
64+
const CONFIRM_INPUT_TYPES = new Set([
65+
'insertReplacementText', // from datalist selection
66+
]);
67+
if (!(e.inputType && CONFIRM_INPUT_TYPES.has(e.inputType))) {
68+
// Typing characters, pasting, deletions - don't proceed
69+
return;
70+
}
71+
} else {
72+
// Other events (compositionupdate, etc.) — don't proceed
73+
return;
74+
}
75+
76+
// Correct casing to the canonical SPDX license ID when possible.
77+
// This will allow user to type in any casing and hit Enter once
78+
// to insert the license immediately.
79+
const match = SPDX_LICENSE_IDS.find(id =>
80+
id.toLowerCase() === license.toLowerCase());
81+
if (match) {
82+
license = match;
83+
licenseField.value = match;
84+
}
85+
86+
if (SPDX_LICENSE_IDS.indexOf(license) == -1) {
5687
licenseField.setCustomValidity('Unknown license id');
5788
}
5889
else {
59-
insertLicenseElement(license);
60-
6190
licenseField.value = "";
62-
licenseField.setCustomValidity('');
63-
generateCodemeta();
91+
const selectedLicenses = document.getElementById("selected-licenses");
92+
const duplicated = Array.from(selectedLicenses.getElementsByClassName("license-id"))
93+
.some(el => el.textContent === license);
94+
if (!duplicated) {
95+
insertLicenseElement(license);
96+
generateCodemeta();
97+
}
98+
99+
// In Chromium-based browsers the datalist popup may remain visible
100+
// after inserting a license by typing + Enter.
101+
// To hide it we detach and re-attach the datalist.
102+
// We avoid doing this in non-Chromium browsers (e.g. Safari) where
103+
// reattaching can cause the popup to reappear.
104+
var ua = (navigator.userAgent || '');
105+
var isChromium = /(Chrome|Chromium|CriOS|Edg|OPR|Brave|Vivaldi|SamsungBrowser)/.test(ua);
106+
if (isChromium) {
107+
licenseField.removeAttribute('list');
108+
setTimeout(() => {
109+
licenseField.setAttribute('list', 'licenses');
110+
}, 0);
111+
}
64112
}
65113
}
66114

0 commit comments

Comments
 (0)