diff --git a/change/@ni-nimble-components-df093e6a-7cba-46ed-8d5d-07670af90719.json b/change/@ni-nimble-components-df093e6a-7cba-46ed-8d5d-07670af90719.json new file mode 100644 index 0000000000..ab0dc00aad --- /dev/null +++ b/change/@ni-nimble-components-df093e6a-7cba-46ed-8d5d-07670af90719.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add locale support to theme provider and use in date-text column", + "packageName": "@ni/nimble-components", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-components/src/table-column/base/types.ts b/packages/nimble-components/src/table-column/base/types.ts index 729f3c67cb..41a9d5d7a3 100644 --- a/packages/nimble-components/src/table-column/base/types.ts +++ b/packages/nimble-components/src/table-column/base/types.ts @@ -1,4 +1,5 @@ -import type { TableRecord, ValidityObject } from '../../table/types'; +import type { TableRecord } from '../../table/types'; +import type { ValidityObject } from '../../utilities/models/validator'; /** * An object whose fields are defined by a particular TableColumn, which is used by the column's diff --git a/packages/nimble-components/src/table-column/date-text/index.ts b/packages/nimble-components/src/table-column/date-text/index.ts index 25d046a9d3..4add8aa016 100644 --- a/packages/nimble-components/src/table-column/date-text/index.ts +++ b/packages/nimble-components/src/table-column/date-text/index.ts @@ -1,4 +1,7 @@ -import { DesignSystem } from '@microsoft/fast-foundation'; +import { + DesignSystem, + DesignTokenSubscriber +} from '@microsoft/fast-foundation'; import { attr } from '@microsoft/fast-element'; import { styles } from '../base/styles'; import { template } from '../base/template'; @@ -27,6 +30,7 @@ import { WeekdayFormat } from './types'; import { TableColumnDateTextValidator } from './models/table-column-date-text-validator'; +import { lang } from '../../theme-provider'; import { optionalBooleanConverter } from '../../utilities/models/converter'; export type TableColumnDateTextCellRecord = TableNumberField<'value'>; @@ -110,11 +114,23 @@ export class TableColumnDateText extends TableColumnTextBase { @attr({ attribute: 'custom-hour-cycle' }) public customHourCycle: HourCycleFormat; + private readonly langSubscriber: DesignTokenSubscriber = { + handleChange: () => { + this.updateColumnConfig(); + } + }; + public override connectedCallback(): void { super.connectedCallback(); + lang.subscribe(this.langSubscriber, this); this.updateColumnConfig(); } + public override disconnectedCallback(): void { + super.disconnectedCallback(); + lang.unsubscribe(this.langSubscriber, this); + } + public override get validity(): TableColumnValidity { return this.validator.getValidity(); } @@ -235,7 +251,7 @@ export class TableColumnDateText extends TableColumnTextBase { options = this.getCustomFormattingOptions(); } try { - return new Intl.DateTimeFormat(undefined, options); + return new Intl.DateTimeFormat(lang.getValueFor(this), options); } catch (e) { return undefined; } diff --git a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts index f8d0cec2f3..f5b8b12ae6 100644 --- a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts +++ b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.spec.ts @@ -7,6 +7,7 @@ import type { TableRecord } from '../../../table/types'; import { TablePageObject } from '../../../table/testing/table.pageobject'; import { TableColumnDateTextPageObject } from '../testing/table-column-date-text.pageobject'; import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized'; +import { lang, themeProviderTag } from '../../../theme-provider'; interface SimpleTableRecord extends TableRecord { field?: number | null; @@ -23,6 +24,8 @@ describe('TableColumnDateText', () => { // prettier-ignore async function setup(): Promise>> { + const themeProvider = document.createElement(themeProviderTag); + themeProvider.lang = 'en-US'; return fixture>( html` <${tableColumnDateTextTag} field-name="field" group-index="0"> @@ -31,7 +34,10 @@ describe('TableColumnDateText', () => { <${tableColumnDateTextTag} field-name="anotherField"> Squeeze Column 1 - ` + `, + { + parent: themeProvider + } ); } @@ -246,6 +252,21 @@ describe('TableColumnDateText', () => { expect(pageObject.getRenderedCellContent(0, 0)).toBe('12/10/2012'); }); + it('updates displayed date when lang token changes', async () => { + await element.setData([ + { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } + ]); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + 'Dec 10, 2012, 10:35:05 PM' + ); + lang.setValueFor(element, 'fr'); + await waitForUpdatesAsync(); + expect(pageObject.getRenderedCellContent(0, 0)).toBe( + '10 déc. 2012, 22:35:05' + ); + }); + it('honors customDateStyle property', async () => { await element.setData([ { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } @@ -465,7 +486,7 @@ describe('TableColumnDateText', () => { expect(column.validity.invalidCustomOptionsCombination).toBeFalse(); }); - it('sets invalid flag on column when custom options are incompatible', async () => { + it('sets invalidCustomOptionsCombination flag on column when custom options are incompatible', async () => { await element.setData([ { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } ]); @@ -477,7 +498,7 @@ describe('TableColumnDateText', () => { expect(column.validity.invalidCustomOptionsCombination).toBeTrue(); }); - it('clears invalid flag on column after fixing custom option incompatibility', async () => { + it('clears invalidCustomOptionsCombination flag on column after fixing custom option incompatibility', async () => { await element.setData([ { field: new Date('Dec 10, 2012, 10:35:05 PM').valueOf() } ]); diff --git a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts index a8b81c26f9..ddb22cf60b 100644 --- a/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts +++ b/packages/nimble-components/src/table-column/date-text/tests/table-column-date-text.stories.ts @@ -106,7 +106,7 @@ interface TextColumnTableArgs extends SharedTableArgs { validity: () => void; } -const dateTextColumnDescription = 'The `nimble-table-column-date-text` column is used to display date-time fields as text in the `nimble-table`. The date-time values must be of type `number` and represent the number of milliseconds since January 1, 1970 UTC. This is the representation used by the [JavaScript `Date` type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date).'; +const dateTextColumnDescription = 'The `nimble-table-column-date-text` column is used to display date-time fields as text in the `nimble-table`. The date-time values must be of type `number` and represent the number of milliseconds since January 1, 1970 UTC. This is the representation used by the [JavaScript `Date` type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date). Dates are formatted in a locale-specific way based on the value of the `lang` token, which can be set via the [`nimble-theme-provider`](?path=/docs/tokens-theme-provider--docs).'; const validityDescription = `Readonly object of boolean values that represents the validity states that the column's configuration can be in. The object's type is \`TableColumnValidity\`, and it contains the following boolean properties: diff --git a/packages/nimble-components/src/table/types.ts b/packages/nimble-components/src/table/types.ts index 517175611e..2976a97a24 100644 --- a/packages/nimble-components/src/table/types.ts +++ b/packages/nimble-components/src/table/types.ts @@ -1,4 +1,5 @@ import type { TableColumn } from '../table-column/base'; +import type { ValidityObject } from '../utilities/models/validator'; /** * TableFieldName describes the type associated with keys within @@ -51,10 +52,6 @@ export type TableNumberField = { [name in FieldName]: TableNumberFieldValue; }; -export interface ValidityObject { - [key: string]: boolean; -} - export interface TableValidity extends ValidityObject { readonly duplicateRecordId: boolean; readonly missingRecordId: boolean; diff --git a/packages/nimble-components/src/theme-provider/index.ts b/packages/nimble-components/src/theme-provider/index.ts index 846fd538c7..c1f49f87b0 100644 --- a/packages/nimble-components/src/theme-provider/index.ts +++ b/packages/nimble-components/src/theme-provider/index.ts @@ -8,6 +8,10 @@ import { Direction } from '@microsoft/fast-web-utilities'; import { template } from './template'; import { styles } from './styles'; import { Theme } from './types'; +import { documentElementLang } from '../utilities/models/document-element-lang'; +import type { ValidityObject } from '../utilities/models/validator'; + +export { Direction }; declare global { interface HTMLElementTagNameMap { @@ -15,6 +19,22 @@ declare global { } } +function isValidLang(value: string): boolean { + try { + // We are relying on the Locale constructor to validate the value + // eslint-disable-next-line no-new + new Intl.Locale(value); + return true; + } catch (e) { + return false; + } +} + +export const lang = DesignToken.create({ + name: 'lang', + cssCustomPropertyName: null +}).withDefault((): string => (isValidLang(documentElementLang.lang) ? documentElementLang.lang : 'en-US')); + // Not represented as a CSS Custom Property, instead available // as an attribute of theme provider. export const direction = DesignToken.create({ @@ -33,19 +53,49 @@ export const theme = DesignToken.create({ * @internal */ export class ThemeProvider extends FoundationElement { - @attr({ - attribute: 'direction' - }) - public direction: Direction = Direction.ltr; - - @attr({ - attribute: 'theme' - }) + @attr() + public override lang!: string; + + @attr() + public direction?: Direction; + + @attr() public theme: Theme = Theme.light; + public get validity(): ValidityObject { + return { + invalidLang: this.langIsInvalid + }; + } + + private langIsInvalid = false; + + public checkValidity(): boolean { + return !this.langIsInvalid; + } + + public langChanged( + _prev: string | undefined | null, + next: string | undefined | null + ): void { + if (next === null || next === undefined) { + lang.deleteValueFor(this); + this.langIsInvalid = false; + return; + } + + if (isValidLang(next)) { + lang.setValueFor(this, next); + this.langIsInvalid = false; + } else { + lang.deleteValueFor(this); + this.langIsInvalid = true; + } + } + public directionChanged( - _prev: Direction | undefined, - next: Direction | undefined + _prev: Direction | undefined | null, + next: Direction | undefined | null ): void { if (next !== undefined && next !== null) { direction.setValueFor(this, next); @@ -55,8 +105,8 @@ export class ThemeProvider extends FoundationElement { } public themeChanged( - _prev: Theme | undefined, - next: Theme | undefined + _prev: Theme | undefined | null, + next: Theme | undefined | null ): void { if (next !== undefined && next !== null) { theme.setValueFor(this, next); diff --git a/packages/nimble-components/src/theme-provider/tests/theme-provider.mdx b/packages/nimble-components/src/theme-provider/tests/theme-provider.mdx new file mode 100644 index 0000000000..adfbbc4158 --- /dev/null +++ b/packages/nimble-components/src/theme-provider/tests/theme-provider.mdx @@ -0,0 +1,10 @@ +import { Controls, DocsStory, Meta, Title } from '@storybook/blocks'; +import { themeProvider } from './theme-provider.stories'; + + +Theme Provider + +The theme provider element allows configuring certain token values for the contained HTML tree. + + + diff --git a/packages/nimble-components/src/theme-provider/tests/theme-provider.spec.ts b/packages/nimble-components/src/theme-provider/tests/theme-provider.spec.ts index aa550962e0..d9add6ab2b 100644 --- a/packages/nimble-components/src/theme-provider/tests/theme-provider.spec.ts +++ b/packages/nimble-components/src/theme-provider/tests/theme-provider.spec.ts @@ -1,8 +1,11 @@ +import { html } from '@microsoft/fast-element'; import { spinalCase } from '@microsoft/fast-web-utilities'; import * as designTokensNamespace from '../design-tokens'; import { tokenNames, suffixFromTokenName } from '../design-token-names'; import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; -import { ThemeProvider } from '..'; +import { ThemeProvider, lang, themeProviderTag } from '..'; +import { waitForUpdatesAsync } from '../../testing/async-helpers'; +import { fixture, type Fixture } from '../../utilities/tests/fixture'; type DesignTokenPropertyName = keyof typeof designTokensNamespace; const designTokenPropertyNames = Object.keys( @@ -16,6 +19,125 @@ describe('Theme Provider', () => { ); }); + describe('lang token', () => { + async function setup( + langValue: string | undefined + ): Promise> { + return fixture( + html`<${themeProviderTag} ${ + langValue === undefined ? '' : `lang="${langValue}"` + }> + ` + ); + } + + let element: ThemeProvider; + let connect: () => Promise; + let disconnect: () => Promise; + let pageLangToRestore: string | null; + + beforeEach(() => { + pageLangToRestore = document.documentElement.getAttribute('lang'); + }); + + afterEach(async () => { + await disconnect(); + if (pageLangToRestore === null) { + document.documentElement.removeAttribute('lang'); + } else { + document.documentElement.setAttribute( + 'lang', + pageLangToRestore + ); + } + }); + + it('value is set to "fr-FR" when theme provider lang attribute is assigned value "fr-FR"', async () => { + ({ element, connect, disconnect } = await setup('fr-FR')); + await connect(); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('fr-FR'); + }); + + it('value defaults to page lang when theme provider lang attribute is removed', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup('fr-FR')); + await connect(); + element.removeAttribute('lang'); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('de-DE'); + }); + + it('value defaults to page lang when theme provider lang attribute is undefined', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup(undefined)); + await connect(); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('de-DE'); + expect(element.validity.invalidLang).toBeFalse(); + }); + + it('value defaults to page lang when theme provider lang attribute is empty string', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup('')); + await connect(); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('de-DE'); + expect(element.validity.invalidLang).toBeTrue(); + }); + + it('value defaults to page lang when theme provider lang attribute is malformed', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup('123')); + await connect(); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('de-DE'); + expect(element.validity.invalidLang).toBeTrue(); + }); + + it('value updates when page lang changes (while theme provider lang unset)', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup(undefined)); + await connect(); + await waitForUpdatesAsync(); + document.documentElement.lang = 'fr-FR'; + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('fr-FR'); + expect(element.validity.invalidLang).toBeFalse(); + }); + + it('value updates when theme provider lang attribute goes from invalid to valid', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup('123')); + await connect(); + await waitForUpdatesAsync(); + element.setAttribute('lang', 'fr-CA'); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('fr-CA'); + expect(element.validity.invalidLang).toBeFalse(); + }); + + it('value updates when theme provider lang attribute goes from valid to invalid', async () => { + document.documentElement.lang = 'de-DE'; + ({ element, connect, disconnect } = await setup('fr-FR')); + await connect(); + await waitForUpdatesAsync(); + element.setAttribute('lang', '123'); + await waitForUpdatesAsync(); + expect(lang.getValueFor(element)).toBe('de-DE'); + expect(element.validity.invalidLang).toBeTrue(); + }); + + it('value defaults to system default (en-US) when page lang is malformed and theme provider lang is malformed', async () => { + document.documentElement.lang = '123'; + ({ element, connect, disconnect } = await setup('456')); + await connect(); + await waitForUpdatesAsync(); + // our karma config and GitHub actions config set the lang to en-US + expect(lang.getValueFor(element)).toBe('en-US'); + }); + }); + describe('design token should match CSSDesignToken', () => { const tokenEntries = designTokenPropertyNames.map( (name: DesignTokenPropertyName) => ({ diff --git a/packages/nimble-components/src/theme-provider/tests/theme-provider.stories.ts b/packages/nimble-components/src/theme-provider/tests/theme-provider.stories.ts new file mode 100644 index 0000000000..1082092e53 --- /dev/null +++ b/packages/nimble-components/src/theme-provider/tests/theme-provider.stories.ts @@ -0,0 +1,117 @@ +import { html, ref } from '@microsoft/fast-element'; +import type { Meta, StoryObj } from '@storybook/html'; +import { createUserSelectedThemeStory } from '../../utilities/tests/storybook'; +import { tableTag } from '../../table'; +import { tableColumnDateTextTag } from '../../table-column/date-text'; +import { + sharedTableArgTypes, + type SharedTableArgs, + sharedTableArgs +} from '../../table-column/base/tests/table-column-stories-utils'; +import { Theme } from '../types'; +import { Direction, themeProviderTag } from '..'; + +const simpleData = [ + { + date: new Date(1984, 4, 12, 14, 34, 19, 377).valueOf() + }, + { + date: new Date(1984, 2, 19, 7, 6, 48, 584).valueOf() + }, + { + date: new Date(2013, 3, 1, 20, 4, 37, 975).valueOf() + }, + { + date: new Date(2022, 0, 12, 20, 4, 37, 975).valueOf() + } +]; + +const metadata: Meta = { + title: 'Tokens/Theme Provider', + parameters: { + docs: { + description: { + component: '' + } + } + }, + argTypes: { + ...sharedTableArgTypes, + selectionMode: { + table: { + disable: true + } + } + }, + args: { + ...sharedTableArgs(simpleData) + } +}; + +export default metadata; + +const langDescription = `Defines the language of the element. See [external documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang) for details. + +Applications should set \`lang\` on the root \`html\` element of the page to reflect the language of the content. If necessary, users may override the language for a subtree by inserting a \`nimble-theme-provider\` element and setting its \`lang\` attribute. Nimble elements will not honor a \`lang\` value set on any other type of ancestor element.`; + +interface ThemeProviderArgs extends SharedTableArgs { + theme: keyof typeof Theme; + lang: string; + direction: keyof typeof Direction; +} + +export const themeProvider: StoryObj = { + render: createUserSelectedThemeStory(html` + <${themeProviderTag} + theme="${x => Theme[x.theme]}" + lang="${x => x.lang}" + direction="${x => Direction[x.direction]}" + > + <${tableTag} + ${ref('tableRef')} + data-unused="${x => x.updateData(x)}" + style="height: 200px" + > + <${tableColumnDateTextTag} + field-name="date" + > + Date + + + + `), + argTypes: { + theme: { + description: + 'The display theme to use. One of `"light"`, `"dark"`, or `"color"`. The `Theme` type exposes these values.', + defaultValue: { + summary: '"light"' + }, + options: Object.keys(Theme), + control: { type: 'radio' } + }, + lang: { + description: langDescription, + defaultValue: { + summary: + '`lang` of the document element if set, otherwise "en-US".' + }, + options: ['en-US', 'fr-FR', 'de-DE'], + control: { type: 'radio' } + }, + direction: { + description: + 'The text direction of the element. Either `"ltr"` or `"rtl"`. The `Direction` type exposes these values. Note: Right-to-left support in Nimble is untested. If you need this capability, please file an issue.', + defaultValue: { + summary: '"ltr"' + }, + options: Object.keys(Direction), + control: { type: 'radio' } + } + }, + args: { + theme: Theme.light, + lang: 'en-US', + direction: Direction.ltr + } +}; diff --git a/packages/nimble-components/src/utilities/models/document-element-lang.ts b/packages/nimble-components/src/utilities/models/document-element-lang.ts new file mode 100644 index 0000000000..a0451f1ded --- /dev/null +++ b/packages/nimble-components/src/utilities/models/document-element-lang.ts @@ -0,0 +1,27 @@ +import { observable } from '@microsoft/fast-element'; + +/** + * Observable class to subscribe to changes in the page's lang attribute + */ +class DocumentElementLang { + @observable + public lang: string = document.documentElement.lang; + + public constructor() { + const observer = new MutationObserver(mutations => { + for (const mutation of mutations) { + if ( + mutation.type === 'attributes' + && mutation.attributeName === 'lang' + ) { + this.lang = (mutation.target as HTMLElement).lang; + } + } + }); + observer.observe(document.documentElement, { + attributeFilter: ['lang'] + }); + } +} + +export const documentElementLang = new DocumentElementLang(); diff --git a/packages/nimble-components/src/utilities/models/validator.ts b/packages/nimble-components/src/utilities/models/validator.ts index e4546336a5..8cc8489cba 100644 --- a/packages/nimble-components/src/utilities/models/validator.ts +++ b/packages/nimble-components/src/utilities/models/validator.ts @@ -1,6 +1,9 @@ -import type { ValidityObject } from '../../table/types'; import { Tracker } from './tracker'; +export interface ValidityObject { + [key: string]: boolean; +} + /** * Generic Validator Utility extends Tracker Utility for validation purposes */