diff --git a/packages/govuk-frontend/src/govuk/common/config.mjs b/packages/govuk-frontend/src/govuk/common/config.mjs new file mode 100644 index 0000000000..bed37eedc1 --- /dev/null +++ b/packages/govuk-frontend/src/govuk/common/config.mjs @@ -0,0 +1,122 @@ +import { GOVUKFrontendComponent } from '../govuk-frontend-component.mjs' +import { isObject } from './index.mjs' + +/** + * Config + * + * @template {ConfigObject} [ConfigurationType=import('./index.mjs').ObjectNested] + */ +class Config { + /** + * Schema for configuration + * + * @type {Schema} + */ + static schema + + + /** + * Defaults for configuration + * + * @type {ConfigObject} + */ + static defaults + + /** + * Dataset overrides + * + * @type {ConfigObject} + */ + static overrides + + /** + * Config flattening function + * + * Takes any number of objects, flattens them into namespaced key-value pairs, + * (e.g. {'i18n.showSection': 'Show section'}) and combines them together, with + * greatest priority on the LAST item passed in. + * + * @param {...ConfigObject} configObjects - configuration objects + * @returns {import('./index.mjs').ObjectNested} - merged configuration object + */ + static mergeConfigs(...configObjects) { + // Start with an empty object as our base + /** @type {{ [key: string]: unknown }} */ + const formattedConfigObject = {} + + // Loop through each of the passed objects + for (const configObject of configObjects) { + for (const key of Object.keys(configObject)) { + const option = formattedConfigObject[key] + const override = configObject[key] + + // Push their keys one-by-one into formattedConfigObject. Any duplicate + // keys with object values will be merged, otherwise the new value will + // override the existing value. + if (isObject(option) && isObject(override)) { + // @ts-expect-error Index signature for type 'string' is missing + formattedConfigObject[key] = this.mergeConfigs(option, override) + } else { + // Apply override + formattedConfigObject[key] = override + } + } + } + + return /** @type {import('./index.mjs').ObjectNested} */ (formattedConfigObject) + } + + configObject = {} + + + /** + * @param {import('../govuk-frontend-component.mjs').ChildClassConstructorWithConfig} component - component instance + * @param {...ConfigObject} configObjects - configuration objects + */ + constructor(component, ...configObjects) { + // const configObject = Config.mergeConfigs(...configObjects) + + this.configObject = configObject + + return new Proxy(this, { + get(target, name, receiver) { + if (!Reflect.has(target, name)) { + return configObject[String(name)] + } + return Reflect.get(target, name, receiver) + } + }) + } +} + +/* eslint-disable jsdoc/valid-types -- + * `{new(...args: any[] ): object}` is not recognised as valid + * https://github.com/gajus/eslint-plugin-jsdoc/issues/145#issuecomment-1308722878 + * https://github.com/jsdoc-type-pratt-parser/jsdoc-type-pratt-parser/issues/131 + **/ + +/** + * @typedef {{new (...args: any[]): any, defaults?: object, moduleName: string, constructor: ComponentClass, $root: { dataset: {[key:string]: string} } }} ComponentInstance + */ + +/** + * @typedef {{ schema: { properties: { [key:string]: { type?: string } } } }} ComponentClass + */ + +/* eslint-enable jsdoc/valid-types */ + +/** + * @typedef {{[key:string]: unknown}} ConfigObject + */ + +/** + * @internal + * @typedef {keyof ObjectNested} NestedKey + * @typedef {{ [key: string]: string | boolean | number | ObjectNested | undefined }} ObjectNested + */ + +/** + * @typedef {import('./index.mjs').Schema} Schema + */ + +export default Config diff --git a/packages/govuk-frontend/src/govuk/common/index.mjs b/packages/govuk-frontend/src/govuk/common/index.mjs index b0495bdd74..b364eda48c 100644 --- a/packages/govuk-frontend/src/govuk/common/index.mjs +++ b/packages/govuk-frontend/src/govuk/common/index.mjs @@ -276,7 +276,7 @@ function isArray(option) { * @param {unknown} option - Option to check * @returns {boolean} Whether the option is an object */ -function isObject(option) { +export function isObject(option) { return !!option && typeof option === 'object' && !isArray(option) } diff --git a/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs b/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs index 10aaf05ff8..20d3c3115f 100644 --- a/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs +++ b/packages/govuk-frontend/src/govuk/components/accordion/accordion.mjs @@ -1,7 +1,8 @@ -import { mergeConfigs } from '../../common/index.mjs' +// import { mergeConfigs } from '../../common/index.mjs' import { normaliseDataset } from '../../common/normalise-dataset.mjs' import { ElementError } from '../../errors/index.mjs' -import { GOVUKFrontendComponent } from '../../govuk-frontend-component.mjs' +import { GOVUKFrontendComponentConfigurable } from '../../govuk-frontend-component.mjs' +import Config from '../../common/config.mjs' import { I18n } from '../../i18n.mjs' /** @@ -17,14 +18,9 @@ import { I18n } from '../../i18n.mjs' * attribute, which also provides accessibility. * * @preserve + * @augments GOVUKFrontendComponentConfigurable */ -export class Accordion extends GOVUKFrontendComponent { - /** - * @private - * @type {AccordionConfig} - */ - config - +export class Accordion extends GOVUKFrontendComponentConfigurable { /** @private */ i18n @@ -111,13 +107,7 @@ export class Accordion extends GOVUKFrontendComponent { * @param {AccordionConfig} [config] - Accordion config */ constructor($root, config = {}) { - super($root) - - this.config = mergeConfigs( - Accordion.defaults, - config, - normaliseDataset(Accordion, this.$root.dataset) - ) + super($root, config) this.i18n = new I18n(this.config.i18n) @@ -618,6 +608,57 @@ export class Accordion extends GOVUKFrontendComponent { }) } + +/** + * Accordion Config Class + * + * @augments Config + */ +class AccordionConfigClass extends Config { + /** + * Accordion default config + * + * @see {@link AccordionConfig} + * @constant + * @type {AccordionConfig} + */ + static defaults = Object.freeze({ + i18n: { + hideAllSections: 'Hide all sections', + hideSection: 'Hide', + hideSectionAriaLabel: 'Hide this section', + showAllSections: 'Show all sections', + showSection: 'Show', + showSectionAriaLabel: 'Show this section' + }, + rememberExpanded: true + }) + + /** + * Accordion config schema + * + * @constant + * @satisfies {Schema} + */ + static schema = Object.freeze({ + properties: { + i18n: { type: 'object' }, + rememberExpanded: { type: 'boolean' } + } + }) + + /** + * @param {...AccordionConfig} configs - objects to set config + */ + constructor(Component, ...configs) { + super(AccordionConfigClass.defaults, ...configs, normaliseDataset( + + )); + + console.log('hello') + } +} + /** * Accordion config * diff --git a/packages/govuk-frontend/src/govuk/components/character-count/character-count.jsdom.test.mjs b/packages/govuk-frontend/src/govuk/components/character-count/character-count.jsdom.test.mjs index 741005be7e..a6c9637db2 100644 --- a/packages/govuk-frontend/src/govuk/components/character-count/character-count.jsdom.test.mjs +++ b/packages/govuk-frontend/src/govuk/components/character-count/character-count.jsdom.test.mjs @@ -188,6 +188,7 @@ describe('CharacterCount', () => { 'data-i18n.characters-under-limit.one', 'Custom text. Count: %{count}' ) + $div.setAttribute('data.maxlength', '50') const component = new CharacterCount($div, { maxlength: 100, @@ -198,6 +199,8 @@ describe('CharacterCount', () => { } }) + expect(component.maxLength).toBe('50') + // @ts-expect-error Property 'formatCountMessage' is private expect(component.formatCountMessage(1, 'characters')).toBe( 'Custom text. Count: 1' diff --git a/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs b/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs index 0a9d60ee72..f9fa915f9d 100644 --- a/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs +++ b/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs @@ -55,7 +55,6 @@ export class CharacterCount extends GOVUKFrontendComponent { /** @private */ i18n - /** @private */ maxLength /** diff --git a/packages/govuk-frontend/src/govuk/govuk-frontend-component.mjs b/packages/govuk-frontend/src/govuk/govuk-frontend-component.mjs index 2e389ddd9a..d67af87b28 100644 --- a/packages/govuk-frontend/src/govuk/govuk-frontend-component.mjs +++ b/packages/govuk-frontend/src/govuk/govuk-frontend-component.mjs @@ -1,5 +1,12 @@ +import Config from './common/config.mjs' import { isInitialised, isSupported } from './common/index.mjs' -import { ElementError, InitError, SupportError } from './errors/index.mjs' +import { normaliseDataset } from './common/normalise-dataset.mjs' +import { + ConfigError, + ElementError, + InitError, + SupportError +} from './errors/index.mjs' /** * Base Component class @@ -103,9 +110,98 @@ export class GOVUKFrontendComponent { } } +/** + * Base Component class + * + * Centralises the behaviours shared by our components + * + * @virtual + * @template {import('./common/index.mjs').ObjectNested} [ConfigurationType={}] + * @template {Element} [RootElementType=HTMLElement] + * @augments GOVUKFrontendComponent + */ +export class GOVUKFrontendComponentConfigurable extends GOVUKFrontendComponent { + /** + * Returns the root element of the component + * + * @protected + * @returns {Config & ConfigurationType} - the root element of component + */ + get config() { + return this._config + } + + /** + * + * @type {Config & ConfigurationType} + */ + _config + + /** + * Constructs a new component, validating that GOV.UK Frontend is supported + * + * @internal + * @param {Element | null} [$root] - HTML element to use for component + * @param {...ConfigurationType} config - HTML element to use for component + */ + constructor($root, ...config) { + super($root) + + const childConstructor = /** @type {ChildClassConstructor} */ ( + this.constructor + ) + + if (typeof childConstructor.schema === 'undefined') { + throw new ConfigError( + 'Config passed as parameter into constructor but no schema defined' + ) + } + + if (typeof childConstructor.defaults === 'undefined') { + throw new ConfigError( + 'Config passed as parameter into constructor but no defaults defined' + ) + } + + const childConstructorWithConfig = + /** @type {ChildClassConstructorWithConfig} */ (this.constructor) + + this._config = + /** @type {Config & ConfigurationType} */ ( + new Config( + childConstructorWithConfig.defaults, + ...config, + normaliseDataset( + childConstructorWithConfig, + // dataset isnt obtainable on Element + // but what the user will actual pass as $root + // will have dataset defined because it will be + // a class that extends Element and implements dataset? + /** @type {{ dataset: DOMStringMap }} */ ( + /** @type {unknown} */ (this.$root) + ).dataset + ) + ) + ) + } +} + /** * @typedef ChildClass * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component + * @property {import('./common/index.mjs').Schema} [schema] - The module name that'll be looked for in the DOM when initialising the component + * @property {import('./common/index.mjs').ObjectNested} [defaults] - The default configuration if not passed by parameter + */ + +/** + * @typedef ChildClassWithConfig + * @property {string} moduleName - The module name that'll be looked for in the DOM when initialising the component + * @property {import('./common/index.mjs').Schema} schema - The module name that'll be looked for in the DOM when initialising the component + * @property {import('./common/index.mjs').ObjectNested} defaults - The default configuration if not passed by parameter + */ + +/** + * @typedef {typeof GOVUKFrontendComponent & ChildClassWithConfig} ChildClassConstructorWithConfig */ /**