diff --git a/change/@ni-nimble-components-6eb6eef1-eec2-4f6d-a98f-f7182ecdad50.json b/change/@ni-nimble-components-6eb6eef1-eec2-4f6d-a98f-f7182ecdad50.json
new file mode 100644
index 0000000000..9fb64fece6
--- /dev/null
+++ b/change/@ni-nimble-components-6eb6eef1-eec2-4f6d-a98f-f7182ecdad50.json
@@ -0,0 +1,7 @@
+{
+ "type": "minor",
+ "comment": "Add nimble-label-provider-core and -table, along with localizable DesignTokens for labels/strings declared by Nimble components. Add docs for Localization.",
+ "packageName": "@ni/nimble-components",
+ "email": "20709258+msmithNI@users.noreply.github.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/nimble-components/CONTRIBUTING.md b/packages/nimble-components/CONTRIBUTING.md
index 5f1463d73e..6c619afd54 100644
--- a/packages/nimble-components/CONTRIBUTING.md
+++ b/packages/nimble-components/CONTRIBUTING.md
@@ -428,6 +428,28 @@ Nimble includes three NI-brand aligned themes (i.e. `light`, `dark`, & `color`).
When creating a new component, create a `*-matrix.stories.ts` Storybook file to confirm that the component reflects the design intent across all themes and states.
+## Localization
+
+Most user-visible strings displayed by Nimble components are provided by the client application and are expected to be localized by the application if necessary. However, some strings are built into Nimble components and are provided only in English. An application can provide localized versions of these strings by using design tokens set on label provider elements.
+
+There are currently 2 label providers:
+
+- `nimble-label-provider-core`: Used for labels for all components besides the table
+- `nimble-label-provider-table`: Used for labels for the table (and table sub-components / column types)
+
+The expected format for label token names is:
+
+- element/type(s) to which the token applies, e.g. `number-field` or `table`
+ - This may not be an exact element name, if this label applies to multiple elements or will be used in multiple contexts
+- component part/category (optional), e.g. `column-header`
+- specific functionality or sub-part, e.g. `decrement`
+- the suffix `label` (will be omitted from the label-provider properties/attributes)
+
+Components using localized labels should document them in Storybook. To add a "Localizable Labels" section:
+
+- Their story `Args` should extend `LabelUserArgs`
+- Call `addLabelUseMetadata()` and pass their declared metadata object, the applicable label provider tag, and the label tokens that they're using
+
## Component naming
Component custom element names are specified in `index.ts` when registering the element. Use the following structure when naming components.
diff --git a/packages/nimble-components/README.md b/packages/nimble-components/README.md
index 937c3db223..3529674cfa 100644
--- a/packages/nimble-components/README.md
+++ b/packages/nimble-components/README.md
@@ -23,8 +23,8 @@ If you have an existing application that incorporates a module bundler like [Web
1. Install the package from [the public NPM registry](https://www.npmjs.com/package/@ni/nimble-components) by running `npm install @ni/nimble-components`.
2. Import the component you want to use from the file you want to use it in. For example: `import '@ni/nimble-components/dist/esm/icons/succeeded';`
-3. Add the HTML for the component to your page. You can see sample code for each component in the [Nimble Storybook](https://ni.github.io/nimble/storybook/) by going to the **Docs** tab for the component and clicking **Show code**. For example: ``.
-4. Nimble components are [standard web components (custom elements)](https://developer.mozilla.org/en-US/docs/Web/Web_Components) so you can configure them via normal DOM APIs like attributes, properties, events, and methods. The [Storybook documentation](https://ni.github.io/nimble/storybook/) for each component describes its custom API.
+3. Add the HTML for the component to your page. You can see sample code for each component in the [Nimble Storybook](https://nimble.ni.dev/storybook/) by going to the **Docs** page for the component and clicking **Show code**. For example: ``.
+4. Nimble components are [standard web components (custom elements)](https://developer.mozilla.org/en-US/docs/Web/Web_Components) so you can configure them via normal DOM APIs like attributes, properties, events, and methods. The [Storybook documentation](https://nimble.ni.dev/storybook/) for each component describes its custom API.
### Prototyping in a static webpage
@@ -96,9 +96,33 @@ The theming system is composed of:
The goal of the Nimble design system is to provide a consistent style for applications. If you find that Nimble does not expose colors, fonts, sizes, etc. that you need in an application get in touch with the Nimble squad.
+## Localization
+
+Most user-visible strings displayed by Nimble components are provided by the client application and are expected to be localized by the application if necessary. However, some strings are built into Nimble components and are provided only in English. An application can provide localized versions of these strings by using design tokens set on label provider elements.
+
+There are currently 2 label providers:
+
+- `nimble-label-provider-core`: Used for labels for all components besides the table
+- `nimble-label-provider-table`: Used for labels for the table (and table sub-components / column types)
+
+If a client is localized, it should:
+
+- Add the `label-provider` element(s) as children of their root theme provider:
+ ```html
+
+
+
+
+
+
+
+
+ ```
+- For each label token on the label provider API, localize the English string, and set the corresponding HTML attribute or JS property on the label provider to the localized values. A list of all label tokens for each label provider (and their corresponding attribute/property names and English strings) can be found in the [Tokens/Label Providers section of Storybook](http://nimble.ni.dev/storybook/?path=/docs/tokens-label-providers--docs).
+
## Accessibility
-For accessibility information related to nimble components, see [ACCESSIBILITY.md](docs/ACCESSIBILITY.md).
+For accessibility information related to nimble components, see [accessibility.md](/packages/nimble-components/docs/accessibility.md).
## Contributing
diff --git a/packages/nimble-components/src/all-components.ts b/packages/nimble-components/src/all-components.ts
index d7bbd2d3b5..6e1b9677c4 100644
--- a/packages/nimble-components/src/all-components.ts
+++ b/packages/nimble-components/src/all-components.ts
@@ -21,6 +21,8 @@ import './combobox';
import './dialog';
import './drawer';
import './icons/all-icons';
+import './label-provider/core';
+import './label-provider/table';
import './list-option';
import './menu';
import './menu-button';
diff --git a/packages/nimble-components/src/banner/template.ts b/packages/nimble-components/src/banner/template.ts
index 1047ec2d10..3309fba30b 100644
--- a/packages/nimble-components/src/banner/template.ts
+++ b/packages/nimble-components/src/banner/template.ts
@@ -6,6 +6,7 @@ import { iconInfoTag } from '../icons/info';
import { iconTriangleFilledTag } from '../icons/triangle-filled';
import { iconXmarkTag } from '../icons/xmark';
import { BannerSeverity } from './types';
+import { popupDismissLabel } from '../label-provider/core/label-tokens';
// prettier-ignore
export const template = html`
@@ -53,7 +54,7 @@ export const template = html`
${when(x => !x.preventDismiss, html`
<${buttonTag} appearance="ghost" content-hidden @click="${x => x.dismissBanner()}">
<${iconXmarkTag} slot="start">${iconXmarkTag}>
- ${x => x.dismissButtonLabel ?? 'Close'}
+ ${x => x.dismissButtonLabel ?? popupDismissLabel.getValueFor(x)}
${buttonTag}>
`)}
diff --git a/packages/nimble-components/src/banner/tests/banner.spec.ts b/packages/nimble-components/src/banner/tests/banner.spec.ts
index e11fc9e207..93fa2a5cfc 100644
--- a/packages/nimble-components/src/banner/tests/banner.spec.ts
+++ b/packages/nimble-components/src/banner/tests/banner.spec.ts
@@ -1,9 +1,15 @@
import { html } from '@microsoft/fast-element';
import { fixture, Fixture } from '../../utilities/tests/fixture';
-import { Banner } from '..';
+import { Banner, bannerTag } from '..';
import { BannerSeverity } from '../types';
import { waitForUpdatesAsync } from '../../testing/async-helpers';
import { createEventListener } from '../../utilities/tests/component';
+import { themeProviderTag, type ThemeProvider } from '../../theme-provider';
+import {
+ LabelProviderCore,
+ labelProviderCoreTag
+} from '../../label-provider/core';
+import { buttonTag } from '../../button';
async function setup(): Promise> {
return fixture(html`
@@ -14,6 +20,18 @@ async function setup(): Promise> {
`);
}
+async function setupWithLabelProvider(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ <${labelProviderCoreTag}>${labelProviderCoreTag}>
+
+ Title
+ Message text
+
+ ${themeProviderTag}>
+ `);
+}
+
describe('Banner', () => {
let element: Banner;
let connect: () => Promise;
@@ -102,3 +120,37 @@ describe('Banner', () => {
).toBe('status');
});
});
+
+describe('Banner with LabelProviderCore', () => {
+ let element: Banner;
+ let labelProvider: LabelProviderCore;
+ let connect: () => Promise;
+ let disconnect: () => Promise;
+
+ beforeEach(async () => {
+ let themeProvider: ThemeProvider;
+ ({
+ element: themeProvider,
+ connect,
+ disconnect
+ } = await setupWithLabelProvider());
+ await connect();
+ element = themeProvider.querySelector(bannerTag)!;
+ labelProvider = themeProvider.querySelector(labelProviderCoreTag)!;
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('uses CoreLabelProvider popupDismissLabel for the close button label when dismissButtonLabel is unset', async () => {
+ labelProvider.popupDismiss = 'Customized Close';
+ await waitForUpdatesAsync();
+
+ const actualCloseButtonText = element
+ .shadowRoot!.querySelector('.dismiss')!
+ .querySelector(buttonTag)!
+ .textContent!.trim();
+ expect(actualCloseButtonText).toBe('Customized Close');
+ });
+});
diff --git a/packages/nimble-components/src/banner/tests/banner.stories.ts b/packages/nimble-components/src/banner/tests/banner.stories.ts
index 5e2dba711c..c579aba58b 100644
--- a/packages/nimble-components/src/banner/tests/banner.stories.ts
+++ b/packages/nimble-components/src/banner/tests/banner.stories.ts
@@ -8,6 +8,12 @@ import { bannerTag } from '..';
import { iconKeyTag } from '../../icons/key';
import { buttonTag } from '../../button';
import { anchorTag } from '../../anchor';
+import { labelProviderCoreTag } from '../../label-provider/core';
+import {
+ LabelUserArgs,
+ addLabelUseMetadata
+} from '../../label-provider/base/tests/label-user-stories-utils';
+import { popupDismissLabel } from '../../label-provider/core/label-tokens';
// eslint-disable-next-line @typescript-eslint/naming-convention
const ActionType = {
@@ -20,7 +26,7 @@ const ActionType = {
// eslint-disable-next-line @typescript-eslint/no-redeclare
type ActionType = (typeof ActionType)[keyof typeof ActionType];
-interface BannerArgs {
+interface BannerArgs extends LabelUserArgs {
open: boolean;
title: string;
text: string;
@@ -55,6 +61,7 @@ const metadata: Meta = {
}
}
};
+addLabelUseMetadata(metadata, labelProviderCoreTag, popupDismissLabel);
export default metadata;
@@ -121,11 +128,13 @@ export const _banner: StoryObj = {
dismissButtonLabel: {
name: 'dismiss-button-label',
description:
- 'Set to a localized label (e.g. `"Close"`) for the dismiss button. This provides an accessible name for assistive technologies.'
+ 'Set to a localized label (e.g. `"Close"`) for the dismiss button. This provides an accessible name for assistive technologies.
(Equivalent to setting `popup-dismiss` on `nimble-label-provider-core`)',
+ control: { type: 'none' }
},
toggle: {
description:
- 'Event emitted by the banner when the `open` state changes. The event details include the booleans `oldState` and `newState`.'
+ 'Event emitted by the banner when the `open` state changes. The event details include the booleans `oldState` and `newState`.',
+ control: { type: 'none' }
}
},
args: {
@@ -135,6 +144,8 @@ export const _banner: StoryObj = {
severity: BannerSeverity.error,
action: 'none',
preventDismiss: false,
- titleHidden: false
+ titleHidden: false,
+ dismissButtonLabel: undefined,
+ toggle: undefined
}
};
diff --git a/packages/nimble-components/src/label-provider/base/index.ts b/packages/nimble-components/src/label-provider/base/index.ts
new file mode 100644
index 0000000000..90502086dd
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/base/index.ts
@@ -0,0 +1,70 @@
+import { DesignToken, FoundationElement } from '@microsoft/fast-foundation';
+import { Notifier, Observable, type Subscriber } from '@microsoft/fast-element';
+import { themeProviderTag } from '../../theme-provider';
+
+export type DesignTokensFor = {
+ [key in keyof ObjectT]: string | undefined;
+};
+
+/**
+ * Base class for label providers
+ */
+export abstract class LabelProviderBase<
+ SupportedLabels extends { [key: string]: DesignToken }
+>
+ extends FoundationElement
+ implements Subscriber {
+ protected abstract supportedLabels: SupportedLabels;
+
+ private readonly propertyNotifier: Notifier = Observable.getNotifier(this);
+ private themeProvider?: HTMLElement;
+
+ public override connectedCallback(): void {
+ super.connectedCallback();
+ this.initializeThemeProvider();
+ this.propertyNotifier.subscribe(this);
+ }
+
+ public override disconnectedCallback(): void {
+ this.propertyNotifier.unsubscribe(this);
+ if (this.themeProvider) {
+ for (const token of Object.values(this.supportedLabels)) {
+ token.deleteValueFor(this.themeProvider);
+ }
+ this.themeProvider = undefined;
+ }
+ }
+
+ public handleChange(
+ _element: LabelProviderBase,
+ property: keyof LabelProviderBase
+ ): void {
+ if (this.supportedLabels[property]) {
+ const token = this.supportedLabels[property]!;
+ const value = this[property];
+ if (this.themeProvider) {
+ if (value === null || value === undefined) {
+ token.deleteValueFor(this.themeProvider);
+ } else {
+ token.setValueFor(this.themeProvider, value as string);
+ }
+ }
+ }
+ }
+
+ private initializeThemeProvider(): void {
+ this.themeProvider = this.closest(themeProviderTag) ?? undefined;
+ if (this.themeProvider) {
+ for (const [property, token] of Object.entries(
+ this.supportedLabels
+ )) {
+ const value = this[property as keyof LabelProviderBase];
+ if (value === null || value === undefined) {
+ token.deleteValueFor(this.themeProvider);
+ } else {
+ token.setValueFor(this.themeProvider, value as string);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/nimble-components/src/label-provider/base/tests/label-name-utils.ts b/packages/nimble-components/src/label-provider/base/tests/label-name-utils.ts
new file mode 100644
index 0000000000..420a6c35f0
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/base/tests/label-name-utils.ts
@@ -0,0 +1,9 @@
+import { spinalCase } from '@microsoft/fast-web-utilities';
+
+export function getPropertyName(jsKey: string): string {
+ return jsKey.replace(/Label$/, '');
+}
+
+export function getAttributeName(jsKey: string): string {
+ return spinalCase(getPropertyName(jsKey));
+}
diff --git a/packages/nimble-components/src/label-provider/base/tests/label-provider-base.spec.ts b/packages/nimble-components/src/label-provider/base/tests/label-provider-base.spec.ts
new file mode 100644
index 0000000000..29858068f8
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/base/tests/label-provider-base.spec.ts
@@ -0,0 +1,158 @@
+import { attr, customElement, html } from '@microsoft/fast-element';
+import { DesignToken } from '@microsoft/fast-foundation';
+import { ThemeProvider, themeProviderTag } from '../../../theme-provider';
+import {
+ Fixture,
+ fixture,
+ uniqueElementName
+} from '../../../utilities/tests/fixture';
+import { buttonTag } from '../../../button';
+import { LabelProviderBase } from '..';
+
+const exampleMessageLabelDefaultValue = 'Initial Value';
+const exampleMessageLabel = DesignToken.create({
+ name: 'test-example-message-label',
+ cssCustomPropertyName: null
+}).withDefault(exampleMessageLabelDefaultValue);
+
+const exampleSupportedLabels = {
+ exampleMessage: exampleMessageLabel
+} as const;
+
+describe('Label Provider Base', () => {
+ let themeProvider: ThemeProvider;
+ let connect: () => Promise;
+ let disconnect: () => Promise;
+
+ const testLabelProviderTag = uniqueElementName();
+ @customElement({
+ name: testLabelProviderTag
+ })
+ class LabelProviderTest extends LabelProviderBase<
+ typeof exampleSupportedLabels
+ > {
+ @attr({ attribute: 'example-message' })
+ public exampleMessage?: string;
+
+ protected override readonly supportedLabels = exampleSupportedLabels;
+ }
+
+ describe('with single theme provider', () => {
+ async function setup(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ ${themeProviderTag}>
+ `);
+ }
+
+ beforeEach(async () => {
+ ({ element: themeProvider, connect, disconnect } = await setup());
+ await connect();
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('token values can be set before connected to DOM, and are set on target theme provider once attached', () => {
+ const element = document.createElement(
+ testLabelProviderTag
+ ) as LabelProviderTest;
+ element.exampleMessage = 'Updated Value';
+ themeProvider.appendChild(element);
+
+ const tokenValue = exampleMessageLabel.getValueFor(themeProvider);
+ expect(tokenValue).toBe('Updated Value');
+ });
+ });
+
+ describe('with 2 nested theme providers and initial label values', () => {
+ async function setup(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ <${testLabelProviderTag} class="parent-provider" example-message="test-parent">${testLabelProviderTag}>
+ <${themeProviderTag}>
+ <${testLabelProviderTag} class="child-provider" example-message="test-child">${testLabelProviderTag}>
+ <${buttonTag}>${buttonTag}>
+ ${themeProviderTag}>
+ ${themeProviderTag}>
+ `);
+ }
+
+ beforeEach(async () => {
+ ({ element: themeProvider, connect, disconnect } = await setup());
+ await connect();
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('token values cascade from parent providers correctly', () => {
+ const parentLabelProvider: LabelProviderTest = themeProvider.querySelector('.parent-provider')!;
+ const childLabelProvider: LabelProviderTest = themeProvider.querySelector('.child-provider')!;
+ const elementUsingLabels: HTMLElement = themeProvider.querySelector(buttonTag)!;
+
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ 'test-child'
+ );
+
+ childLabelProvider.exampleMessage = undefined;
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ 'test-parent'
+ );
+
+ parentLabelProvider.exampleMessage = 'test-parent-new-value';
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ 'test-parent-new-value'
+ );
+
+ childLabelProvider.exampleMessage = 'test-child-new-value';
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ 'test-child-new-value'
+ );
+
+ childLabelProvider.removeAttribute('example-message');
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ 'test-parent-new-value'
+ );
+ });
+ });
+
+ describe('with 2 nested theme providers without initial label values', () => {
+ async function setup(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ <${testLabelProviderTag} class="parent-provider">${testLabelProviderTag}>
+ <${themeProviderTag}>
+ <${testLabelProviderTag} class="child-provider">${testLabelProviderTag}>
+ <${buttonTag}>${buttonTag}>
+ ${themeProviderTag}>
+ ${themeProviderTag}>
+ `);
+ }
+
+ beforeEach(async () => {
+ ({ element: themeProvider, connect, disconnect } = await setup());
+ await connect();
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('token values cascade from parent providers correctly', () => {
+ const parentLabelProvider: LabelProviderTest = themeProvider.querySelector('.parent-provider')!;
+ const elementUsingLabels: HTMLElement = themeProvider.querySelector(buttonTag)!;
+
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ exampleMessageLabelDefaultValue
+ );
+
+ parentLabelProvider.exampleMessage = 'test-parent-new-value';
+ expect(exampleMessageLabel.getValueFor(elementUsingLabels)).toBe(
+ 'test-parent-new-value'
+ );
+ });
+ });
+});
diff --git a/packages/nimble-components/src/label-provider/base/tests/label-provider-stories-utils.ts b/packages/nimble-components/src/label-provider/base/tests/label-provider-stories-utils.ts
new file mode 100644
index 0000000000..658f244ba9
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/base/tests/label-provider-stories-utils.ts
@@ -0,0 +1,119 @@
+import { ViewTemplate, html, ref } from '@microsoft/fast-element';
+import type { Meta } from '@storybook/html';
+import type { DesignToken } from '@microsoft/fast-foundation';
+import { createUserSelectedThemeStory } from '../../../utilities/tests/storybook';
+import { bodyFont } from '../../../theme-provider/design-tokens';
+import { getAttributeName, getPropertyName } from './label-name-utils';
+import { Table, tableTag } from '../../../table';
+import { tableColumnTextTag } from '../../../table-column/text';
+
+export interface LabelProviderArgs {
+ tableRef: Table;
+ labelProviderTag: string;
+ labelTokens: [string, DesignToken][];
+ removeNamePrefix(tokenName: string): string;
+ updateData(args: LabelProviderArgs): void;
+}
+
+const createTemplate = (
+ labelProviderTag: string
+): ViewTemplate => html`
+<${labelProviderTag}>${labelProviderTag}>
+Element name: ${x => x.labelProviderTag}
+<${tableTag}
+ ${ref('tableRef')}
+ data-unused="${x => x.updateData(x)}"
+>
+ <${tableColumnTextTag}
+ column-id="token-name-column"
+ field-name="tokenName"
+ >
+ Token name
+ ${tableColumnTextTag}>
+ <${tableColumnTextTag}
+ column-id="attribute-name-column"
+ field-name="htmlAttributeName"
+ >
+ HTML attribute name
+ ${tableColumnTextTag}>
+ <${tableColumnTextTag}
+ column-id="property-name-column"
+ field-name="jsPropertyName"
+ >
+ JS property name
+ ${tableColumnTextTag}>
+ <${tableColumnTextTag}
+ column-id="default-value-column"
+ field-name="defaultValue"
+ >
+ Default value (English)
+ ${tableColumnTextTag}>
+${tableTag}>`;
+
+export const labelProviderMetadata: Meta = {
+ render: createUserSelectedThemeStory(html`
+
+
+ ${x => createTemplate(x.labelProviderTag)}
+
+ `),
+ argTypes: {
+ removeNamePrefix: {
+ table: {
+ disable: true
+ }
+ },
+ labelProviderTag: {
+ table: {
+ disable: true
+ }
+ },
+ labelTokens: {
+ table: {
+ disable: true
+ }
+ },
+ tableRef: {
+ table: {
+ disable: true
+ }
+ },
+ updateData: {
+ table: {
+ disable: true
+ }
+ }
+ },
+ args: {
+ removeNamePrefix: jsTokenName => jsTokenName,
+ tableRef: undefined,
+ updateData: x => {
+ void (async () => {
+ // Safari workaround: the table element instance is made at this point
+ // but doesn't seem to be upgraded to a custom element yet
+ await customElements.whenDefined('nimble-table');
+
+ const data = x.labelTokens.map(token => {
+ return {
+ tokenName: token[0],
+ htmlAttributeName: getAttributeName(
+ x.removeNamePrefix(token[0])
+ ),
+ jsPropertyName: getPropertyName(
+ x.removeNamePrefix(token[0])
+ ),
+ defaultValue: token[1].getValueFor(document.body)
+ };
+ });
+ await x.tableRef.setData(data);
+ })();
+ }
+ }
+};
diff --git a/packages/nimble-components/src/label-provider/base/tests/label-providers.mdx b/packages/nimble-components/src/label-provider/base/tests/label-providers.mdx
new file mode 100644
index 0000000000..26e742daa3
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/base/tests/label-providers.mdx
@@ -0,0 +1,23 @@
+import { Canvas, Meta, Title } from '@storybook/blocks';
+import { coreLabelProvider } from '../../core/tests/label-provider-core.stories';
+import { tableLabelProvider } from '../../table/tests/label-provider-table.stories';
+
+
+Label Providers
+
+Most user-visible strings displayed by Nimble components are provided by the client application and are expected to be localized by the application if necessary. However, some strings are built into Nimble components and are provided only in English. An application can provide localized versions of these strings by using design tokens set on label provider elements.
+
+See the nimble-components readme and
+contributing docs for more information.
+
+## Core Label Provider
+
+Provides labels for all Nimble components besides the table component.
+
+
+
+## Table Label Provider
+
+Provides labels for the `nimble-table` and all associated table sub-components / column types.
+
+
diff --git a/packages/nimble-components/src/label-provider/base/tests/label-user-stories-utils.ts b/packages/nimble-components/src/label-provider/base/tests/label-user-stories-utils.ts
new file mode 100644
index 0000000000..87ddd4726b
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/base/tests/label-user-stories-utils.ts
@@ -0,0 +1,33 @@
+import type { Meta } from '@storybook/html';
+import type { DesignToken } from '@microsoft/fast-foundation';
+
+export interface LabelUserArgs {
+ usedLabels: null;
+}
+
+export function addLabelUseMetadata(
+ metadata: Meta,
+ labelProviderTag: string,
+ ...labelTokens: DesignToken[]
+): void {
+ let tokenContent: string;
+ if (labelTokens.length === 0) {
+ tokenContent = '- (All tokens on this provider are used)';
+ } else {
+ tokenContent = labelTokens
+ .map(token => `- \`${token.name}\``)
+ .join('\n');
+ }
+ if (metadata.argTypes === undefined) {
+ metadata.argTypes = {};
+ }
+ metadata.argTypes.usedLabels = {
+ name: 'Localizable labels',
+ description: `Label Provider:\`${labelProviderTag}\`
+${tokenContent}
+
+See the "Tokens/Label Providers" docs page for more information.
+`,
+ control: { type: 'none' }
+ };
+}
diff --git a/packages/nimble-components/src/label-provider/core/index.ts b/packages/nimble-components/src/label-provider/core/index.ts
new file mode 100644
index 0000000000..af5860a5d2
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/core/index.ts
@@ -0,0 +1,47 @@
+import { attr } from '@microsoft/fast-element';
+import { DesignSystem } from '@microsoft/fast-foundation';
+import { DesignTokensFor, LabelProviderBase } from '../base';
+import {
+ popupDismissLabel,
+ numericDecrementLabel,
+ numericIncrementLabel
+} from './label-tokens';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nimble-label-provider-core': LabelProviderCore;
+ }
+}
+
+const supportedLabels = {
+ popupDismiss: popupDismissLabel,
+ numericDecrement: numericDecrementLabel,
+ numericIncrement: numericIncrementLabel
+} as const;
+
+/**
+ * Core label provider for Nimble
+ */
+export class LabelProviderCore
+ extends LabelProviderBase
+ implements DesignTokensFor {
+ @attr({ attribute: 'popup-dismiss' })
+ public popupDismiss: string | undefined;
+
+ @attr({ attribute: 'numeric-decrement' })
+ public numericDecrement: string | undefined;
+
+ @attr({ attribute: 'numeric-increment' })
+ public numericIncrement: string | undefined;
+
+ protected override readonly supportedLabels = supportedLabels;
+}
+
+const nimbleLabelProviderCore = LabelProviderCore.compose({
+ baseName: 'label-provider-core'
+});
+
+DesignSystem.getOrCreate()
+ .withPrefix('nimble')
+ .register(nimbleLabelProviderCore());
+export const labelProviderCoreTag = DesignSystem.tagFor(LabelProviderCore);
diff --git a/packages/nimble-components/src/label-provider/core/label-token-defaults.ts b/packages/nimble-components/src/label-provider/core/label-token-defaults.ts
new file mode 100644
index 0000000000..23a312e2f1
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/core/label-token-defaults.ts
@@ -0,0 +1,9 @@
+import type * as TokensNamespace from './label-tokens';
+
+type TokenName = keyof typeof TokensNamespace;
+
+export const coreLabelDefaults: { readonly [key in TokenName]: string } = {
+ popupDismissLabel: 'Close',
+ numericIncrementLabel: 'Increment',
+ numericDecrementLabel: 'Decrement'
+};
diff --git a/packages/nimble-components/src/label-provider/core/label-tokens.ts b/packages/nimble-components/src/label-provider/core/label-tokens.ts
new file mode 100644
index 0000000000..cddd101ce3
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/core/label-tokens.ts
@@ -0,0 +1,17 @@
+import { DesignToken } from '@microsoft/fast-foundation';
+import { coreLabelDefaults } from './label-token-defaults';
+
+export const popupDismissLabel = DesignToken.create({
+ name: 'popup-dismiss-label',
+ cssCustomPropertyName: null
+}).withDefault(coreLabelDefaults.popupDismissLabel);
+
+export const numericDecrementLabel = DesignToken.create({
+ name: 'numeric-decrement-label',
+ cssCustomPropertyName: null
+}).withDefault(coreLabelDefaults.numericDecrementLabel);
+
+export const numericIncrementLabel = DesignToken.create({
+ name: 'numeric-increment-label',
+ cssCustomPropertyName: null
+}).withDefault(coreLabelDefaults.numericIncrementLabel);
diff --git a/packages/nimble-components/src/label-provider/core/tests/label-provider-core.spec.ts b/packages/nimble-components/src/label-provider/core/tests/label-provider-core.spec.ts
new file mode 100644
index 0000000000..5dca1cf8d0
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/core/tests/label-provider-core.spec.ts
@@ -0,0 +1,130 @@
+import { spinalCase } from '@microsoft/fast-web-utilities';
+import { html } from '@microsoft/fast-element';
+import * as labelTokensNamespace from '../label-tokens';
+import { LabelProviderCore, labelProviderCoreTag } from '..';
+import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized';
+import {
+ getAttributeName,
+ getPropertyName
+} from '../../base/tests/label-name-utils';
+import { ThemeProvider, themeProviderTag } from '../../../theme-provider';
+import { Fixture, fixture } from '../../../utilities/tests/fixture';
+
+type DesignTokenPropertyName = keyof typeof labelTokensNamespace;
+const designTokenPropertyNames = Object.keys(
+ labelTokensNamespace
+) as DesignTokenPropertyName[];
+
+async function setup(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ <${labelProviderCoreTag}>${labelProviderCoreTag}>
+ ${themeProviderTag}>
+ `);
+}
+
+describe('Label Provider Core', () => {
+ let element: LabelProviderCore;
+ let themeProvider: ThemeProvider;
+ let connect: () => Promise;
+ let disconnect: () => Promise;
+
+ beforeEach(async () => {
+ ({ element: themeProvider, connect, disconnect } = await setup());
+ element = themeProvider.querySelector(labelProviderCoreTag)!;
+ await connect();
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('can construct an element instance', () => {
+ expect(
+ document.createElement('nimble-label-provider-core')
+ ).toBeInstanceOf(LabelProviderCore);
+ });
+
+ describe('token JS key should match DesignToken.name', () => {
+ const tokenEntries = designTokenPropertyNames.map(
+ (name: DesignTokenPropertyName) => ({
+ name,
+ labelToken: labelTokensNamespace[name]
+ })
+ );
+
+ for (const tokenEntry of tokenEntries) {
+ const focused: DesignTokenPropertyName[] = [];
+ const disabled: DesignTokenPropertyName[] = [];
+ const specType = getSpecTypeByNamedList(
+ tokenEntry,
+ focused,
+ disabled
+ );
+ specType(`for token name ${tokenEntry.name}`, () => {
+ const convertedTokenValue = spinalCase(tokenEntry.name);
+ expect(tokenEntry.labelToken.name).toBe(convertedTokenValue);
+ });
+ }
+ });
+
+ describe('token JS key should match a LabelProvider property/attribute', () => {
+ const tokenEntries = designTokenPropertyNames.map(
+ (name: DesignTokenPropertyName) => ({
+ name,
+ labelToken: labelTokensNamespace[name]
+ })
+ );
+
+ for (const tokenEntry of tokenEntries) {
+ const focused: DesignTokenPropertyName[] = [];
+ const disabled: DesignTokenPropertyName[] = [];
+ const specType = getSpecTypeByNamedList(
+ tokenEntry,
+ focused,
+ disabled
+ );
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
+ specType(`for token name ${tokenEntry.name}`, () => {
+ const expectedPropertyName = getPropertyName(tokenEntry.name);
+ const expectedAttributeName = getAttributeName(tokenEntry.name);
+ const attributeDefinition = element.$fastController.definition.attributes.find(
+ a => a.name === expectedPropertyName
+ );
+ expect(attributeDefinition).toBeDefined();
+ expect(expectedAttributeName).toEqual(
+ attributeDefinition!.attribute
+ );
+ });
+ }
+ });
+
+ describe('token value is updated after setting corresponding LabelProvider attribute', () => {
+ const tokenEntries = designTokenPropertyNames.map(
+ (name: DesignTokenPropertyName) => ({
+ name,
+ labelToken: labelTokensNamespace[name]
+ })
+ );
+
+ for (const tokenEntry of tokenEntries) {
+ const focused: DesignTokenPropertyName[] = [];
+ const disabled: DesignTokenPropertyName[] = [];
+ const specType = getSpecTypeByNamedList(
+ tokenEntry,
+ focused,
+ disabled
+ );
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
+ specType(`for token name ${tokenEntry.name}`, () => {
+ const attributeName = getAttributeName(tokenEntry.name);
+ const updatedValue = `NewString-${tokenEntry.name}`;
+ element.setAttribute(attributeName, updatedValue);
+
+ expect(tokenEntry.labelToken.getValueFor(themeProvider)).toBe(
+ updatedValue
+ );
+ });
+ }
+ });
+});
diff --git a/packages/nimble-components/src/label-provider/core/tests/label-provider-core.stories.ts b/packages/nimble-components/src/label-provider/core/tests/label-provider-core.stories.ts
new file mode 100644
index 0000000000..6eff6fb29a
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/core/tests/label-provider-core.stories.ts
@@ -0,0 +1,21 @@
+import type { StoryObj } from '@storybook/html';
+import {
+ LabelProviderArgs,
+ labelProviderMetadata
+} from '../../base/tests/label-provider-stories-utils';
+import { labelProviderCoreTag } from '..';
+import * as labelTokensNamespace from '../label-tokens';
+
+const metadata = {
+ ...labelProviderMetadata,
+ title: 'Tokens/Label Providers'
+};
+
+export default metadata;
+
+export const coreLabelProvider: StoryObj = {
+ args: {
+ labelProviderTag: labelProviderCoreTag,
+ labelTokens: Object.entries(labelTokensNamespace)
+ }
+};
diff --git a/packages/nimble-components/src/label-provider/table/index.ts b/packages/nimble-components/src/label-provider/table/index.ts
new file mode 100644
index 0000000000..1c3ada4972
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/table/index.ts
@@ -0,0 +1,57 @@
+import { attr } from '@microsoft/fast-element';
+import { DesignSystem } from '@microsoft/fast-foundation';
+import { DesignTokensFor, LabelProviderBase } from '../base';
+import {
+ tableCellActionMenuLabel,
+ tableColumnHeaderGroupedIndicatorLabel,
+ tableGroupCollapseLabel,
+ tableGroupExpandLabel,
+ tableGroupsCollapseAllLabel
+} from './label-tokens';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'nimble-label-provider-table': LabelProviderTable;
+ }
+}
+
+const supportedLabels = {
+ groupCollapse: tableGroupCollapseLabel,
+ groupExpand: tableGroupExpandLabel,
+ groupsCollapseAll: tableGroupsCollapseAllLabel,
+ cellActionMenu: tableCellActionMenuLabel,
+ columnHeaderGroupedIndicator: tableColumnHeaderGroupedIndicatorLabel
+} as const;
+
+/**
+ * Label provider for the Nimble table (and its sub-components and columns)
+ */
+export class LabelProviderTable
+ extends LabelProviderBase
+ implements DesignTokensFor {
+ @attr({ attribute: 'group-collapse' })
+ public groupCollapse: string | undefined;
+
+ @attr({ attribute: 'group-expand' })
+ public groupExpand: string | undefined;
+
+ @attr({ attribute: 'groups-collapse-all' })
+ public groupsCollapseAll: string | undefined;
+
+ @attr({ attribute: 'cell-action-menu' })
+ public cellActionMenu: string | undefined;
+
+ @attr({ attribute: 'column-header-grouped-indicator' })
+ public columnHeaderGroupedIndicator: string | undefined;
+
+ protected override readonly supportedLabels = supportedLabels;
+}
+
+const nimbleLabelProviderTable = LabelProviderTable.compose({
+ baseName: 'label-provider-table'
+});
+
+DesignSystem.getOrCreate()
+ .withPrefix('nimble')
+ .register(nimbleLabelProviderTable());
+export const labelProviderTableTag = DesignSystem.tagFor(LabelProviderTable);
diff --git a/packages/nimble-components/src/label-provider/table/label-token-defaults.ts b/packages/nimble-components/src/label-provider/table/label-token-defaults.ts
new file mode 100644
index 0000000000..b2a31d3de2
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/table/label-token-defaults.ts
@@ -0,0 +1,11 @@
+import type * as TokensNamespace from './label-tokens';
+
+type TokenName = keyof typeof TokensNamespace;
+
+export const tableLabelDefaults: { readonly [key in TokenName]: string } = {
+ tableGroupCollapseLabel: 'Collapse group',
+ tableGroupExpandLabel: 'Expand group',
+ tableGroupsCollapseAllLabel: 'Collapse all groups',
+ tableCellActionMenuLabel: 'Options',
+ tableColumnHeaderGroupedIndicatorLabel: 'Grouped'
+};
diff --git a/packages/nimble-components/src/label-provider/table/label-tokens.ts b/packages/nimble-components/src/label-provider/table/label-tokens.ts
new file mode 100644
index 0000000000..5d8a41394a
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/table/label-tokens.ts
@@ -0,0 +1,27 @@
+import { DesignToken } from '@microsoft/fast-foundation';
+import { tableLabelDefaults } from './label-token-defaults';
+
+export const tableGroupCollapseLabel = DesignToken.create({
+ name: 'table-group-collapse-label',
+ cssCustomPropertyName: null
+}).withDefault(tableLabelDefaults.tableGroupCollapseLabel);
+
+export const tableGroupExpandLabel = DesignToken.create({
+ name: 'table-group-expand-label',
+ cssCustomPropertyName: null
+}).withDefault(tableLabelDefaults.tableGroupExpandLabel);
+
+export const tableGroupsCollapseAllLabel = DesignToken.create({
+ name: 'table-groups-collapse-all-label',
+ cssCustomPropertyName: null
+}).withDefault(tableLabelDefaults.tableGroupsCollapseAllLabel);
+
+export const tableCellActionMenuLabel = DesignToken.create({
+ name: 'table-cell-action-menu-label',
+ cssCustomPropertyName: null
+}).withDefault(tableLabelDefaults.tableCellActionMenuLabel);
+
+export const tableColumnHeaderGroupedIndicatorLabel = DesignToken.create({
+ name: 'table-column-header-grouped-indicator-label',
+ cssCustomPropertyName: null
+}).withDefault(tableLabelDefaults.tableColumnHeaderGroupedIndicatorLabel);
diff --git a/packages/nimble-components/src/label-provider/table/name-utils.ts b/packages/nimble-components/src/label-provider/table/name-utils.ts
new file mode 100644
index 0000000000..27fa0784ca
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/table/name-utils.ts
@@ -0,0 +1,12 @@
+/**
+ * Removes the table prefix and camelCases the input token name.
+ * (The design token name has the table prefix, but the properties on the LabelProviderTable do not, as they're already
+ * scoped to the table only)
+ */
+export function removeTablePrefixAndCamelCase(jsTokenName: string): string {
+ // Example: 'tableGroupExpandLabel' => 'groupExpandLabel'
+ return jsTokenName.replace(
+ /^table(\w)(\w+)/,
+ (_match: string, firstChar: string, restOfString: string) => `${firstChar.toLowerCase()}${restOfString}`
+ );
+}
diff --git a/packages/nimble-components/src/label-provider/table/tests/label-provider-table.spec.ts b/packages/nimble-components/src/label-provider/table/tests/label-provider-table.spec.ts
new file mode 100644
index 0000000000..1659d7e4db
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/table/tests/label-provider-table.spec.ts
@@ -0,0 +1,137 @@
+import { spinalCase } from '@microsoft/fast-web-utilities';
+import { html } from '@microsoft/fast-element';
+import * as labelTokensNamespace from '../label-tokens';
+import { LabelProviderTable, labelProviderTableTag } from '..';
+import { getSpecTypeByNamedList } from '../../../utilities/tests/parameterized';
+import {
+ getAttributeName,
+ getPropertyName
+} from '../../base/tests/label-name-utils';
+import { ThemeProvider, themeProviderTag } from '../../../theme-provider';
+import { Fixture, fixture } from '../../../utilities/tests/fixture';
+import { removeTablePrefixAndCamelCase } from '../name-utils';
+
+type DesignTokenPropertyName = keyof typeof labelTokensNamespace;
+const designTokenPropertyNames = Object.keys(
+ labelTokensNamespace
+) as DesignTokenPropertyName[];
+
+async function setup(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ <${labelProviderTableTag}>${labelProviderTableTag}>
+ ${themeProviderTag}>
+ `);
+}
+
+describe('Label Provider Table', () => {
+ let element: LabelProviderTable;
+ let themeProvider: ThemeProvider;
+ let connect: () => Promise;
+ let disconnect: () => Promise;
+
+ beforeEach(async () => {
+ ({ element: themeProvider, connect, disconnect } = await setup());
+ element = themeProvider.querySelector(labelProviderTableTag)!;
+ await connect();
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('can construct an element instance', () => {
+ expect(
+ document.createElement('nimble-label-provider-table')
+ ).toBeInstanceOf(LabelProviderTable);
+ });
+
+ describe('token JS key should match DesignToken.name', () => {
+ const tokenEntries = designTokenPropertyNames.map(
+ (name: DesignTokenPropertyName) => ({
+ name,
+ labelToken: labelTokensNamespace[name]
+ })
+ );
+
+ for (const tokenEntry of tokenEntries) {
+ const focused: DesignTokenPropertyName[] = [];
+ const disabled: DesignTokenPropertyName[] = [];
+ const specType = getSpecTypeByNamedList(
+ tokenEntry,
+ focused,
+ disabled
+ );
+ specType(`for token name ${tokenEntry.name}`, () => {
+ const convertedTokenValue = spinalCase(tokenEntry.name);
+ expect(tokenEntry.labelToken.name).toBe(convertedTokenValue);
+ });
+ }
+ });
+
+ describe('token JS key should match a LabelProvider property/attribute', () => {
+ const tokenEntries = designTokenPropertyNames.map(
+ (name: DesignTokenPropertyName) => ({
+ name,
+ labelToken: labelTokensNamespace[name]
+ })
+ );
+
+ for (const tokenEntry of tokenEntries) {
+ const focused: DesignTokenPropertyName[] = [];
+ const disabled: DesignTokenPropertyName[] = [];
+ const specType = getSpecTypeByNamedList(
+ tokenEntry,
+ focused,
+ disabled
+ );
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
+ specType(`for token name ${tokenEntry.name}`, () => {
+ const tokenName = removeTablePrefixAndCamelCase(
+ tokenEntry.name
+ );
+ const expectedPropertyName = getPropertyName(tokenName);
+ const expectedAttributeName = getAttributeName(tokenName);
+ const attributeDefinition = element.$fastController.definition.attributes.find(
+ a => a.name === expectedPropertyName
+ );
+ expect(attributeDefinition).toBeDefined();
+ expect(expectedAttributeName).toEqual(
+ attributeDefinition!.attribute
+ );
+ });
+ }
+ });
+
+ describe('token value is updated after setting corresponding LabelProvider attribute', () => {
+ const tokenEntries = designTokenPropertyNames.map(
+ (name: DesignTokenPropertyName) => ({
+ name,
+ labelToken: labelTokensNamespace[name]
+ })
+ );
+
+ for (const tokenEntry of tokenEntries) {
+ const focused: DesignTokenPropertyName[] = [];
+ const disabled: DesignTokenPropertyName[] = [];
+ const specType = getSpecTypeByNamedList(
+ tokenEntry,
+ focused,
+ disabled
+ );
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
+ specType(`for token name ${tokenEntry.name}`, () => {
+ const tokenName = removeTablePrefixAndCamelCase(
+ tokenEntry.name
+ );
+ const attributeName = getAttributeName(tokenName);
+ const updatedValue = `NewString-${tokenName}`;
+ element.setAttribute(attributeName, updatedValue);
+
+ expect(tokenEntry.labelToken.getValueFor(themeProvider)).toBe(
+ updatedValue
+ );
+ });
+ }
+ });
+});
diff --git a/packages/nimble-components/src/label-provider/table/tests/label-provider-table.stories.ts b/packages/nimble-components/src/label-provider/table/tests/label-provider-table.stories.ts
new file mode 100644
index 0000000000..4904d06b56
--- /dev/null
+++ b/packages/nimble-components/src/label-provider/table/tests/label-provider-table.stories.ts
@@ -0,0 +1,23 @@
+import type { StoryObj } from '@storybook/html';
+import {
+ LabelProviderArgs,
+ labelProviderMetadata
+} from '../../base/tests/label-provider-stories-utils';
+import { labelProviderTableTag } from '..';
+import { removeTablePrefixAndCamelCase } from '../name-utils';
+import * as labelTokensNamespace from '../label-tokens';
+
+const metadata = {
+ ...labelProviderMetadata,
+ title: 'Tokens/Label Providers'
+};
+
+export default metadata;
+
+export const tableLabelProvider: StoryObj = {
+ args: {
+ labelProviderTag: labelProviderTableTag,
+ labelTokens: Object.entries(labelTokensNamespace),
+ removeNamePrefix: removeTablePrefixAndCamelCase
+ }
+};
diff --git a/packages/nimble-components/src/number-field/index.ts b/packages/nimble-components/src/number-field/index.ts
index 9aecd6daae..9fdf5ea622 100644
--- a/packages/nimble-components/src/number-field/index.ts
+++ b/packages/nimble-components/src/number-field/index.ts
@@ -13,6 +13,10 @@ import { buttonTag } from '../button';
import { iconMinusWideTag } from '../icons/minus-wide';
import { iconAddTag } from '../icons/add';
import { iconExclamationMarkTag } from '../icons/exclamation-mark';
+import {
+ numericDecrementLabel,
+ numericIncrementLabel
+} from '../label-provider/core/label-tokens';
declare global {
interface HTMLElementTagNameMap {
@@ -64,28 +68,28 @@ const nimbleNumberField = NumberField.compose({
shadowOptions: {
delegatesFocus: true
},
- stepDownGlyph: html`
+ stepDownGlyph: html`
<${buttonTag}
class="step-up-down-button"
appearance="ghost"
content-hidden
tabindex="-1"
>
- "Decrement"
+ ${x => numericDecrementLabel.getValueFor(x)}
<${iconMinusWideTag}
slot="start"
>
${iconMinusWideTag}>
${buttonTag}>
`,
- stepUpGlyph: html`
+ stepUpGlyph: html`
<${buttonTag}
class="step-up-down-button"
appearance="ghost"
content-hidden
tabindex="-1"
>
- "Increment"
+ ${x => numericIncrementLabel.getValueFor(x)}
<${iconAddTag}
slot="start">
${iconAddTag}>
diff --git a/packages/nimble-components/src/number-field/tests/number-field.spec.ts b/packages/nimble-components/src/number-field/tests/number-field.spec.ts
index a8d5d981c3..f4794170df 100644
--- a/packages/nimble-components/src/number-field/tests/number-field.spec.ts
+++ b/packages/nimble-components/src/number-field/tests/number-field.spec.ts
@@ -1,4 +1,21 @@
+import { html } from '@microsoft/fast-element';
import { NumberField, numberFieldTag } from '..';
+import {
+ LabelProviderCore,
+ labelProviderCoreTag
+} from '../../label-provider/core';
+import { waitForUpdatesAsync } from '../../testing/async-helpers';
+import { ThemeProvider, themeProviderTag } from '../../theme-provider';
+import { fixture, type Fixture } from '../../utilities/tests/fixture';
+
+async function setupWithLabelProvider(): Promise> {
+ return fixture(html`
+ <${themeProviderTag}>
+ <${labelProviderCoreTag}>${labelProviderCoreTag}>
+ <${numberFieldTag}>${numberFieldTag}>
+ ${themeProviderTag}>
+ `);
+}
describe('NumberField', () => {
it('should export its tag', () => {
@@ -11,3 +28,41 @@ describe('NumberField', () => {
);
});
});
+
+describe('NumberField with LabelProviderCore', () => {
+ let element: NumberField;
+ let labelProvider: LabelProviderCore;
+ let connect: () => Promise;
+ let disconnect: () => Promise;
+
+ beforeEach(async () => {
+ let themeProvider: ThemeProvider;
+ ({
+ element: themeProvider,
+ connect,
+ disconnect
+ } = await setupWithLabelProvider());
+ await connect();
+ element = themeProvider.querySelector(numberFieldTag)!;
+ labelProvider = themeProvider.querySelector(labelProviderCoreTag)!;
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('uses CoreLabelProvider numericIncrement/numericDecrement labels for the inc/dec buttons', async () => {
+ labelProvider.numericIncrement = 'Customized Increment';
+ labelProvider.numericDecrement = 'Customized Decrement';
+ await waitForUpdatesAsync();
+
+ const actualIncrementText = element
+ .shadowRoot!.querySelector('slot[name="step-up-glyph"]')!
+ .textContent!.trim();
+ expect(actualIncrementText).toEqual('Customized Increment');
+ const actualDecrementText = element
+ .shadowRoot!.querySelector('slot[name="step-down-glyph"]')!
+ .textContent!.trim();
+ expect(actualDecrementText).toEqual('Customized Decrement');
+ });
+});
diff --git a/packages/nimble-components/src/number-field/tests/number-field.stories.ts b/packages/nimble-components/src/number-field/tests/number-field.stories.ts
index 9fbfe9c48e..d5d2fc5295 100644
--- a/packages/nimble-components/src/number-field/tests/number-field.stories.ts
+++ b/packages/nimble-components/src/number-field/tests/number-field.stories.ts
@@ -4,8 +4,17 @@ import type { Meta, StoryObj } from '@storybook/html';
import { createUserSelectedThemeStory } from '../../utilities/tests/storybook';
import { NumberFieldAppearance } from '../types';
import { numberFieldTag } from '..';
+import { labelProviderCoreTag } from '../../label-provider/core';
+import {
+ addLabelUseMetadata,
+ type LabelUserArgs
+} from '../../label-provider/base/tests/label-user-stories-utils';
+import {
+ numericDecrementLabel,
+ numericIncrementLabel
+} from '../../label-provider/core/label-tokens';
-interface NumberFieldArgs {
+interface NumberFieldArgs extends LabelUserArgs {
label: string;
value: number;
step: number;
@@ -71,6 +80,12 @@ const metadata: Meta = {
errorText: 'Value is invalid'
}
};
+addLabelUseMetadata(
+ metadata,
+ labelProviderCoreTag,
+ numericDecrementLabel,
+ numericIncrementLabel
+);
export default metadata;
diff --git a/packages/nimble-components/src/table/components/cell/template.ts b/packages/nimble-components/src/table/components/cell/template.ts
index 3d139147a9..2d835667ac 100644
--- a/packages/nimble-components/src/table/components/cell/template.ts
+++ b/packages/nimble-components/src/table/components/cell/template.ts
@@ -6,6 +6,7 @@ import {
ButtonAppearance,
MenuButtonToggleEventDetail
} from '../../../menu-button/types';
+import { tableCellActionMenuLabel } from '../../../label-provider/table/label-tokens';
// prettier-ignore
export const template = html`
@@ -21,7 +22,7 @@ export const template = html`
class="action-menu"
>
<${iconThreeDotsLineTag} slot="start">${iconThreeDotsLineTag}>
- ${x => x.actionMenuLabel}
+ ${x => x.actionMenuLabel ?? tableCellActionMenuLabel.getValueFor(x)}
${menuButtonTag}>
`)}
diff --git a/packages/nimble-components/src/table/components/group-row/template.ts b/packages/nimble-components/src/table/components/group-row/template.ts
index 4f80284dcc..01c23cea95 100644
--- a/packages/nimble-components/src/table/components/group-row/template.ts
+++ b/packages/nimble-components/src/table/components/group-row/template.ts
@@ -5,6 +5,10 @@ import { buttonTag } from '../../../button';
import { ButtonAppearance } from '../../../button/types';
import { iconArrowExpanderRightTag } from '../../../icons/arrow-expander-right';
import { checkboxTag } from '../../../checkbox';
+import {
+ tableGroupCollapseLabel,
+ tableGroupExpandLabel
+} from '../../../label-provider/table/label-tokens';
// prettier-ignore
export const template = html`
@@ -33,6 +37,7 @@ export const template = html`
tabindex="-1"
>
<${iconArrowExpanderRightTag} ${ref('expandIcon')} slot="start" class="expander-icon ${x => x.animationClass}">${iconArrowExpanderRightTag}>
+ ${x => (x.expanded ? tableGroupCollapseLabel.getValueFor(x) : tableGroupExpandLabel.getValueFor(x))}
${buttonTag}>
diff --git a/packages/nimble-components/src/table/components/header/template.ts b/packages/nimble-components/src/table/components/header/template.ts
index f7a1c0afc0..cf0f48dcfb 100644
--- a/packages/nimble-components/src/table/components/header/template.ts
+++ b/packages/nimble-components/src/table/components/header/template.ts
@@ -4,6 +4,7 @@ import { iconArrowDownTag } from '../../../icons/arrow-down';
import { iconArrowUpTag } from '../../../icons/arrow-up';
import { iconTwoSquaresInBracketsTag } from '../../../icons/two-squares-in-brackets';
import { TableColumnSortDirection } from '../../types';
+import { tableColumnHeaderGroupedIndicatorLabel } from '../../../label-provider/table/label-tokens';
// prettier-ignore
export const template = html`
@@ -13,15 +14,15 @@ export const template = html`
@mousedown="${(_x, c) => !((c.event as MouseEvent).detail > 1)}"
>
-
+ ${'' /* Omit title attribute for sort indicators because aria-sort is set on the 1st sorted column */}
${when(x => x.sortDirection === TableColumnSortDirection.ascending, html`
<${iconArrowUpTag} class="sort-indicator" aria-hidden="true">${iconArrowUpTag}>
`)}
${when(x => x.sortDirection === TableColumnSortDirection.descending, html`
<${iconArrowDownTag} class="sort-indicator" aria-hidden="true">${iconArrowDownTag}>
`)}
- ${when(x => x.isGrouped, html`
- <${iconTwoSquaresInBracketsTag} class="grouped-indicator">${iconTwoSquaresInBracketsTag}>
+ ${when(x => x.isGrouped, html`
+ <${iconTwoSquaresInBracketsTag} class="grouped-indicator" title="${x => tableColumnHeaderGroupedIndicatorLabel.getValueFor(x)}">${iconTwoSquaresInBracketsTag}>
`)}
`;
diff --git a/packages/nimble-components/src/table/template.ts b/packages/nimble-components/src/table/template.ts
index 77368bef7c..dc9df1b544 100644
--- a/packages/nimble-components/src/table/template.ts
+++ b/packages/nimble-components/src/table/template.ts
@@ -23,6 +23,7 @@ import { buttonTag } from '../button';
import { ButtonAppearance } from '../button/types';
import { iconTriangleTwoLinesHorizontalTag } from '../icons/triangle-two-lines-horizontal';
import { checkboxTag } from '../checkbox';
+import { tableGroupsCollapseAllLabel } from '../label-provider/table/label-tokens';
// prettier-ignore
export const template = html`
@@ -56,9 +57,11 @@ export const template = html`
class="collapse-all-button ${x => `${x.showCollapseAll ? 'visible' : ''}`}"
content-hidden
appearance="${ButtonAppearance.ghost}"
+ title="${x => tableGroupsCollapseAllLabel.getValueFor(x)}"
@click="${x => x.handleCollapseAllGroupRows()}"
>
<${iconTriangleTwoLinesHorizontalTag} slot="start">${iconTriangleTwoLinesHorizontalTag}>
+ ${x => tableGroupsCollapseAllLabel.getValueFor(x)}
${buttonTag}>
diff --git a/packages/nimble-components/src/table/tests/table-labels.spec.ts b/packages/nimble-components/src/table/tests/table-labels.spec.ts
new file mode 100644
index 0000000000..89d0d3cd44
--- /dev/null
+++ b/packages/nimble-components/src/table/tests/table-labels.spec.ts
@@ -0,0 +1,128 @@
+import { html } from '@microsoft/fast-element';
+import { tableTag, type Table } from '..';
+import { waitForUpdatesAsync } from '../../testing/async-helpers';
+import { type Fixture, fixture } from '../../utilities/tests/fixture';
+import type { TableRecord } from '../types';
+import { TablePageObject } from '../testing/table.pageobject';
+import { themeProviderTag, type ThemeProvider } from '../../theme-provider';
+import {
+ LabelProviderTable,
+ labelProviderTableTag
+} from '../../label-provider/table';
+import type { TableColumnText } from '../../table-column/text';
+import { tableGroupRowTag } from '../components/group-row';
+
+interface SimpleTableRecord extends TableRecord {
+ stringData: string;
+ moreStringData: string;
+}
+
+const simpleTableData = [
+ {
+ stringData: 'string 1',
+ moreStringData: 'foo'
+ },
+ {
+ stringData: 'hello world',
+ moreStringData: 'foo'
+ },
+ {
+ stringData: 'another string',
+ moreStringData: 'foo'
+ }
+] as const;
+
+// prettier-ignore
+async function setup(): Promise> {
+ return fixture(
+ html`
+ <${themeProviderTag}>
+ <${labelProviderTableTag}>${labelProviderTableTag}>
+
+ stringData
+ moreStringData
+
+ <${themeProviderTag}>`
+ );
+}
+
+describe('Table with LabelProviderTable', () => {
+ let element: Table;
+ let labelProvider: LabelProviderTable;
+ let connect: () => Promise;
+ let disconnect: () => Promise;
+ let pageObject: TablePageObject;
+ let column1: TableColumnText;
+
+ beforeEach(async () => {
+ let themeProvider: ThemeProvider;
+ ({ element: themeProvider, connect, disconnect } = await setup());
+ element = themeProvider.querySelector(tableTag)!;
+ labelProvider = themeProvider.querySelector(labelProviderTableTag)!;
+
+ pageObject = new TablePageObject(element);
+ column1 = element.querySelector('#first-column')!;
+ });
+
+ afterEach(async () => {
+ await disconnect();
+ });
+
+ it('uses correct labels when a column is grouped (groupCollapse/groupExpand/groupsCollapseAll/columnHeaderGroupedIndicator)', async () => {
+ await element.setData(simpleTableData);
+ await connect();
+
+ labelProvider.groupsCollapseAll = 'Customized Collapse All';
+ labelProvider.groupExpand = 'Customized Expand';
+ labelProvider.groupCollapse = 'Customized Collapse';
+ labelProvider.columnHeaderGroupedIndicator = 'Customized Grouped';
+ column1.groupIndex = 0;
+ await waitForUpdatesAsync();
+
+ const actualGroupsCollapseAllLabel = element
+ .shadowRoot!.querySelector('.collapse-all-button')!
+ .textContent!.trim();
+ expect(actualGroupsCollapseAllLabel).toBe('Customized Collapse All');
+ const actualColumnHeaderGroupedIndicatorLabel = pageObject
+ .getHeaderElement(0)
+ .shadowRoot!.querySelector('.grouped-indicator')!
+ .getAttribute('title');
+ expect(actualColumnHeaderGroupedIndicatorLabel).toBe(
+ 'Customized Grouped'
+ );
+ const firstGroupRow: HTMLElement = element.shadowRoot!.querySelector(tableGroupRowTag)!;
+ const actualGroupCollapseLabel = firstGroupRow
+ .shadowRoot!.querySelector('.expand-collapse-button')!
+ .textContent!.trim();
+ expect(actualGroupCollapseLabel).toBe('Customized Collapse');
+
+ firstGroupRow.click();
+ await waitForUpdatesAsync();
+
+ const actualGroupExpandLabel = firstGroupRow
+ .shadowRoot!.querySelector('.expand-collapse-button')!
+ .textContent!.trim();
+ expect(actualGroupExpandLabel).toBe('Customized Expand');
+ });
+
+ it('uses correct labels when a column has an action menu (cellActionMenu)', async () => {
+ const slot = 'my-action-menu';
+ column1.actionMenuSlot = slot;
+ const menu = document.createElement('nimble-menu');
+ const menuItem1 = document.createElement('nimble-menu-item');
+ menuItem1.textContent = 'menu item 1';
+ menu.appendChild(menuItem1);
+ menu.slot = slot;
+ element.appendChild(menu);
+ await element.setData(simpleTableData);
+ await connect();
+
+ labelProvider.cellActionMenu = 'Customized Cell Options';
+ await waitForUpdatesAsync();
+
+ const actualCellActionMenuLabel = pageObject
+ .getCellActionMenu(0, 0)!
+ .textContent!.trim();
+ expect(actualCellActionMenuLabel).toBe('Customized Cell Options');
+ });
+});
diff --git a/packages/nimble-components/src/table/tests/table.stories.ts b/packages/nimble-components/src/table/tests/table.stories.ts
index bec06acd6c..58d9189cbf 100644
--- a/packages/nimble-components/src/table/tests/table.stories.ts
+++ b/packages/nimble-components/src/table/tests/table.stories.ts
@@ -12,8 +12,13 @@ import { iconUserTag } from '../../icons/user';
import { menuTag } from '../../menu';
import { menuItemTag } from '../../menu-item';
import { tableColumnTextTag } from '../../table-column/text';
+import {
+ addLabelUseMetadata,
+ type LabelUserArgs
+} from '../../label-provider/base/tests/label-user-stories-utils';
+import { labelProviderTableTag } from '../../label-provider/table';
-interface TableArgs {
+interface TableArgs extends LabelUserArgs {
data: ExampleDataType;
selectionMode: keyof typeof TableRowSelectionMode;
idFieldName: undefined;
@@ -269,6 +274,7 @@ const metadata: Meta = {
}
}
};
+addLabelUseMetadata(metadata, labelProviderTableTag);
export default metadata;