Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spike into Config class if defined by GOVUKFrontendComponentConfigurable #5430

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions packages/govuk-frontend/src/govuk/common/config.mjs
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion packages/govuk-frontend/src/govuk/common/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -17,14 +18,9 @@ import { I18n } from '../../i18n.mjs'
* attribute, which also provides accessibility.
*
* @preserve
* @augments GOVUKFrontendComponentConfigurable<AccordionConfig>
*/
export class Accordion extends GOVUKFrontendComponent {
/**
* @private
* @type {AccordionConfig}
*/
config

export class Accordion extends GOVUKFrontendComponentConfigurable {
/** @private */
i18n

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -618,6 +608,57 @@ export class Accordion extends GOVUKFrontendComponent {
})
}


/**
* Accordion Config Class
*
* @augments Config<AccordionConfig>
*/
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
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ export class CharacterCount extends GOVUKFrontendComponent {
/** @private */
i18n

/** @private */
maxLength

/**
Expand Down
98 changes: 97 additions & 1 deletion packages/govuk-frontend/src/govuk/govuk-frontend-component.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<RootElementType>
*/
export class GOVUKFrontendComponentConfigurable extends GOVUKFrontendComponent {
/**
* Returns the root element of the component
*
* @protected
* @returns {Config<ConfigurationType> & ConfigurationType} - the root element of component
*/
get config() {
return this._config
}

/**
*
* @type {Config<ConfigurationType> & 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> & 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
*/

/**
Expand Down
Loading