diff --git a/src/components/cc-zone-card/cc-zone-card.js b/src/components/cc-zone-card/cc-zone-card.js new file mode 100644 index 000000000..8c4ac1e66 --- /dev/null +++ b/src/components/cc-zone-card/cc-zone-card.js @@ -0,0 +1,184 @@ +import { css, html, LitElement } from 'lit'; +import { iconRemixCheckboxCircleFill as selectedIcon } from '../../assets/cc-remix.icons.js'; +import { i18n } from '../../translations/translation.js'; +import '../cc-icon/cc-icon.js'; +import '../cc-img/cc-img.js'; + +/** + * @typedef {import('./cc-zone-card.types.js').ZoneImage} ZoneImage + */ + +/** + * A component displaying a card with relative zone information (name, code...) + * + * @cssdisplay flex + */ +export class CcZoneCard extends LitElement { + static get properties() { + return { + code: { type: String }, + country: { type: String }, + countryCode: { type: String, attribute: 'country-code' }, + disabled: { type: Boolean, reflect: true }, + flagUrl: { type: String, attribute: 'flag-url' }, + images: { type: Array }, + name: { type: String }, + selected: { type: Boolean, reflect: true }, + }; + } + + constructor() { + super(); + + /** @type {string} The zone code */ + this.code = null; + + /** @type {string} The country name */ + this.country = null; + + /** @type {string} The ISO 3166-1 alpha-2 country code (e.g: FR) */ + this.countryCode = null; + + /** @type {boolean} Whether the card should be disabled */ + this.disabled = false; + + /** @type {string} The url of the flag image */ + this.flagUrl = null; + + /** @type {ZoneImage[]} A list of images that will displayed in the footer */ + this.images = []; + + /** @type {string} The name of the zone */ + this.name = null; + + /** @type {boolean} Whether the card should be selected */ + this.selected = false; + } + + render() { + return html` +
+
${this.code}
+
${this.name}
+
+ +
+ ${this.flagUrl + ? html` + + ` + : ''} + ${this.images.map((image) => html``)} +
+ `; + } + + static get styles() { + return [ + // language=CSS + css` + :host { + border: 2px solid var(--cc-color-border-neutral); + border-radius: var(--cc-border-radius-default); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + } + + .title { + flex: 1 1 auto; + padding: 1em 1em 0.75em; + } + + :host(:hover:not([disabled])) { + border-color: var(--cc-color-border-neutral-hovered); + } + + :host(:not([selected], [disabled])) { + cursor: pointer; + } + + :host([selected]) { + border-color: var(--cc-color-bg-primary); + } + + :host([selected]) .title .zone-code { + color: var(--cc-color-text-primary-strong); + } + + :host([selected]) .title .zone-name { + color: var(--cc-color-text-primary-strongest); + } + + :host([selected]) .icon-selected { + display: block; + } + + :host([selected]) .thumbnails { + background-color: var(--cc-color-bg-primary-weak); + } + + :host([disabled]) { + border-color: var(--cc-color-border-neutral-disabled); + opacity: var(--cc-opacity-when-disabled); + } + + :host([disabled]) .zone-code { + color: #fff; + } + + :host([disabled]) .thumbnails cc-icon, + :host([disabled]) .thumbnails cc-img { + filter: grayscale(1); + } + + .zone-code { + color: var(--cc-color-text-weak); + font-size: 0.875em; + line-height: 1.125; + padding-inline-start: 0.125em; + } + + .zone-name { + font-size: 1.5em; + line-height: 1.125; + } + + .icon-selected { + --cc-icon-color: var(--cc-color-bg-primary); + + display: none; + position: absolute; + right: 0.5em; + top: 0.5em; + } + + .thumbnails { + align-items: center; + background-color: var(--cc-color-bg-neutral); + display: inline-flex; + flex: 0 0 auto; + gap: 0.5em; + padding: 0.75em 1.125em; + } + + .thumbnails > cc-img { + --cc-img-fit: contain; + + height: 1.5em; + width: 1.5em; + } + `, + ]; + } +} + +window.customElements.define('cc-zone-card', CcZoneCard); diff --git a/src/components/cc-zone-card/cc-zone-card.stories.js b/src/components/cc-zone-card/cc-zone-card.stories.js new file mode 100644 index 000000000..fb28d72eb --- /dev/null +++ b/src/components/cc-zone-card/cc-zone-card.stories.js @@ -0,0 +1,102 @@ +import { getFlagUrl } from '../../lib/remote-assets.js'; +import { makeStory } from '../../stories/lib/make-story.js'; +import './cc-zone-card.js'; + +export default { + tags: ['autodocs'], + title: 'đź›  Creation Tunnel/', + component: 'cc-zone-card', +}; + +const cleverIcon = new URL('../../stories/assets/clevercloud.svg', import.meta.url); + +const conf = { + component: 'cc-zone-card', +}; + +/** + * @typedef {import('./cc-zone-card.js').CcZoneCard} CcZoneCard + */ + +/** @type {Partial} */ +const DEFAULT_ITEM = { + code: 'par', + name: 'Paris', + selected: false, + images: [{ url: cleverIcon, alt: 'infra: Clever Cloud' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', +}; + +/** @type {Partial} */ +const LONG_CODE = { + code: 'very-very-very-very-very-very-very-long-code', + name: 'Zone', + selected: false, + images: [{ url: cleverIcon, alt: 'infra: Clever Cloud' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', +}; + +/** @type {Partial} */ +const LONG_NAME = { + code: 'code', + name: 'very-very-very-very-very-very-very-long-name', + selected: false, + images: [{ url: cleverIcon, alt: 'infra: Clever Cloud' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', +}; + +/** @type {Partial} */ +const LONG_CODE_AND_NAME = { + code: 'very-very-very-very-very-very-very-long-code', + name: 'very-very-very-very-very-very-very-long-name', + selected: false, + images: [{ url: cleverIcon, alt: 'infra: Clever Cloud' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', +}; + +export const defaultStory = makeStory(conf, { + items: [DEFAULT_ITEM], +}); + +export const disabled = makeStory(conf, { + items: [{ ...DEFAULT_ITEM, disabled: true }], +}); + +export const selected = makeStory(conf, { + items: [{ ...DEFAULT_ITEM, selected: true }], +}); + +export const longCode = makeStory(conf, { + items: [ + { ...LONG_CODE, style: 'width: 10em' }, + { ...LONG_CODE, style: 'width: 20em' }, + { ...LONG_CODE, style: 'width: 30em' }, + LONG_CODE, + ], +}); + +export const longName = makeStory(conf, { + items: [ + { ...LONG_NAME, style: 'width: 10em' }, + { ...LONG_NAME, style: 'width: 20em' }, + { ...LONG_NAME, style: 'width: 30em' }, + LONG_NAME, + ], +}); + +export const longCodeAndLongName = makeStory(conf, { + items: [ + { ...LONG_CODE_AND_NAME, style: 'width: 10em' }, + { ...LONG_CODE_AND_NAME, style: 'width: 20em' }, + { ...LONG_CODE_AND_NAME, style: 'width: 30em' }, + LONG_CODE_AND_NAME, + ], +}); diff --git a/src/components/cc-zone-card/cc-zone-card.test.js b/src/components/cc-zone-card/cc-zone-card.test.js new file mode 100644 index 000000000..6999a7014 --- /dev/null +++ b/src/components/cc-zone-card/cc-zone-card.test.js @@ -0,0 +1,11 @@ +/* eslint-env node, mocha */ + +import { testAccessibility } from '../../../test/helpers/accessibility.js'; +import { getStories } from '../../../test/helpers/get-stories.js'; +import * as storiesModule from './cc-zone-card.stories.js'; + +const storiesToTest = getStories(storiesModule); + +describe(`Component: ${storiesModule.default.component}`, function () { + testAccessibility(storiesToTest); +}); diff --git a/src/components/cc-zone-card/cc-zone-card.types.d.ts b/src/components/cc-zone-card/cc-zone-card.types.d.ts new file mode 100644 index 000000000..a0cc62ccf --- /dev/null +++ b/src/components/cc-zone-card/cc-zone-card.types.d.ts @@ -0,0 +1,4 @@ +export interface ZoneImage { + url: string | URL; + alt: string; +} diff --git a/src/components/cc-zone-picker/cc-zone-picker.js b/src/components/cc-zone-picker/cc-zone-picker.js new file mode 100644 index 000000000..f71472695 --- /dev/null +++ b/src/components/cc-zone-picker/cc-zone-picker.js @@ -0,0 +1,184 @@ +import { css, html } from 'lit'; +import { CcFormControlElement } from '../../lib/form/cc-form-control-element.abstract.js'; +import { accessibilityStyles } from '../../styles/accessibility.js'; +import { i18n } from '../../translations/translation.js'; +import '../cc-zone-card/cc-zone-card.js'; + +import { ifDefined } from 'lit/directives/if-defined.js'; +import { iconRemixEarthLine as zoneIcon } from '../../assets/cc-remix.icons.js'; +import { dispatchCustomEvent } from '../../lib/events.js'; + +/** + * @typedef {import('./cc-zone-picker.types.js').ZoneItem} ZoneItem + * @typedef {import('./cc-zone-picker.types.js').ZoneSection} ZoneSection + * @typedef {import('./cc-zone-picker.types.js').SingleZoneSection} SingleZoneSection + * @typedef {import('./cc-zone-picker.types.js').ZonesSections} ZonesSections + * @typedef {import('../../lib/events.types.js').EventWithTarget} HTMLInputElementEvent + */ + +/** + * A component that allows you to select a zone from a list of zones sections. + * + * @cssdisplay block + * + * @fires {CustomEvent} cc-zone-picker:input - Fires the zone code when a zone has been selected. + */ +export class CcZonePicker extends CcFormControlElement { + static get properties() { + return { + ...super.properties, + value: { type: String }, + zonesSections: { type: Array, attribute: 'zones-sections' }, + }; + } + + constructor() { + super(); + + /** @type {ZonesSections} array of zones sections */ + this.zonesSections = []; + + /** @type {string} current selected zone code */ + this.value = null; + } + + /** + * @param {HTMLInputElementEvent} e + */ + _onZoneSelect(e) { + this.value = e.target.value; + dispatchCustomEvent(this, 'cc-zone-picker:input', e.target.value); + } + + render() { + return html` +
+ + + ${i18n('cc-zone-picker.legend')} + +
+ ${this.zonesSections.map((zoneSection, index) => this._renderZoneSection(zoneSection, index))} +
+
+ `; + } + + /** + * @param {ZoneSection|SingleZoneSection} zoneSection + * @param {number} index + */ + _renderZoneSection(zoneSection, index) { + const hasZoneSectionHeaderTitle = zoneSection != null && 'title' in zoneSection; + const zoneSectionHeaderId = hasZoneSectionHeaderTitle ? `section-header-${index}` : null; + return html` + ${hasZoneSectionHeaderTitle + ? html`
${zoneSection.title}
` + : ''} +
+ ${zoneSection.zones.map((zone) => this._renderZoneCard(zone, zone.code === this.value, zoneSectionHeaderId))} +
+ `; + } + + /** + * @param {ZoneItem} zone + * @param {boolean} isZoneSelected + * @param {string} zoneSectionHeaderId + */ + _renderZoneCard(zone, isZoneSelected, zoneSectionHeaderId) { + return html` + + + `; + } + + static get styles() { + return [ + accessibilityStyles, + // language=CSS + css` + :host { + display: block; + /* We have to use px value because we change font-size value and we need the same margin for every other elements */ + --fixed-margin: 34px; + } + + legend { + display: flex; + gap: 0.25em; + } + + .form-controls { + display: grid; + gap: 1em; + grid-template-columns: repeat(auto-fill, minmax(12.5em, 1fr)); + margin-block-start: 0.5em; + margin-inline-start: var(--fixed-margin); + } + + .zone-legend-icon { + --cc-icon-color: var(--cc-color-text-primary); + + align-self: center; + } + + .zone-legend-text { + color: var(--cc-color-text-primary-strongest); + font-family: var(--cc-ff-form-legend), inherit; + font-size: 1.625em; + font-weight: 500; + } + + .zone-section-title { + color: var(--cc-color-text-primary-strongest); + font-family: var(--cc-ff-form-legend), inherit; + font-size: 1.15em; + margin-block-start: 1em; + margin-inline-start: var(--fixed-margin); + } + + .form-controls + .zone-section-title { + margin-block-start: 2em; + } + + fieldset { + border: none; + margin: 0; + padding: 0; + } + + cc-zone-card { + height: 100%; + } + + input[type='radio']:focus-visible + label cc-zone-card { + border-radius: var(--cc-border-radius-default, 0.25em); + outline: var(--cc-focus-outline, #000 solid 2px); + outline-offset: var(--cc-focus-outline-offset, 2px); + } + `, + ]; + } +} + +window.customElements.define('cc-zone-picker', CcZonePicker); diff --git a/src/components/cc-zone-picker/cc-zone-picker.stories.js b/src/components/cc-zone-picker/cc-zone-picker.stories.js new file mode 100644 index 000000000..b703d9fec --- /dev/null +++ b/src/components/cc-zone-picker/cc-zone-picker.stories.js @@ -0,0 +1,259 @@ +import { getFlagUrl } from '../../lib/remote-assets.js'; +import { makeStory } from '../../stories/lib/make-story.js'; +import './cc-zone-picker.js'; + +const cleverIcon = new URL('../../stories/assets/clevercloud.svg', import.meta.url); +const cloudTempleIcon = new URL('../../stories/assets/cloudtemple.svg', import.meta.url); +const oracleIcon = new URL('../../stories/assets/oracle.svg', import.meta.url); +const ovhIcon = new URL('../../stories/assets/ovh.svg', import.meta.url); +const scalewayIcon = new URL('../../stories/assets/scaleway.svg', import.meta.url); + +export default { + tags: ['autodocs'], + title: '🛠 Creation Tunnel/', + component: 'cc-zone-picker', +}; + +const conf = { + component: 'cc-zone-picker', + // language=CSS + css: ``, +}; + +/** + * @typedef {import('./cc-zone-picker.js').CcZonePicker} CcZonePicker + * @typedef {import('./cc-zone-picker.types.js').ZoneItem} ZoneItem + * @typedef {import('./cc-zone-picker.types.js').ZoneSection} ZoneSection + */ + +/** @type {ZoneItem[]} */ +const PUBLIC_ZONES = [ + { + code: 'scw', + name: 'Paris', + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', + images: [{ url: scalewayIcon, alt: 'infra' }], + disabled: false, + }, + { + code: 'sgp', + name: 'Singapore', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('SG'), + countryCode: 'SG', + country: 'Singapore', + }, + { + code: 'par', + name: 'Paris', + images: [{ url: cleverIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', + }, + { + code: 'grahds', + name: 'Gravelines', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', + }, + { + code: 'mtl', + name: 'Montreal', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('CA'), + countryCode: 'CA', + country: 'Canada', + }, + { + code: 'syd', + name: 'Sydney', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('AU'), + countryCode: 'AU', + country: 'Australia', + }, + { + code: 'rbx', + name: 'Roubaix', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', + }, + { + code: 'wsw', + name: 'Warsaw', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('PL'), + countryCode: 'PL', + country: 'Poland', + }, + { + code: 'rbxhds', + name: 'Roubaix', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', + }, + { + code: 'fr-north-hds', + name: 'North', + images: [{ url: ovhIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + countryCode: 'FR', + country: 'France', + }, +]; + +/** @type {ZoneItem[]} */ +const PRIVATE_ZONES = [ + { + code: 'foo-foobars', + name: 'Private MySQL Cluster', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'Foo', + name: 'City Member Lab', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'foo5', + name: 'testing environment', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'foobarz', + name: 'Foobarz', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'foozbar', + name: 'Chips Dale Sound City', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'foobazbabarzone', + name: 'Sleep Edge Abroad Bird Random', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'champion', + name: 'Private MongoDB Cluster', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'ichipsndales-postgresql-internal', + name: 'Private PostgreSQL Cluster', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'mainstream', + name: 'Sleep Edge Abroad Bird Matrix', + images: [], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, +]; + +export const defaultStory = makeStory(conf, { + /** @type {Array>} */ + items: [ + { + zonesSections: [ + { + zones: PUBLIC_ZONES, + }, + ], + value: 'par', + }, + ], +}); + +export const publicAndPrivateZones = makeStory(conf, { + /** @type {Array>} */ + items: [ + { + zonesSections: [ + { + title: 'Private zones', + zones: PRIVATE_ZONES, + }, + { + title: 'Public zones', + zones: PUBLIC_ZONES, + }, + ], + }, + ], +}); + +export const multipleSections = makeStory(conf, { + /** @type {Array>} */ + items: [ + { + zonesSections: [ + { + title: 'Private zones', + zones: PRIVATE_ZONES, + }, + { + title: 'Public zones', + zones: PUBLIC_ZONES, + }, + { + title: 'Other section', + zones: [ + { + code: 'other-zone', + name: 'Other Zone', + images: [{ url: oracleIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + { + code: 'other-zone-two', + name: 'Other Zone 2', + images: [{ url: cloudTempleIcon, alt: 'infra' }], + flagUrl: getFlagUrl('FR'), + country: 'France', + countryCode: 'FR', + }, + ], + }, + ], + }, + ], +}); diff --git a/src/components/cc-zone-picker/cc-zone-picker.test.js b/src/components/cc-zone-picker/cc-zone-picker.test.js new file mode 100644 index 000000000..b32fbe616 --- /dev/null +++ b/src/components/cc-zone-picker/cc-zone-picker.test.js @@ -0,0 +1,11 @@ +/* eslint-env node, mocha */ + +import { testAccessibility } from '../../../test/helpers/accessibility.js'; +import { getStories } from '../../../test/helpers/get-stories.js'; +import * as storiesModule from './cc-zone-picker.stories.js'; + +const storiesToTest = getStories(storiesModule); + +describe(`Component: ${storiesModule.default.component}`, function () { + testAccessibility(storiesToTest); +}); diff --git a/src/components/cc-zone-picker/cc-zone-picker.types.d.ts b/src/components/cc-zone-picker/cc-zone-picker.types.d.ts new file mode 100644 index 000000000..83b2e81a0 --- /dev/null +++ b/src/components/cc-zone-picker/cc-zone-picker.types.d.ts @@ -0,0 +1,23 @@ +import { ZoneImage } from '../cc-zone-card/cc-zone-card.types.js'; + +export interface ZoneItem { + code: string; + country: string; + countryCode: string; + name: string; + flagUrl: string; + images: Array; + disabled?: boolean; + selected?: boolean; +} + +export type ZonesSections = Array | [SingleZoneSection]; + +export interface ZoneSection { + title: string; + zones: Array; +} + +export interface SingleZoneSection { + zones: Array; +} diff --git a/src/stories/assets/clevercloud.svg b/src/stories/assets/clevercloud.svg new file mode 100644 index 000000000..8c6a8c0f6 --- /dev/null +++ b/src/stories/assets/clevercloud.svg @@ -0,0 +1,35 @@ + + diff --git a/src/stories/assets/cloudtemple.svg b/src/stories/assets/cloudtemple.svg new file mode 100644 index 000000000..f273b1d4d --- /dev/null +++ b/src/stories/assets/cloudtemple.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/stories/assets/oracle.svg b/src/stories/assets/oracle.svg new file mode 100644 index 000000000..1e41072ff --- /dev/null +++ b/src/stories/assets/oracle.svg @@ -0,0 +1 @@ +Oracle \ No newline at end of file diff --git a/src/stories/assets/ovh.svg b/src/stories/assets/ovh.svg new file mode 100644 index 000000000..053aa36f0 --- /dev/null +++ b/src/stories/assets/ovh.svg @@ -0,0 +1 @@ +OVH \ No newline at end of file diff --git a/src/stories/assets/scaleway.svg b/src/stories/assets/scaleway.svg new file mode 100644 index 000000000..2227f1be9 --- /dev/null +++ b/src/stories/assets/scaleway.svg @@ -0,0 +1 @@ +Scaleway \ No newline at end of file diff --git a/src/styles/default-theme.css b/src/styles/default-theme.css index 70339ede6..021de5b16 100644 --- a/src/styles/default-theme.css +++ b/src/styles/default-theme.css @@ -1,4 +1,5 @@ :root { + --cc-ff-form-legend: 'Source Sans 3', sans-serif; --cc-ff-monospace: 'SourceCodePro', 'monaco', monospace; /* All custom properties are sorted alphabetically within each region. */ diff --git a/src/translations/translations.en.js b/src/translations/translations.en.js index 0f33403e1..9c0bf5695 100644 --- a/src/translations/translations.en.js +++ b/src/translations/translations.en.js @@ -1284,8 +1284,15 @@ export const translations = { 'cc-zone.country': /** @param {{code: string, name: string}} _ */ ({ code, name }) => getCountryName(lang, code, name), //#endregion + //#region cc-zone-card + 'cc-zone-card.alt.country-name': /** @param {{code: string, name: string}} _ */ ({ code, name }) => + getCountryName(lang, code, name), + //#endregion //#region cc-zone-input 'cc-zone-input.error': `Something went wrong while loading zones.`, 'cc-zone-input.private-map-warning': `Private zones don't appear on the map.`, //#endregion + //#region cc-zone-picker + 'cc-zone-picker.legend': `Select your zone`, + //#endregion }; diff --git a/src/translations/translations.fr.js b/src/translations/translations.fr.js index 446fc0110..f52c51a01 100644 --- a/src/translations/translations.fr.js +++ b/src/translations/translations.fr.js @@ -1308,8 +1308,15 @@ export const translations = { 'cc-zone.country': /** @param {{code: string, name: string}} _ */ ({ code, name }) => getCountryName(lang, code, name), //#endregion + //#region cc-zone-card + 'cc-zone-card.alt.country-name': /** @param {{code: string, name: string}} _ */ ({ code, name }) => + getCountryName(lang, code, name), + //#endregion //#region cc-zone-input 'cc-zone-input.error': `Une erreur est survenue pendant le chargement des zones.`, 'cc-zone-input.private-map-warning': `Les zones privées n'apparaissent pas sur la carte.`, //#endregion + //#region cc-zone-picker + 'cc-zone-picker.legend': `Sélectionnez votre zone`, + //#endregion };