Merge branch 'master' into fix/checked-index-and-value

This commit is contained in:
Danny Moerkerke 2022-05-16 12:02:20 +02:00
commit 0ee55369bc
54 changed files with 1080 additions and 15319 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': minor
---
remove name property check on lion field for forward compatibility with Form participation api

View file

@ -0,0 +1,5 @@
---
'@lion/input-tel': patch
---
Make use of awsome-phonenumber, remove local copy

View file

@ -1,5 +0,0 @@
---
'@lion/input-tel-dropdown': patch
---
sync disable state to dropdown for a11y

View file

@ -1,5 +0,0 @@
---
'@lion/switch': patch
---
fix(switch) unregister on disconnectedCallback

View file

@ -1,5 +0,0 @@
---
'@lion/form-core': patch
---
fix: reset the form validators after a form `reset` click or field emptied

View file

@ -9,6 +9,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Sanity check
run: node ./scripts/yarn-lock-scan.js
- name: Setup Node 14.x - name: Setup Node 14.x
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:

View file

@ -219,3 +219,37 @@ export const indeterminateChildren = () => html`
</lion-checkbox-group> </lion-checkbox-group>
`; `;
``` ```
You can also use `mixed-state` attribute so your indeterminate checkbox toggles through three states (indeterminate, checked, unchecked), where for indeterminate state the old children states are restored when you toggle back into this.
```js preview-story
export const mixedState = () => html`
<lion-checkbox-group name="scientists[]" label="Favorite scientists">
<lion-checkbox-indeterminate mixed-state label="Scientists">
<lion-checkbox
slot="checkbox"
label="Isaac Newton"
.choiceValue=${'Isaac Newton'}
></lion-checkbox>
<lion-checkbox
slot="checkbox"
label="Galileo Galilei"
.choiceValue=${'Galileo Galilei'}
></lion-checkbox>
<lion-checkbox-indeterminate mixed-state slot="checkbox" label="Old Greek scientists">
<lion-checkbox
slot="checkbox"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox slot="checkbox" label="Plato" .choiceValue=${'Plato'}></lion-checkbox>
<lion-checkbox
slot="checkbox"
label="Pythagoras"
.choiceValue=${'Pythagoras'}
></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`;
```

View file

@ -138,7 +138,7 @@ export const disabledRotateNavigation = () => html`
<lion-option .choiceValue=${'Beets'}>Beets</lion-option> <lion-option .choiceValue=${'Beets'}>Beets</lion-option>
<lion-option .choiceValue=${'Bell pepper'}>Bell pepper</lion-option> <lion-option .choiceValue=${'Bell pepper'}>Bell pepper</lion-option>
<lion-option .choiceValue=${'Broccoli'}>Broccoli</lion-option> <lion-option .choiceValue=${'Broccoli'}>Broccoli</lion-option>
<lion-option .choiceValue=${'Brussel sprout'} disabled>Brussels sprout</lion-option> <lion-option .choiceValue=${'Brussels sprout'} disabled>Brussels sprout</lion-option>
<lion-option .choiceValue=${'Cabbage'}>Cabbage</lion-option> <lion-option .choiceValue=${'Cabbage'}>Cabbage</lion-option>
<lion-option .choiceValue=${'Carrot'}>Carrot</lion-option> <lion-option .choiceValue=${'Carrot'}>Carrot</lion-option>
</lion-listbox> </lion-listbox>

View file

@ -1,5 +1,13 @@
# Change Log # Change Log
## 0.15.0
### Minor Changes
- 558edcb6: Adds a `maxResponseSize` cache option to specify a max size for responses to be cached. The option prevents caching and cache retrieval for responses that are larger than the given maximum as reported in the `Content-Length` header. If this header is missing nothing happens, that is to say caching is not prevented.
- efcdf653: Adds a `contentTypes` cache option to specify a whitelist of content types to be cached. The option prevents caching and cache retrieval for responses that do not have one of these values in the `Content-Type` header.
- 56af96f1: Add an option "addCaching" to the Ajax config, in order to add cache interceptors when useCache is turned off. In this situation, all requests are cached proactively.
## 0.14.0 ## 0.14.0
### Minor Changes ### Minor Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/ajax", "name": "@lion/ajax",
"version": "0.14.0", "version": "0.15.0",
"description": "Thin wrapper around fetch with support for interceptors.", "description": "Thin wrapper around fetch with support for interceptors.",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",

View file

@ -23,11 +23,12 @@ export class Ajax {
*/ */
constructor(config = {}) { constructor(config = {}) {
/** /**
* @type {Partial<AjaxConfig>} * @type {AjaxConfig}
* @private * @private
*/ */
this.__config = { this.__config = {
addAcceptLanguage: true, addAcceptLanguage: true,
addCaching: false,
xsrfCookieName: 'XSRF-TOKEN', xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN',
jsonPrefix: '', jsonPrefix: '',
@ -53,7 +54,7 @@ export class Ajax {
} }
const { cacheOptions } = this.__config; const { cacheOptions } = this.__config;
if (cacheOptions?.useCache) { if (cacheOptions.useCache || this.__config.addCaching) {
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors( const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
cacheOptions.getCacheIdentifier, cacheOptions.getCacheIdentifier,
cacheOptions, cacheOptions,
@ -65,7 +66,7 @@ export class Ajax {
/** /**
* Configures the Ajax instance * Configures the Ajax instance
* @param {Partial<AjaxConfig>} config configuration for the Ajax instance * @param {AjaxConfig} config configuration for the Ajax instance
*/ */
set options(config) { set options(config) {
this.__config = config; this.__config = config;

View file

@ -82,6 +82,8 @@ export const extendCacheOptions = ({
requestIdFunction = DEFAULT_GET_REQUEST_ID, requestIdFunction = DEFAULT_GET_REQUEST_ID,
invalidateUrls, invalidateUrls,
invalidateUrlsRegex, invalidateUrlsRegex,
contentTypes,
maxResponseSize,
}) => ({ }) => ({
useCache, useCache,
methods, methods,
@ -89,6 +91,8 @@ export const extendCacheOptions = ({
requestIdFunction, requestIdFunction,
invalidateUrls, invalidateUrls,
invalidateUrlsRegex, invalidateUrlsRegex,
contentTypes,
maxResponseSize,
}); });
/** /**
@ -101,6 +105,8 @@ export const validateCacheOptions = ({
requestIdFunction, requestIdFunction,
invalidateUrls, invalidateUrls,
invalidateUrlsRegex, invalidateUrlsRegex,
contentTypes,
maxResponseSize,
} = {}) => { } = {}) => {
if (useCache !== undefined && typeof useCache !== 'boolean') { if (useCache !== undefined && typeof useCache !== 'boolean') {
throw new Error('Property `useCache` must be a `boolean`'); throw new Error('Property `useCache` must be a `boolean`');
@ -112,14 +118,20 @@ export const validateCacheOptions = ({
throw new Error('Property `maxAge` must be a finite `number`'); throw new Error('Property `maxAge` must be a finite `number`');
} }
if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) { if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) {
throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`'); throw new Error('Property `invalidateUrls` must be an `Array` or `undefined`');
} }
if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) { if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) {
throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`');
} }
if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') { if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') {
throw new Error('Property `requestIdFunction` must be a `function`'); throw new Error('Property `requestIdFunction` must be a `function`');
} }
if (contentTypes !== undefined && !Array.isArray(contentTypes)) {
throw new Error('Property `contentTypes` must be an `Array` or `undefined`');
}
if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) {
throw new Error('Property `maxResponseSize` must be a finite `number`');
}
}; };
/** /**

View file

@ -10,6 +10,42 @@ import {
isCurrentSessionId, isCurrentSessionId,
} from '../cacheManager.js'; } from '../cacheManager.js';
/**
* Tests whether the request method is supported according to the `cacheOptions`
* @param {ValidatedCacheOptions} cacheOptions
* @param {string} method
* @returns {boolean}
*/
const isMethodSupported = (cacheOptions, method) =>
cacheOptions.methods.includes(method.toLowerCase());
/**
* Tests whether the response content type is supported by the `contentTypes` whitelist
* @param {Response} response
* @param {CacheOptions} cacheOptions
* @returns {boolean} `true` if the contentTypes property is not an array, or if the value of the Content-Type header is in the array
*/
const isResponseContentTypeSupported = (response, { contentTypes } = {}) => {
if (!Array.isArray(contentTypes)) return true;
return contentTypes.includes(String(response.headers.get('Content-Type')));
};
/**
* Tests whether the response size is not too large to be cached according to the `maxResponseSize` property
* @param {Response} response
* @param {CacheOptions} cacheOptions
* @returns {boolean} `true` if the `maxResponseSize` property is not larger than zero, or if the Content-Length header is not present, or if the value of the header is not larger than the `maxResponseSize` property
*/
const isResponseSizeSupported = (response, { maxResponseSize } = {}) => {
const responseSize = +(response.headers.get('Content-Length') || 0);
if (!maxResponseSize) return true;
if (!responseSize) return true;
return responseSize <= maxResponseSize;
};
/** /**
* Request interceptor to return relevant cached requests * Request interceptor to return relevant cached requests
* @param {function(): string} getCacheId used to invalidate cache if identifier is changed * @param {function(): string} getCacheId used to invalidate cache if identifier is changed
@ -36,9 +72,8 @@ const createCacheRequestInterceptor =
} }
const requestId = cacheOptions.requestIdFunction(request); const requestId = cacheOptions.requestIdFunction(request);
const isMethodSupported = cacheOptions.methods.includes(request.method.toLowerCase());
if (!isMethodSupported) { if (!isMethodSupported(cacheOptions, request.method)) {
invalidateMatchingCache(requestId, cacheOptions); invalidateMatchingCache(requestId, cacheOptions);
return request; return request;
} }
@ -50,7 +85,11 @@ const createCacheRequestInterceptor =
} }
const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge); const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
if (cachedResponse) { if (
cachedResponse &&
isResponseContentTypeSupported(cachedResponse, cacheOptions) &&
isResponseSizeSupported(cachedResponse, cacheOptions)
) {
// Return the response from cache // Return the response from cache
request.cacheOptions = request.cacheOptions ?? { useCache: false }; request.cacheOptions = request.cacheOptions ?? { useCache: false };
/** @type {CacheResponse} */ /** @type {CacheResponse} */
@ -81,13 +120,14 @@ const createCacheResponseInterceptor =
...response.request.cacheOptions, ...response.request.cacheOptions,
}); });
if (!response.fromCache && isMethodSupported(cacheOptions, response.request.method)) {
const requestId = cacheOptions.requestIdFunction(response.request); const requestId = cacheOptions.requestIdFunction(response.request);
const isAlreadyFromCache = !!response.fromCache;
const isCacheActive = cacheOptions.useCache;
const isMethodSupported = cacheOptions.methods.includes(response.request?.method.toLowerCase());
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) { if (
if (isCurrentSessionId(response.request.cacheSessionId)) { isCurrentSessionId(response.request.cacheSessionId) &&
isResponseContentTypeSupported(response, cacheOptions) &&
isResponseSizeSupported(response, cacheOptions)
) {
// Cache the response // Cache the response
ajaxCache.set(requestId, response.clone()); ajaxCache.set(requestId, response.clone());
} }
@ -95,6 +135,7 @@ const createCacheResponseInterceptor =
// Mark the pending request as resolved // Mark the pending request as resolved
pendingRequestStore.resolve(requestId); pendingRequestStore.resolve(requestId);
} }
return response; return response;
}; };

View file

@ -43,6 +43,7 @@ describe('Ajax', () => {
}; };
const expected = { const expected = {
addAcceptLanguage: true, addAcceptLanguage: true,
addCaching: false,
xsrfCookieName: 'XSRF-TOKEN', xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN', xsrfHeaderName: 'X-XSRF-TOKEN',
jsonPrefix: ")]}',", jsonPrefix: ")]}',",
@ -303,6 +304,44 @@ describe('Ajax', () => {
getCacheIdentifier = () => String(cacheId); getCacheIdentifier = () => String(cacheId);
}); });
it('does not add cache interceptors when useCache is turned off', () => {
const customAjax = new Ajax({
cacheOptions: {
maxAge: 100,
getCacheIdentifier,
},
});
expect(customAjax._requestInterceptors.length).to.equal(2);
expect(customAjax._responseInterceptors.length).to.equal(0);
});
it('adds cache interceptors when useCache is turned on', () => {
const customAjax = new Ajax({
cacheOptions: {
useCache: true,
maxAge: 100,
getCacheIdentifier,
},
});
expect(customAjax._requestInterceptors.length).to.equal(3);
expect(customAjax._responseInterceptors.length).to.equal(1);
});
it('adds cache interceptors when addCaching is turned on', () => {
const customAjax = new Ajax({
addCaching: true,
cacheOptions: {
maxAge: 100,
getCacheIdentifier,
},
});
expect(customAjax._requestInterceptors.length).to.equal(3);
expect(customAjax._responseInterceptors.length).to.equal(1);
});
describe('caching interceptors', async () => { describe('caching interceptors', async () => {
/** /**
* @type {Ajax} * @type {Ajax}

View file

@ -74,6 +74,8 @@ describe('cacheManager', () => {
requestIdFunction, requestIdFunction,
invalidateUrls: invalidateUrlsResult, invalidateUrls: invalidateUrlsResult,
invalidateUrlsRegex: invalidateUrlsRegexResult, invalidateUrlsRegex: invalidateUrlsRegexResult,
contentTypes,
maxResponseSize,
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex }); } = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
// Assert // Assert
expect(useCache).to.be.false; expect(useCache).to.be.false;
@ -82,6 +84,8 @@ describe('cacheManager', () => {
expect(typeof requestIdFunction).to.eql('function'); expect(typeof requestIdFunction).to.eql('function');
expect(invalidateUrlsResult).to.equal(invalidateUrls); expect(invalidateUrlsResult).to.equal(invalidateUrls);
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex); expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
expect(contentTypes).to.be.undefined;
expect(maxResponseSize).to.be.undefined;
}); });
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => { it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
@ -129,18 +133,22 @@ describe('cacheManager', () => {
it('does not accept null as argument', () => { it('does not accept null as argument', () => {
expect(() => validateCacheOptions(null)).to.throw(TypeError); expect(() => validateCacheOptions(null)).to.throw(TypeError);
}); });
it('accepts an empty object', () => { it('accepts an empty object', () => {
expect(() => validateCacheOptions({})).not.to.throw( expect(() => validateCacheOptions({})).not.to.throw(
'Property `useCache` must be a `boolean`', 'Property `useCache` must be a `boolean`',
); );
}); });
describe('the useCache property', () => { describe('the useCache property', () => {
it('accepts a boolean', () => { it('accepts a boolean', () => {
expect(() => validateCacheOptions({ useCache: false })).not.to.throw; expect(() => validateCacheOptions({ useCache: false })).not.to.throw;
}); });
it('accepts undefined', () => { it('accepts undefined', () => {
expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw; expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw;
}); });
it('does not accept anything else', () => { it('does not accept anything else', () => {
// @ts-ignore // @ts-ignore
expect(() => validateCacheOptions({ useCache: '' })).to.throw( expect(() => validateCacheOptions({ useCache: '' })).to.throw(
@ -148,13 +156,16 @@ describe('cacheManager', () => {
); );
}); });
}); });
describe('the methods property', () => { describe('the methods property', () => {
it('accepts an array with the value `get`', () => { it('accepts an array with the value `get`', () => {
expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw; expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw;
}); });
it('accepts undefined', () => { it('accepts undefined', () => {
expect(() => validateCacheOptions({ methods: undefined })).not.to.throw; expect(() => validateCacheOptions({ methods: undefined })).not.to.throw;
}); });
it('does not accept anything else', () => { it('does not accept anything else', () => {
expect(() => validateCacheOptions({ methods: [] })).to.throw( expect(() => validateCacheOptions({ methods: [] })).to.throw(
'Cache can only be utilized with `GET` method', 'Cache can only be utilized with `GET` method',
@ -167,13 +178,16 @@ describe('cacheManager', () => {
); );
}); });
}); });
describe('the maxAge property', () => { describe('the maxAge property', () => {
it('accepts a finite number', () => { it('accepts a finite number', () => {
expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw; expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw;
}); });
it('accepts undefined', () => { it('accepts undefined', () => {
expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw; expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw;
}); });
it('does not accept anything else', () => { it('does not accept anything else', () => {
// @ts-ignore // @ts-ignore
expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw( expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw(
@ -184,6 +198,7 @@ describe('cacheManager', () => {
); );
}); });
}); });
describe('the invalidateUrls property', () => { describe('the invalidateUrls property', () => {
it('accepts an array', () => { it('accepts an array', () => {
// @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions // @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions
@ -191,31 +206,37 @@ describe('cacheManager', () => {
validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }), validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }),
).not.to.throw; ).not.to.throw;
}); });
it('accepts undefined', () => { it('accepts undefined', () => {
expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw; expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw;
}); });
it('does not accept anything else', () => { it('does not accept anything else', () => {
// @ts-ignore // @ts-ignore
expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw( expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw(
'Property `invalidateUrls` must be an `Array` or `falsy`', 'Property `invalidateUrls` must be an `Array` or `undefined`',
); );
}); });
}); });
describe('the invalidateUrlsRegex property', () => { describe('the invalidateUrlsRegex property', () => {
it('accepts a regular expression', () => { it('accepts a regular expression', () => {
expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ })) expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ }))
.not.to.throw; .not.to.throw;
}); });
it('accepts undefined', () => { it('accepts undefined', () => {
expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw; expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw;
}); });
it('does not accept anything else', () => { it('does not accept anything else', () => {
// @ts-ignore // @ts-ignore
expect(() => expect(() =>
validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }), validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }),
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); ).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`');
}); });
}); });
describe('the requestIdFunction property', () => { describe('the requestIdFunction property', () => {
it('accepts a function', () => { it('accepts a function', () => {
// @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions // @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions
@ -223,9 +244,11 @@ describe('cacheManager', () => {
validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }), validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }),
).not.to.throw; ).not.to.throw;
}); });
it('accepts undefined', () => { it('accepts undefined', () => {
expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw; expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw;
}); });
it('does not accept anything else', () => { it('does not accept anything else', () => {
// @ts-ignore // @ts-ignore
expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw( expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw(
@ -233,6 +256,45 @@ describe('cacheManager', () => {
); );
}); });
}); });
describe('the contentTypes property', () => {
it('accepts an array', () => {
// @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions
expect(() => validateCacheOptions({ contentTypes: [6, 'elements', 'in', 1, true, Array] }))
.not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ contentTypes: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() => validateCacheOptions({ contentTypes: 'not-an-array' })).to.throw(
'Property `contentTypes` must be an `Array` or `undefined`',
);
});
});
describe('the maxResponseSize property', () => {
it('accepts a finite number', () => {
expect(() => validateCacheOptions({ maxResponseSize: 42 })).not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ maxResponseSize: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() => validateCacheOptions({ maxResponseSize: 'string' })).to.throw(
'Property `maxResponseSize` must be a finite `number`',
);
expect(() => validateCacheOptions({ maxResponseSize: Infinity })).to.throw(
'Property `maxResponseSize` must be a finite `number`',
);
});
});
}); });
describe('invalidateMatchingCache', () => { describe('invalidateMatchingCache', () => {

View file

@ -22,6 +22,8 @@ describe('cache interceptors', () => {
let cacheId; let cacheId;
/** @type {sinon.SinonStub} */ /** @type {sinon.SinonStub} */
let fetchStub; let fetchStub;
/** @type {Response} */
let mockResponse;
const getCacheIdentifier = () => String(cacheId); const getCacheIdentifier = () => String(cacheId);
/** @type {sinon.SinonSpy} */ /** @type {sinon.SinonSpy} */
let ajaxRequestSpy; let ajaxRequestSpy;
@ -51,8 +53,9 @@ describe('cache interceptors', () => {
beforeEach(() => { beforeEach(() => {
ajax = new Ajax(); ajax = new Ajax();
mockResponse = new Response('mock response');
fetchStub = sinon.stub(window, 'fetch'); fetchStub = sinon.stub(window, 'fetch');
fetchStub.returns(Promise.resolve(new Response('mock response'))); fetchStub.resolves(mockResponse);
ajaxRequestSpy = sinon.spy(ajax, 'fetch'); ajaxRequestSpy = sinon.spy(ajax, 'fetch');
}); });
@ -150,14 +153,12 @@ describe('cache interceptors', () => {
expect(fetchStub.callCount).to.equal(1); expect(fetchStub.callCount).to.equal(1);
}); });
// TODO: Check if this is the behaviour we want it('all calls are cached proactively', async () => {
it('all calls with non-default `maxAge` are cached proactively', async () => {
// Given // Given
newCacheId(); newCacheId();
addCacheInterceptors(ajax, { addCacheInterceptors(ajax, {
useCache: false, useCache: false,
maxAge: 100,
}); });
// When // When
@ -169,11 +170,7 @@ describe('cache interceptors', () => {
expect(fetchStub.callCount).to.equal(1); expect(fetchStub.callCount).to.equal(1);
// When // When
await ajax.fetch('/test', { await ajax.fetch('/test');
cacheOptions: {
useCache: true,
},
});
// Then // Then
expect(fetchStub.callCount).to.equal(2); expect(fetchStub.callCount).to.equal(2);
@ -297,7 +294,7 @@ describe('cache interceptors', () => {
); );
// @ts-ignore not an actual valid CacheResponse object // @ts-ignore not an actual valid CacheResponse object
await cacheResponseInterceptor({ request: { method: 'get' } }) await cacheResponseInterceptor({ request: { method: 'get' }, headers: new Headers() })
.then(() => expect('everything').to.be.ok) .then(() => expect('everything').to.be.ok)
.catch(err => .catch(err =>
expect.fail( expect.fail(
@ -305,6 +302,259 @@ describe('cache interceptors', () => {
), ),
); );
}); });
it('caches concurrent requests', async () => {
newCacheId();
const clock = sinon.useFakeTimers();
fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 750,
});
const firstRequest = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
// firstRequest is cached at tick 1000 in the next line!
const firstResponses = await Promise.all([
firstRequest,
concurrentFirstRequest1,
concurrentFirstRequest2,
]);
expect(fetchStub.callCount).to.equal(1);
const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(500);
const cachedFirstResponse = await cachedFirstRequest;
expect(fetchStub.callCount).to.equal(1);
const secondRequest = ajax.fetch('/test').then(r => r.text());
const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
expect(fetchStub.callCount).to.equal(2);
expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
expect(cachedFirstResponse).to.equal('mock response 1');
expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
clock.restore();
});
it('preserves status and headers when returning cached response', async () => {
newCacheId();
fetchStub.returns(
Promise.resolve(
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
),
);
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
const response1 = await ajax.fetch('/test');
const response2 = await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
expect(response1.status).to.equal(206);
expect(response1.headers.get('x-foo')).to.equal('x-bar');
expect(response2.status).to.equal(206);
expect(response2.headers.get('x-foo')).to.equal('x-bar');
});
it('does save to the cache when `contentTypes` is specified and a supported content type is returned', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-type', 'application/xml');
addCacheInterceptors(ajax, {
useCache: true,
contentTypes: ['application/json', 'application/xml'],
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
});
it('does save to the cache when `maxResponseSize` is specified and the response size is within the threshold', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-length', '20000');
addCacheInterceptors(ajax, {
useCache: true,
maxResponseSize: 50000,
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
});
it('does save to the cache when `maxResponseSize` is specified and the response size is unknown', async () => {
// Given
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxResponseSize: 50000,
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
});
});
describe('Bypassing the cache', () => {
it('caches response but does not return it when expiration time is 0', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 0,
});
const clock = sinon.useFakeTimers();
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
clock.tick(1);
await ajax.fetch('/test');
clock.restore();
expect(fetchStub.callCount).to.equal(2);
});
it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
// Given
newCacheId();
addCacheInterceptors(ajax, { useCache: true });
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('does not save to the cache when `contentTypes` is specified and an unsupported content type is returned', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-type', 'text/html');
addCacheInterceptors(ajax, {
useCache: true,
contentTypes: ['application/json', 'application/xml'],
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test', { cacheOptions: { contentTypes: ['text/html'] } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('does not read from the cache when `contentTypes` is specified and an unsupported content type is returned', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-type', 'application/json');
addCacheInterceptors(ajax, {
useCache: true,
contentTypes: ['application/json', 'application/xml'],
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { contentTypes: [] } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('does not save to the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-length', '80000');
addCacheInterceptors(ajax, {
useCache: true,
maxResponseSize: 50000,
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 100000 } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('does not read from the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-length', '80000');
addCacheInterceptors(ajax, {
useCache: true,
maxResponseSize: 100000,
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 50000 } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
}); });
describe('Cache invalidation', () => { describe('Cache invalidation', () => {
@ -449,101 +699,6 @@ describe('cache interceptors', () => {
expect(fetchStub.callCount).to.equal(3); expect(fetchStub.callCount).to.equal(3);
}); });
it('caches response but does not return it when expiration time is 0', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 0,
});
const clock = sinon.useFakeTimers();
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
clock.tick(1);
await ajax.fetch('/test');
clock.restore();
expect(fetchStub.callCount).to.equal(2);
});
it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
// Given
addCacheInterceptors(ajax, { useCache: true });
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('caches concurrent requests', async () => {
newCacheId();
const clock = sinon.useFakeTimers();
fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 750,
});
const firstRequest = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
// firstRequest is cached at tick 1000 in the next line!
const firstResponses = await Promise.all([
firstRequest,
concurrentFirstRequest1,
concurrentFirstRequest2,
]);
expect(fetchStub.callCount).to.equal(1);
const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(500);
const cachedFirstResponse = await cachedFirstRequest;
expect(fetchStub.callCount).to.equal(1);
const secondRequest = ajax.fetch('/test').then(r => r.text());
const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
expect(fetchStub.callCount).to.equal(2);
expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
expect(cachedFirstResponse).to.equal('mock response 1');
expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
});
it('discards responses that are requested in a different cache session', async () => { it('discards responses that are requested in a different cache session', async () => {
newCacheId(); newCacheId();
@ -569,27 +724,5 @@ describe('cache interceptors', () => {
expect(ajaxCache._cachedRequests).to.deep.equal({}); expect(ajaxCache._cachedRequests).to.deep.equal({});
expect(fetchStub.callCount).to.equal(1); expect(fetchStub.callCount).to.equal(1);
}); });
it('preserves status and headers when returning cached response', async () => {
newCacheId();
fetchStub.returns(
Promise.resolve(
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
),
);
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
const response1 = await ajax.fetch('/test');
const response2 = await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
expect(response1.status).to.equal(206);
expect(response1.headers.get('x-foo')).to.equal('x-bar');
expect(response2.status).to.equal(206);
expect(response2.headers.get('x-foo')).to.equal('x-bar');
});
}); });
}); });

View file

@ -12,6 +12,7 @@ export interface LionRequestInit extends Omit<RequestInit, 'body'> {
export interface AjaxConfig { export interface AjaxConfig {
addAcceptLanguage: boolean; addAcceptLanguage: boolean;
addCaching: boolean;
xsrfCookieName: string | null; xsrfCookieName: string | null;
xsrfHeaderName: string | null; xsrfHeaderName: string | null;
cacheOptions: CacheOptionsWithIdentifier; cacheOptions: CacheOptionsWithIdentifier;
@ -39,6 +40,8 @@ export interface CacheOptions {
invalidateUrls?: string[]; invalidateUrls?: string[];
invalidateUrlsRegex?: RegExp; invalidateUrlsRegex?: RegExp;
requestIdFunction?: RequestIdFunction; requestIdFunction?: RequestIdFunction;
contentTypes?: string[];
maxResponseSize?: number;
} }
export interface CacheOptionsWithIdentifier extends CacheOptions { export interface CacheOptionsWithIdentifier extends CacheOptions {

View file

@ -1,5 +1,13 @@
# Change Log # Change Log
## 0.20.1
### Patch Changes
- 41297869: Add mixed-state feature to checkbox indeterminate. See https://www.w3.org/TR/wai-aria-practices-1.1/examples/checkbox/checkbox-2/checkbox-2.html for the WAI ARIA pattern.
- Updated dependencies [43dd1320]
- @lion/form-core@0.17.1
## 0.20.0 ## 0.20.0
### Minor Changes ### Minor Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/checkbox-group", "name": "@lion/checkbox-group",
"version": "0.20.0", "version": "0.20.1",
"description": "A container for multiple checkboxes", "description": "A container for multiple checkboxes",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",
@ -40,7 +40,7 @@
], ],
"dependencies": { "dependencies": {
"@lion/core": "^0.22.0", "@lion/core": "^0.22.0",
"@lion/form-core": "^0.17.0", "@lion/form-core": "^0.17.1",
"@lion/input": "^0.17.0" "@lion/input": "^0.17.0"
}, },
"keywords": [ "keywords": [

View file

@ -31,6 +31,11 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
type: Boolean, type: Boolean,
reflect: true, reflect: true,
}, },
mixedState: {
type: Boolean,
reflect: true,
attribute: 'mixed-state',
},
}; };
} }
@ -58,6 +63,18 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
return /** @type LionCheckbox[] */ (checkboxes); return /** @type LionCheckbox[] */ (checkboxes);
} }
_storeIndeterminateState() {
this._indeterminateSubStates = this._subCheckboxes.map(checkbox => checkbox.checked);
}
_setOldState() {
if (this.indeterminate) {
this._oldState = 'indeterminate';
} else {
this._oldState = this.checked ? 'checked' : 'unchecked';
}
}
/** /**
* @protected * @protected
*/ */
@ -89,6 +106,27 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
}); });
} }
_setBasedOnMixedState() {
switch (this._oldState) {
case 'checked':
// --> unchecked
this.checked = false;
this.indeterminate = false;
break;
case 'unchecked':
// --> indeterminate
this.checked = false;
this.indeterminate = true;
break;
case 'indeterminate':
// --> checked
this.checked = true;
this.indeterminate = false;
break;
// no default
}
}
/** /**
* @param {Event} ev * @param {Event} ev
* @private * @private
@ -97,15 +135,44 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
if (this.disabled) { if (this.disabled) {
return; return;
} }
const _ev = /** @type {CustomEvent} */ (ev); const _ev = /** @type {CustomEvent} */ (ev);
// If the model value change event is coming from out own _inputNode
// and we're not already setting our own (mixed) state programmatically
if (_ev.detail.formPath[0] === this && !this.__settingOwnChecked) { if (_ev.detail.formPath[0] === this && !this.__settingOwnChecked) {
const allEqual = (/** @type {any[]} */ arr) => arr.every(val => val === arr[0]);
// If our child checkboxes states are all the same, we shouldn't be able to reach indeterminate (mixed) state
if (this.mixedState && !allEqual(this._indeterminateSubStates)) {
this._setBasedOnMixedState();
}
this.__settingOwnSubs = true;
if (this.indeterminate && this.mixedState) {
this._subCheckboxes.forEach((checkbox, i) => {
// eslint-disable-next-line no-param-reassign
checkbox.checked = this._indeterminateSubStates[i];
});
} else {
this._subCheckboxes.forEach(checkbox => { this._subCheckboxes.forEach(checkbox => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
checkbox.checked = this._inputNode.checked; checkbox.checked = this._inputNode.checked;
}); });
} }
this.updateComplete.then(() => {
this.__settingOwnSubs = false;
});
} else {
this._setOwnCheckedState(); this._setOwnCheckedState();
this.updateComplete.then(() => {
if (!this.__settingOwnSubs && !this.__settingOwnChecked && this.mixedState) {
this._storeIndeterminateState();
}
});
}
if (this.mixedState) {
this._setOldState();
}
} }
/** /**
@ -132,6 +199,9 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
this.indeterminate = false; this.indeterminate = false;
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this); this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.__onModelValueChanged = this.__onModelValueChanged.bind(this); this.__onModelValueChanged = this.__onModelValueChanged.bind(this);
/** @type {boolean[]} */
this._indeterminateSubStates = [];
this.mixedState = false;
} }
connectedCallback() { connectedCallback() {
@ -146,6 +216,15 @@ export class LionCheckboxIndeterminate extends LionCheckbox {
this.removeEventListener('form-element-register', this._onRequestToAddFormElement); this.removeEventListener('form-element-register', this._onRequestToAddFormElement);
} }
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this._setOldState();
if (this.indeterminate) {
this._storeIndeterminateState();
}
}
/** @param {import('lit-element').PropertyValues } changedProperties */ /** @param {import('lit-element').PropertyValues } changedProperties */
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);

View file

@ -4,6 +4,7 @@ import { getFormControlMembers } from '@lion/form-core/test-helpers';
import '@lion/checkbox-group/define'; import '@lion/checkbox-group/define';
/** /**
* @typedef {import('../src/LionCheckbox').LionCheckbox} LionCheckbox
* @typedef {import('../src/LionCheckboxIndeterminate').LionCheckboxIndeterminate} LionCheckboxIndeterminate * @typedef {import('../src/LionCheckboxIndeterminate').LionCheckboxIndeterminate} LionCheckboxIndeterminate
* @typedef {import('../src/LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup * @typedef {import('../src/LionCheckboxGroup').LionCheckboxGroup} LionCheckboxGroup
*/ */
@ -428,4 +429,153 @@ describe('<lion-checkbox-indeterminate>', () => {
expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false; expect(elIndeterminate?.hasAttribute('indeterminate')).to.be.false;
expect(elIndeterminate?.checked).to.be.true; expect(elIndeterminate?.checked).to.be.true;
}); });
// https://www.w3.org/TR/wai-aria-practices-1.1/examples/checkbox/checkbox-2/checkbox-2.html
describe('mixed-state', () => {
it('can have a mixed-state (using mixed-state attribute), none -> indeterminate -> all, cycling through', async () => {
const el = await fixture(html`
<lion-checkbox-group name="scientists[]">
<lion-checkbox-indeterminate mixed-state label="Favorite scientists">
<lion-checkbox slot="checkbox" label="Archimedes" checked></lion-checkbox>
<lion-checkbox slot="checkbox" label="Francis Bacon"></lion-checkbox>
<lion-checkbox slot="checkbox" label="Marie Curie"></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`);
const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector('lion-checkbox-indeterminate')
);
expect(elIndeterminate.mixedState).to.be.true;
expect(elIndeterminate.checked).to.be.false;
expect(elIndeterminate.indeterminate).to.be.true;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.true;
expect(elIndeterminate.indeterminate).to.be.false;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.false;
expect(elIndeterminate.indeterminate).to.be.false;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.false;
expect(elIndeterminate.indeterminate).to.be.true;
});
it('should reset to old child checkbox states when reaching indeterminate state', async () => {
const el = await fixture(html`
<lion-checkbox-group name="scientists[]">
<lion-checkbox-indeterminate mixed-state label="Favorite scientists">
<lion-checkbox slot="checkbox" label="Archimedes" checked></lion-checkbox>
<lion-checkbox slot="checkbox" label="Francis Bacon"></lion-checkbox>
<lion-checkbox slot="checkbox" label="Marie Curie"></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`);
const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector('lion-checkbox-indeterminate')
);
const checkboxEls = /** @type {LionCheckbox[]} */ (
Array.from(el.querySelectorAll('lion-checkbox'))
);
expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([true, false, false]);
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([true, true, true]);
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([false, false, false]);
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(checkboxEls.map(checkboxEl => checkboxEl.checked)).to.eql([true, false, false]);
});
it('should no longer reach indeterminate state if the child boxes are all checked or all unchecked during indeterminate state', async () => {
const el = await fixture(html`
<lion-checkbox-group name="scientists[]">
<lion-checkbox-indeterminate mixed-state label="Favorite scientists">
<lion-checkbox slot="checkbox" label="Archimedes" checked></lion-checkbox>
<lion-checkbox slot="checkbox" label="Francis Bacon"></lion-checkbox>
<lion-checkbox slot="checkbox" label="Marie Curie"></lion-checkbox>
</lion-checkbox-indeterminate>
</lion-checkbox-group>
`);
const elIndeterminate = /** @type {LionCheckboxIndeterminate} */ (
el.querySelector('lion-checkbox-indeterminate')
);
const checkboxEls = /** @type {LionCheckbox[]} */ (
Array.from(el.querySelectorAll('lion-checkbox'))
);
// Check when all child boxes in indeterminate state are unchecked
// we don't have a tri-state, but a duo-state.
// @ts-ignore for testing purposes, we access this protected getter
checkboxEls[0]._inputNode.click();
await elIndeterminate.updateComplete;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.true;
expect(elIndeterminate.indeterminate).to.be.false;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.false;
expect(elIndeterminate.indeterminate).to.be.false;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.true;
expect(elIndeterminate.indeterminate).to.be.false;
// Check when all child boxes in indeterminate state are getting checked
// we also don't have a tri-state, but a duo-state.
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click(); // unchecked
await elIndeterminate.updateComplete;
for (const checkEl of checkboxEls) {
// @ts-ignore for testing purposes, we access this protected getter
checkEl._inputNode.click();
// Give each checking of the sub checkbox a chance to finish updating
// This means indeterminate state will be true for a bit and the state gets stored
await checkEl.updateComplete;
await elIndeterminate.updateComplete;
}
expect(elIndeterminate.checked).to.be.true;
expect(elIndeterminate.indeterminate).to.be.false;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.false;
expect(elIndeterminate.indeterminate).to.be.false;
// @ts-ignore for testing purposes, we access this protected getter
elIndeterminate._inputNode.click();
await elIndeterminate.updateComplete;
expect(elIndeterminate.checked).to.be.true;
expect(elIndeterminate.indeterminate).to.be.false;
});
});
}); });

View file

@ -331,7 +331,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
* @protected * @protected
*/ */
this._listboxReceivesNoFocus = true; this._listboxReceivesNoFocus = true;
/**
* @configure ListboxMixin
* @protected
*/
this._noTypeAhead = true;
/** /**
* @private * @private
*/ */

View file

@ -1,5 +1,11 @@
# Change Log # Change Log
## 0.17.1
### Patch Changes
- 43dd1320: fix: reset the form validators after a form `reset` click or field emptied
## 0.17.0 ## 0.17.0
### Minor Changes ### Minor Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/form-core", "name": "@lion/form-core",
"version": "0.17.0", "version": "0.17.1",
"description": "Form-core contains all essential building blocks for creating form fields and fieldsets", "description": "Form-core contains all essential building blocks for creating form fields and fieldsets",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",

View file

@ -156,10 +156,6 @@ const FormRegistrarMixinImplementation = superclass =>
// 2. Add children as object key // 2. Add children as object key
if (this._isFormOrFieldset) { if (this._isFormOrFieldset) {
const { name } = child; const { name } = child;
if (!name) {
console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError('You need to define a name');
}
if (name === this.name) { if (name === this.name) {
console.info('Error Node:', child); // eslint-disable-line no-console console.info('Error Node:', child); // eslint-disable-line no-console
throw new TypeError(`You can not have the same name "${name}" as your parent`); throw new TypeError(`You can not have the same name "${name}" as your parent`);

View file

@ -133,27 +133,6 @@ export function runFormGroupMixinSuite(cfg = {}) {
]); ]);
}); });
it('throws if an element without a name tries to register', async () => {
const orig = console.info;
console.info = () => {};
let error;
const el = /** @type {FormGroup} */ (await fixture(html`<${tag}></${tag}>`));
try {
// we test the api directly as errors thrown from a web component are in a
// different context and we can not catch them here => register fake elements
el.addFormElement(
/** @type {HTMLElement & import('../../types/FormControlMixinTypes').FormControlHost} */ ({}),
);
} catch (err) {
error = err;
}
expect(error).to.be.instanceOf(TypeError);
expect(/** @type {TypeError} */ (error).message).to.equal('You need to define a name');
console.info = orig; // restore original console
});
it('throws if name is the same as its parent', async () => { it('throws if name is the same as its parent', async () => {
const orig = console.info; const orig = console.info;
console.info = () => {}; console.info = () => {};

View file

@ -1,5 +1,12 @@
# @lion/input-tel-dropdown # @lion/input-tel-dropdown
## 0.1.2
### Patch Changes
- 8ecfc31c: sync disable state to dropdown for a11y
- @lion/input-tel@0.1.2
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/input-tel-dropdown", "name": "@lion/input-tel-dropdown",
"version": "0.1.1", "version": "0.1.2",
"description": "Input field for entering phone numbers with the help of a dropdown region list", "description": "Input field for entering phone numbers with the help of a dropdown region list",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",
@ -35,7 +35,7 @@
], ],
"dependencies": { "dependencies": {
"@lion/core": "0.22.0", "@lion/core": "0.22.0",
"@lion/input-tel": "0.1.1", "@lion/input-tel": "0.1.2",
"@lion/localize": "0.24.0" "@lion/localize": "0.24.0"
}, },
"keywords": [ "keywords": [

View file

@ -20,13 +20,12 @@ import { localize } from '@lion/localize';
* @typedef {import('lit/directives/ref.js').Ref} Ref * @typedef {import('lit/directives/ref.js').Ref} Ref
* @typedef {import('@lion/core').RenderOptions} RenderOptions * @typedef {import('@lion/core').RenderOptions} RenderOptions
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatHost} FormatHost * @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatHost} FormatHost
* @typedef {import('@lion/input-tel/types').FormatStrategy} FormatStrategy
* @typedef {import('@lion/input-tel/types').RegionCode} RegionCode * @typedef {import('@lion/input-tel/types').RegionCode} RegionCode
* @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel * @typedef {import('../types').TemplateDataForDropdownInputTel} TemplateDataForDropdownInputTel
* @typedef {import('../types').OnDropdownChangeEvent} OnDropdownChangeEvent * @typedef {import('../types').OnDropdownChangeEvent} OnDropdownChangeEvent
* @typedef {import('../types').DropdownRef} DropdownRef * @typedef {import('../types').DropdownRef} DropdownRef
* @typedef {import('../types').RegionMeta} RegionMeta * @typedef {import('../types').RegionMeta} RegionMeta
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
* @typedef {import('@lion/select-rich').LionSelectRich} LionSelectRich * @typedef {import('@lion/select-rich').LionSelectRich} LionSelectRich
* @typedef {import('@lion/overlays').OverlayController} OverlayController * @typedef {import('@lion/overlays').OverlayController} OverlayController
* @typedef {TemplateDataForDropdownInputTel & {data: {regionMetaList:RegionMeta[]}}} TemplateDataForIntlInputTel * @typedef {TemplateDataForDropdownInputTel & {data: {regionMetaList:RegionMeta[]}}} TemplateDataForIntlInputTel

View file

@ -1,4 +1,4 @@
import { RegionCode } from '@lion/input-tel/types/types'; import { RegionCode } from '@lion/input-tel/types';
import { LionSelectRich } from '@lion/select-rich'; import { LionSelectRich } from '@lion/select-rich';
import { LionCombobox } from '@lion/combobox'; import { LionCombobox } from '@lion/combobox';

View file

@ -1,5 +1,12 @@
# @lion/input-tel # @lion/input-tel
## 0.1.2
### Patch Changes
- Updated dependencies [43dd1320]
- @lion/form-core@0.17.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/input-tel", "name": "@lion/input-tel",
"version": "0.1.1", "version": "0.1.2",
"description": "Input field for entering phone numbers, including validation, formatting and mobile keyboard support.", "description": "Input field for entering phone numbers, including validation, formatting and mobile keyboard support.",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",
@ -38,9 +38,10 @@
], ],
"dependencies": { "dependencies": {
"@lion/core": "0.22.0", "@lion/core": "0.22.0",
"@lion/form-core": "0.17.0", "@lion/form-core": "0.17.1",
"@lion/input": "0.17.0", "@lion/input": "0.17.0",
"@lion/localize": "0.24.0" "@lion/localize": "0.24.0",
"awesome-phonenumber": "^3.0.1"
}, },
"keywords": [ "keywords": [
"input", "input",

View file

@ -9,12 +9,12 @@ import { PhoneNumber } from './validators.js';
import { localizeNamespaceLoader } from './localizeNamespaceLoader.js'; import { localizeNamespaceLoader } from './localizeNamespaceLoader.js';
/** /**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode * @typedef {import('../types').RegionCode} RegionCode
* @typedef {import('../types').PhoneNumberType} PhoneNumberType * @typedef {import('awesome-phonenumber').PhoneNumberFormat} PhoneNumberFormat
* @typedef {import('awesome-phonenumber').PhoneNumberTypes} PhoneNumberTypes
* @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatOptions} FormatOptions * @typedef {import('@lion/form-core/types/FormatMixinTypes').FormatOptions} FormatOptions
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
* @typedef {FormatOptions & {regionCode: RegionCode; formatStrategy: FormatStrategy}} FormatOptionsTel * @typedef {FormatOptions & {regionCode: RegionCode; formatStrategy: PhoneNumberFormat}} FormatOptionsTel
*/ */
export class LionInputTel extends LocalizeMixin(LionInput) { export class LionInputTel extends LocalizeMixin(LionInput) {
@ -65,7 +65,7 @@ export class LionInputTel extends LocalizeMixin(LionInput) {
* -'unknown' * -'unknown'
* See https://www.npmjs.com/package/awesome-phonenumber * See https://www.npmjs.com/package/awesome-phonenumber
* @readonly * @readonly
* @property {PhoneNumberType|undefined} activePhoneNumberType * @property {PhoneNumberTypes|undefined} activePhoneNumberTypes
*/ */
get activePhoneNumberType() { get activePhoneNumberType() {
let pn; let pn;
@ -135,7 +135,7 @@ export class LionInputTel extends LocalizeMixin(LionInput) {
* Determines what the formatter output should look like. * Determines what the formatter output should look like.
* Formatting strategies as provided by google-libphonenumber * Formatting strategies as provided by google-libphonenumber
* See: https://www.npmjs.com/package/google-libphonenumber * See: https://www.npmjs.com/package/google-libphonenumber
* @type {FormatStrategy} * @type {PhoneNumberFormat}
*/ */
this.formatStrategy = 'international'; this.formatStrategy = 'international';
@ -286,7 +286,7 @@ export class LionInputTel extends LocalizeMixin(LionInput) {
*/ */
_onPhoneNumberUtilReady() { _onPhoneNumberUtilReady() {
// This should trigger a rerender in shadow dom // This should trigger a rerender in shadow dom
this._phoneUtil = /** @type {PhoneNumber} */ (PhoneUtilManager.PhoneUtil); this._phoneUtil = /** @type {AwesomePhoneNumber} */ (PhoneUtilManager.PhoneUtil);
// This should trigger a rerender in light dom // This should trigger a rerender in light dom
this._scheduleLightDomRender(); this._scheduleLightDomRender();
// Format when libPhoneNumber is loaded // Format when libPhoneNumber is loaded

View file

@ -9,7 +9,7 @@ let resolveLoaded;
*/ */
export class PhoneUtilManager { export class PhoneUtilManager {
static async loadLibPhoneNumber() { static async loadLibPhoneNumber() {
const PhoneUtil = (await import('./lib/awesome-phonenumber-esm.js')).default; const PhoneUtil = (await import('awesome-phonenumber')).default;
this.PhoneUtil = PhoneUtil; this.PhoneUtil = PhoneUtil;
resolveLoaded(undefined); resolveLoaded(undefined);
return PhoneUtil; return PhoneUtil;

View file

@ -1,16 +1,16 @@
import { PhoneUtilManager } from './PhoneUtilManager.js'; import { PhoneUtilManager } from './PhoneUtilManager.js';
/** /**
* @typedef {import('../types').FormatStrategy} FormatStrategy * @typedef {import('awesome-phonenumber').PhoneNumberFormat} PhoneNumberFormat
* @typedef {import('../types').RegionCode} RegionCode * @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
*/ */
/** /**
* @param {string} modelValue * @param {string} modelValue
* @param {object} options * @param {object} options
* @param {RegionCode} options.regionCode * @param {RegionCode} options.regionCode
* @param {FormatStrategy} [options.formatStrategy='international'] * @param {PhoneNumberFormat} [options.formatStrategy='international']
* @returns {string} * @returns {string}
*/ */
export function formatPhoneNumber(modelValue, { regionCode, formatStrategy = 'international' }) { export function formatPhoneNumber(modelValue, { regionCode, formatStrategy = 'international' }) {

File diff suppressed because it is too large Load diff

View file

@ -2,12 +2,12 @@ import { PhoneUtilManager } from './PhoneUtilManager.js';
/** /**
* @typedef {import('../types').RegionCode} RegionCode * @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
*/ */
/** /**
* @param {string} viewValue * @param {string} viewValue
* @param {{regionCode:RegionCode;}} options * @param {{regionCode:RegionCode}} options
* @returns {string} * @returns {string}
*/ */
export function parsePhoneNumber(viewValue, { regionCode }) { export function parsePhoneNumber(viewValue, { regionCode }) {

View file

@ -2,9 +2,9 @@ import { PhoneUtilManager } from './PhoneUtilManager.js';
import { formatPhoneNumber } from './formatters.js'; import { formatPhoneNumber } from './formatters.js';
/** /**
* @typedef {import('../types').FormatStrategy} FormatStrategy
* @typedef {import('../types').RegionCode} RegionCode * @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {import('awesome-phonenumber').PhoneNumberFormat} PhoneNumberFormat
* @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
*/ */
/** /**
@ -13,7 +13,7 @@ import { formatPhoneNumber } from './formatters.js';
* @param {RegionCode} options.regionCode * @param {RegionCode} options.regionCode
* @param {string} options.prevViewValue * @param {string} options.prevViewValue
* @param {number} options.currentCaretIndex * @param {number} options.currentCaretIndex
* @param {FormatStrategy} options.formatStrategy * @param {PhoneNumberFormat} options.formatStrategy
* @returns {{viewValue:string; caretIndex:number;}|undefined} * @returns {{viewValue:string; caretIndex:number;}|undefined}
*/ */
export function liveFormatPhoneNumber( export function liveFormatPhoneNumber(

View file

@ -3,7 +3,7 @@ import { PhoneUtilManager } from './PhoneUtilManager.js';
/** /**
* @typedef {import('../types').RegionCode} RegionCode * @typedef {import('../types').RegionCode} RegionCode
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
*/ */
/** /**

View file

@ -8,7 +8,7 @@ import {
} from '../test-helpers/mockPhoneUtilManager.js'; } from '../test-helpers/mockPhoneUtilManager.js';
/** /**
* @typedef {* & import('@lion/input-tel/src/lib/awesome-phonenumber-esm').default} AwesomePhoneNumber * @typedef {* & import('awesome-phonenumber').default} AwesomePhoneNumber
*/ */
// For enum output, see: https://www.npmjs.com/package/awesome-phonenumber // For enum output, see: https://www.npmjs.com/package/awesome-phonenumber

View file

@ -1,46 +1,6 @@
/*
* Phone number types as provided by google-libphonenumber
* See:
* - https://www.npmjs.com/package/google-libphonenumber
* - https://www.npmjs.com/package/awesome-phonenumber
*/
export type PhoneNumberType =
| 'fixed-line'
| 'fixed-line-or-mobile'
| 'mobile'
| 'pager'
| 'personal-number'
| 'premium-rate'
| 'shared-cost'
| 'toll-free'
| 'uan'
| 'voip'
| 'unknown';
/*
* Phone number possibilities as provided by google-libphonenumber
* See:
* - https://www.npmjs.com/package/google-libphonenumber
* - https://www.npmjs.com/package/awesome-phonenumber
*/
export type PhoneNumberPossibility =
| 'is-possible'
| 'invalid-country-code'
| 'too-long'
| 'too-short'
| 'unknown';
/*
* Phone number formats / formatting strategies as provided by google-libphonenumber
* See:
* - https://www.npmjs.com/package/google-libphonenumber
* - https://www.npmjs.com/package/awesome-phonenumber
*/
export type FormatStrategy = 'e164' | 'international' | 'national' | 'rfc3966' | 'significant';
/** /**
* Supported countries/regions as provided via * Supported countries/regions as provided via
* `libphonenumber.PhoneNumberUtil.getInstance().getSupportedRegions()` * `libphonenumber.getSupportedRegionCodes()`
*/ */
export type RegionCode = export type RegionCode =
| 'AC' | 'AC'

View file

@ -1,5 +1,13 @@
# @lion/listbox # @lion/listbox
## 0.13.1
### Patch Changes
- a28686ee: Add TypeAhead, so with typing characters you will set an option with matching value active/checked
- Updated dependencies [43dd1320]
- @lion/form-core@0.17.1
## 0.13.0 ## 0.13.0
### Minor Changes ### Minor Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/listbox", "name": "@lion/listbox",
"version": "0.13.0", "version": "0.13.1",
"description": "A listbox widget presents a list of options and allows a user to select one or more of them", "description": "A listbox widget presents a list of options and allows a user to select one or more of them",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",
@ -40,7 +40,7 @@
], ],
"dependencies": { "dependencies": {
"@lion/core": "^0.22.0", "@lion/core": "^0.22.0",
"@lion/form-core": "^0.17.0" "@lion/form-core": "^0.17.1"
}, },
"keywords": [ "keywords": [
"form", "form",

View file

@ -81,6 +81,9 @@ const ListboxMixinImplementation = superclass =>
reflect: true, reflect: true,
attribute: 'has-no-default-selected', attribute: 'has-no-default-selected',
}, },
_noTypeAhead: {
type: Boolean,
},
}; };
} }
@ -270,7 +273,16 @@ const ListboxMixinImplementation = superclass =>
* See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus * See: https://www.w3.org/TR/wai-aria-practices/#kbd_selection_follows_focus
*/ */
this.selectionFollowsFocus = false; this.selectionFollowsFocus = false;
/**
* When false, a user can type on which the focus will jump to the matching option
*/
this._noTypeAhead = false;
/**
* The pending char sequence that will set active list item
* @type {number}
* @protected
*/
this._typeAheadTimeout = 1000;
/** /**
* @type {number | null} * @type {number | null}
* @protected * @protected
@ -327,6 +339,11 @@ const ListboxMixinImplementation = superclass =>
* @private * @private
*/ */
this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this); this.__preventScrollingWithArrowKeys = this.__preventScrollingWithArrowKeys.bind(this);
/**
* @type {string[]}
* @private
*/
this.__typedChars = [];
} }
connectedCallback() { connectedCallback() {
@ -466,6 +483,39 @@ const ListboxMixinImplementation = superclass =>
this.resetInteractionState(); this.resetInteractionState();
} }
/**
* @param {KeyboardEvent} ev
* @param {{setAsChecked:boolean}} options
* @protected
*/
_handleTypeAhead(ev, { setAsChecked }) {
const { key, code } = ev;
if (code.startsWith('Key') || code.startsWith('Digit') || code.startsWith('Numpad')) {
ev.preventDefault();
this.__typedChars.push(key);
const chars = this.__typedChars.join('');
const matchedItemIndex =
// TODO: consider making this condition overridable for Subclassers by extracting it into protected method
this.formElements.findIndex(el => el.modelValue.value.toLowerCase().startsWith(chars));
if (matchedItemIndex >= 0) {
if (setAsChecked) {
this.setCheckedIndex(matchedItemIndex);
}
this.activeIndex = matchedItemIndex;
}
if (this.__pendingTypeAheadTimeout) {
// Prevent that pending timeouts 'intersect' with new 'typeahead sessions'
// @ts-ignore
window.clearTimeout(this.__pendingTypeAheadTimeout);
}
this.__pendingTypeAheadTimeout = setTimeout(() => {
// schedule a timeout to reset __typedChars
this.__typedChars = [];
}, this._typeAheadTimeout);
}
}
/** /**
* @override ChoiceGroupMixin: in the select disabled options are still going to a possible * @override ChoiceGroupMixin: in the select disabled options are still going to a possible
* value, for example when prefilling or programmatically setting it. * value, for example when prefilling or programmatically setting it.
@ -621,7 +671,12 @@ const ListboxMixinImplementation = superclass =>
ev.preventDefault(); ev.preventDefault();
this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0); this.activeIndex = this._getPreviousEnabledOption(this.formElements.length - 1, 0);
break; break;
/* no default */ default:
if (!this._noTypeAhead) {
this._handleTypeAhead(ev, {
setAsChecked: this.selectionFollowsFocus && !this.multipleChoice,
});
}
} }
const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End']; const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'];

View file

@ -26,10 +26,11 @@ const fixture = /** @type {(arg: TemplateResult) => Promise<LionListbox>} */ (_f
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
* @param {string} key * @param {string} key
* @param {string} code
*/ */
function mimicKeyPress(el, key) { function mimicKeyPress(el, key, code = '') {
el.dispatchEvent(new KeyboardEvent('keydown', { key })); el.dispatchEvent(new KeyboardEvent('keydown', { key, code }));
el.dispatchEvent(new KeyboardEvent('keyup', { key })); el.dispatchEvent(new KeyboardEvent('keyup', { key, code }));
} }
/** /**
@ -381,8 +382,7 @@ export function runListboxMixinSuite(customConfig = {}) {
}); });
describe('Accessibility', () => { describe('Accessibility', () => {
// TODO: enable when native button is not a child anymore it('[axe]: is accessible when opened', async () => {
it.skip('[axe]: is accessible when opened', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} label="age" opened> <${tag} label="age" opened>
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
@ -396,8 +396,7 @@ export function runListboxMixinSuite(customConfig = {}) {
}); });
// NB: regular listbox is always 'opened', but needed for combobox and select-rich // NB: regular listbox is always 'opened', but needed for combobox and select-rich
// TODO: enable when native button is not a child anymore it('[axe]: is accessible when closed', async () => {
it.skip('[axe]: is accessible when closed', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} label="age"> <${tag} label="age">
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}> <${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
@ -442,7 +441,6 @@ export function runListboxMixinSuite(customConfig = {}) {
await el.updateComplete; await el.updateComplete;
expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('first'); expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('first');
mimicKeyPress(_activeDescendantOwnerNode, 'ArrowDown'); mimicKeyPress(_activeDescendantOwnerNode, 'ArrowDown');
// _activeDescendantOwnerNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
await el.updateComplete; await el.updateComplete;
expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('second'); expect(_activeDescendantOwnerNode.getAttribute('aria-activedescendant')).to.equal('second');
}); });
@ -601,7 +599,6 @@ export function runListboxMixinSuite(customConfig = {}) {
// Normalize // Normalize
el.activeIndex = 0; el.activeIndex = 0;
const options = el.formElements; const options = el.formElements;
// mimicKeyPress(listbox, 'ArrowUp');
mimicKeyPress(_listboxNode, 'ArrowUp'); mimicKeyPress(_listboxNode, 'ArrowUp');
@ -609,7 +606,6 @@ export function runListboxMixinSuite(customConfig = {}) {
expect(options[1].active).to.be.false; expect(options[1].active).to.be.false;
expect(options[2].active).to.be.false; expect(options[2].active).to.be.false;
el.activeIndex = 2; el.activeIndex = 2;
// mimicKeyPress(listbox, 'ArrowDown');
mimicKeyPress(_listboxNode, 'ArrowDown'); mimicKeyPress(_listboxNode, 'ArrowDown');
expect(options[0].active).to.be.false; expect(options[0].active).to.be.false;
@ -709,32 +705,138 @@ export function runListboxMixinSuite(customConfig = {}) {
}); });
}); });
// TODO: add key combinations like shift+home/ctrl+A etc etc. // TODO: add key combinations like shift+home/ctrl+A etc etc.
// TODO: nice to have. Get from menu impl.
it.skip('selects a value with single [character] key', async () => { describe('Typeahead', () => {
it('activates a value with single [character] key', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened> <${tag} opened id="color" name="color" label="Favorite color">
<${optionTag} .choiceValue=${'a'}>A</${optionTag}> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'b'}>B</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'c'}>C</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
</${tag}> </${tag}>
`); `);
expect(el.modelValue).to.equal('a'); // @ts-expect-error [allow-protected-in-tests]
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'C' })); if (el._noTypeAhead) {
expect(el.modelValue).to.equal('c'); return;
}
const { _listboxNode } = getListboxMembers(el);
// Normalize start values between listbox, select and combobox and test interaction below
el.activeIndex = 0;
mimicKeyPress(_listboxNode, 't', 'KeyT');
// await aTimeout(0);
expect(el.activeIndex).to.equal(1);
}); });
it.skip('selects a value with multiple [character] keys', async () => {
it('activates a value with multiple [character] keys', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened> <${tag} opened id="color" name="color" label="Favorite color">
<${optionTag} .choiceValue=${'bar'}>Bar</${optionTag}> <${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'far'}>Far</${optionTag}> <${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'foo'}>Foo</${optionTag}> <${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
</${tag}> </${tag}>
`); `);
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F' })); // @ts-expect-error [allow-protected-in-tests]
expect(el.modelValue).to.equal('far'); if (el._noTypeAhead) {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'O' })); return;
expect(el.modelValue).to.equal('foo'); }
const { _listboxNode } = getListboxMembers(el);
// Normalize start values between listbox, select and combobox and test interaction below
el.activeIndex = 0;
mimicKeyPress(_listboxNode, 't', 'KeyT');
expect(el.activeIndex).to.equal(1);
mimicKeyPress(_listboxNode, 'u', 'KeyU');
expect(el.activeIndex).to.equal(2);
}); });
it('selects a value with [character] keys and selectionFollowsFocus', async () => {
const el = await fixture(html`
<${tag} opened id="color" name="color" label="Favorite color" selection-follows-focus>
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
</${tag}>
`);
// @ts-expect-error [allow-protected-in-tests]
if (el._noTypeAhead) {
return;
}
const { _listboxNode } = getListboxMembers(el);
// Normalize start values between listbox, select and combobox and test interaction below
el.checkedIndex = 0;
mimicKeyPress(_listboxNode, 't', 'KeyT');
expect(el.checkedIndex).to.equal(1);
mimicKeyPress(_listboxNode, 'u', 'KeyU');
expect(el.checkedIndex).to.equal(2);
});
it('clears typedChars after _typeAheadTimeout', async () => {
const el = await fixture(html`
<${tag} opened id="color" name="color" label="Favorite color">
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'turquoise'}>turquoise</${optionTag}>
</${tag}>
`);
// @ts-expect-error [allow-protected-in-tests]
if (el._noTypeAhead) {
return;
}
const clock = sinon.useFakeTimers();
const { _listboxNode } = getListboxMembers(el);
mimicKeyPress(_listboxNode, 't', 'KeyT');
// @ts-ignore [allow-private] in test
expect(el.__typedChars).to.deep.equal(['t']);
mimicKeyPress(_listboxNode, 'u', 'KeyU');
// @ts-ignore [allow-private] in test
expect(el.__typedChars).to.deep.equal(['t', 'u']);
clock.tick(1000);
// @ts-ignore [allow-private] in test
expect(el.__typedChars).to.deep.equal([]);
clock.restore();
});
it('clears scheduled timeouts', async () => {
const el = await fixture(html`
<${tag} opened id="color" name="color" label="Favorite color">
<${optionTag} .choiceValue=${'red'}>Red</${optionTag}>
<${optionTag} .choiceValue=${'teal'}>Teal</${optionTag}>
<${optionTag} .choiceValue=${'turquoise'}>Turquoise</${optionTag}>
</${tag}>
`);
// @ts-expect-error [allow-protected-in-tests]
if (el._noTypeAhead) {
return;
}
const { _listboxNode } = getListboxMembers(el);
// Normalize start values between listbox, select and combobox and test interaction below
el.activeIndex = 0;
mimicKeyPress(_listboxNode, 't', 'KeyT');
// @ts-expect-error [allow-private-in-tests]
const pendingClear = el.__pendingTypeAheadTimeout;
const clearTimeoutSpy = sinon.spy(window, 'clearTimeout');
mimicKeyPress(_listboxNode, 'u', 'KeyU');
expect(clearTimeoutSpy.args[0][0]).to.equal(pendingClear);
});
});
it('navigates to first and last option with [Home] and [End] keys', async () => { it('navigates to first and last option with [Home] and [End] keys', async () => {
const el = await fixture(html` const el = await fixture(html`
<${tag} opened> <${tag} opened>
@ -1021,7 +1123,7 @@ export function runListboxMixinSuite(customConfig = {}) {
const { _listboxNode } = getListboxMembers(el); const { _listboxNode } = getListboxMembers(el);
const options = el.formElements; const options = el.formElements;
// Normalize start values between listbox, slect and combobox and test interaction below // Normalize start values between listbox, select and combobox and test interaction below
el.activeIndex = 0; el.activeIndex = 0;
el.checkedIndex = 0; el.checkedIndex = 0;
expect(el.activeIndex).to.equal(0); expect(el.activeIndex).to.equal(0);

View file

@ -51,10 +51,14 @@ export declare class ListboxHost {
protected _listboxReceivesNoFocus: boolean; protected _listboxReceivesNoFocus: boolean;
protected _noTypeAhead: boolean;
protected _uncheckChildren(): void; protected _uncheckChildren(): void;
private __setupListboxNode(): void; private __setupListboxNode(): void;
protected _handleTypeAhead(ev: KeyboardEvent, { setAsChecked: boolean }): void;
protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number; protected _getPreviousEnabledOption(currentIndex: number, offset?: number): number;
protected _getNextEnabledOption(currentIndex: number, offset?: number): number; protected _getNextEnabledOption(currentIndex: number, offset?: number): number;
@ -78,6 +82,8 @@ export declare class ListboxHost {
protected get _activeDescendantOwnerNode(): HTMLElement; protected get _activeDescendantOwnerNode(): HTMLElement;
protected _onListboxContentChanged(): void; protected _onListboxContentChanged(): void;
private __pendingTypeAheadTimeout: number | undefined;
} }
export declare function ListboxImplementation<T extends Constructor<LitElement>>( export declare function ListboxImplementation<T extends Constructor<LitElement>>(

View file

@ -1,5 +1,15 @@
# Change Log # Change Log
## 0.30.1
### Patch Changes
- a28686ee: Add TypeAhead, so with typing characters you will set an option with matching value active/checked
- Updated dependencies [a28686ee]
- Updated dependencies [43dd1320]
- @lion/listbox@0.13.1
- @lion/form-core@0.17.1
## 0.30.0 ## 0.30.0
### Minor Changes ### Minor Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/select-rich", "name": "@lion/select-rich",
"version": "0.30.0", "version": "0.30.1",
"description": "Provides a select with options that can contain html", "description": "Provides a select with options that can contain html",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",
@ -42,8 +42,8 @@
"dependencies": { "dependencies": {
"@lion/button": "^0.17.0", "@lion/button": "^0.17.0",
"@lion/core": "^0.22.0", "@lion/core": "^0.22.0",
"@lion/form-core": "^0.17.0", "@lion/form-core": "^0.17.1",
"@lion/listbox": "^0.13.0", "@lion/listbox": "^0.13.1",
"@lion/overlays": "^0.32.0" "@lion/overlays": "^0.32.0"
}, },
"keywords": [ "keywords": [

View file

@ -489,7 +489,10 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
this.opened = true; this.opened = true;
} }
break; break;
/* no default */ default:
if (!this._noTypeAhead) {
this._handleTypeAhead(ev, { setAsChecked: true });
}
} }
} }
@ -514,7 +517,6 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L
// Tab can only be caught in keydown // Tab can only be caught in keydown
this.opened = false; this.opened = false;
break; break;
/* no default */
case 'Escape': case 'Escape':
this.opened = false; this.opened = false;
this.__blockListShowDuringTransition(); this.__blockListShowDuringTransition();

View file

@ -10,6 +10,16 @@ import '@lion/select-rich/define';
* @typedef {import('@lion/listbox').LionOption} LionOption * @typedef {import('@lion/listbox').LionOption} LionOption
*/ */
/**
* @param {HTMLElement} el
* @param {string} key
* @param {string} code
*/
function mimicKeyPress(el, key, code = '') {
el.dispatchEvent(new KeyboardEvent('keydown', { key, code }));
el.dispatchEvent(new KeyboardEvent('keyup', { key, code }));
}
/** /**
* @param {LionSelectRich} lionSelectEl * @param {LionSelectRich} lionSelectEl
*/ */
@ -142,6 +152,27 @@ describe('lion-select-rich interactions', () => {
expect(el.checkedIndex).to.equal(0); expect(el.checkedIndex).to.equal(0);
expectOnlyGivenOneOptionToBeChecked(options, 0); expectOnlyGivenOneOptionToBeChecked(options, 0);
}); });
it('checkes a value with [character] keys while listbox unopened', async () => {
const el = /** @type {LionSelectRich} */ (
await fixture(html`
<lion-select-rich interaction-mode="windows/linux">
<lion-options slot="input">
<lion-option .choiceValue=${'red'}>Red</lion-option>
<lion-option .choiceValue=${'teal'}>Teal</lion-option>
<lion-option .choiceValue=${'turquoise'}>Turquoise</lion-option>
</lion-options>
</lion-select-rich>
`)
);
// @ts-ignore [allow-private] in test
mimicKeyPress(el, 't', 'KeyT');
expect(el.checkedIndex).to.equal(1);
mimicKeyPress(el, 'u', 'KeyU');
expect(el.checkedIndex).to.equal(2);
});
}); });
describe('Disabled', () => { describe('Disabled', () => {

View file

@ -1,5 +1,13 @@
# Change Log # Change Log
## 0.20.1
### Patch Changes
- 355aabc0: fix(switch) unregister on disconnectedCallback
- Updated dependencies [43dd1320]
- @lion/form-core@0.17.1
## 0.20.0 ## 0.20.0
### Minor Changes ### Minor Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@lion/switch", "name": "@lion/switch",
"version": "0.20.0", "version": "0.20.1",
"description": "A Switch is used for switching a property or feature on and off", "description": "A Switch is used for switching a property or feature on and off",
"license": "MIT", "license": "MIT",
"author": "ing-bank", "author": "ing-bank",
@ -39,7 +39,7 @@
], ],
"dependencies": { "dependencies": {
"@lion/core": "^0.22.0", "@lion/core": "^0.22.0",
"@lion/form-core": "^0.17.0", "@lion/form-core": "^0.17.1",
"@lion/helpers": "^0.11.0" "@lion/helpers": "^0.11.0"
}, },
"keywords": [ "keywords": [

View file

@ -1,10 +1,16 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const yarnLockPath = './yarn.lock'; const lockFileContent = fs.readFileSync(path.resolve('./yarn.lock'), 'utf8');
const data = fs.readFileSync(path.resolve(yarnLockPath), 'utf8');
if (data.match(/artifactory/g)) { const allowedRegistries = ['registry.yarnpkg.com', 'registry.npmjs.org'];
const resolvedUrls = lockFileContent.match(/"https:.*"/g);
resolvedUrls.forEach(url => {
const [, registry] = url.match(/^"https:\/\/(.*?)\/.*"$/) || [];
if (!allowedRegistries.includes(registry)) {
throw new Error( throw new Error(
'Artifactory references in your yarn.lock! Please make sure you are using a public npm registry when downloading your dependencies!', `Disallowed registries ("${registry}") in your yarn.lock!
Please make sure you are using a public npm registry when downloading your dependencies!`,
); );
} }
});

View file

@ -3430,6 +3430,11 @@ autosize@4.0.2:
resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.2.tgz#073cfd07c8bf45da4b9fd153437f5bafbba1e4c9" resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.2.tgz#073cfd07c8bf45da4b9fd153437f5bafbba1e4c9"
integrity sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA== integrity sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA==
awesome-phonenumber@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/awesome-phonenumber/-/awesome-phonenumber-3.0.1.tgz#8d73aaa1c2b0a660b117567b0d9797623457e1d0"
integrity sha512-H5rqjTJ1+HxmyuSKDoPgvHUgP+RBRhtWQ25ccy4BmSLQL5UVg3K+yo2QCX4IlkxiVNst3suGMArV9TH7B1KEPw==
axe-core@^4.3.3: axe-core@^4.3.3:
version "4.3.3" version "4.3.3"
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.3.tgz#b55cd8e8ddf659fe89b064680e1c6a4dceab0325" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.3.tgz#b55cd8e8ddf659fe89b064680e1c6a4dceab0325"