Skip to content

Commit

Permalink
Icon table column (#1418)
Browse files Browse the repository at this point in the history
## 🀨 Rationale

Part of #1013

## πŸ‘©β€πŸ’» Implementation

Created new `nimble-table-column-icon` component plus the supporting
elements `nimble-mapping-icon` and `nimble-mapping-spinner`.

Angular and Blazor support will be implemented in follow-on PRs.

## πŸ§ͺ Testing

Unit tests written for all new components. Tested in Storybook.

## βœ… Checklist

- [x] I have updated the project documentation to reflect my changes or
determined no changes are needed.

---------

Co-authored-by: Jonathan Meyer <[email protected]>
  • Loading branch information
m-akinc and atmgrifter00 authored Aug 21, 2023
1 parent 04c76db commit 4f0f474
Show file tree
Hide file tree
Showing 37 changed files with 1,574 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Icon table column",
"packageName": "@ni/nimble-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
3 changes: 3 additions & 0 deletions packages/nimble-components/src/all-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import './label-provider/core';
import './label-provider/table';
import './list-option';
import './mapping/text';
import './mapping/icon';
import './mapping/spinner';
import './menu';
import './menu-button';
import './menu-item';
Expand All @@ -42,6 +44,7 @@ import './table';
import './table-column/anchor';
import './table-column/date-text';
import './table-column/enum-text';
import './table-column/icon';
import './table-column/text';
import './tabs';
import './tabs-toolbar';
Expand Down
82 changes: 82 additions & 0 deletions packages/nimble-components/src/mapping/icon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { attr, observable } from '@microsoft/fast-element';
import { DesignSystem } from '@microsoft/fast-foundation';
import { Mapping } from '../base';
import { template } from '../base/template';
import type { IconSeverity } from '../../icon-base/types';
import { Icon } from '../../icon-base';

declare global {
interface HTMLElementTagNameMap {
'nimble-mapping-icon': MappingIcon;
}
}

function isIconClass(elementClass: CustomElementConstructor): boolean {
return elementClass.prototype instanceof Icon;
}

/**
* Maps a data value to an icon.
* One or more may be added as children of a nimble-table-column-icon element to define
* how specific data values should be displayed as icons in that column's cells.
*/
export class MappingIcon extends Mapping {
@attr()
public icon?: string;

@attr()
public severity: IconSeverity;

/**
* @internal
* Calculated asynchronously by the icon mapping based on the configured icon value.
* When assigned, it corresponds to an element name that is resolved to type of Nimble Icon.
*/
@observable
public resolvedIcon?: string;

// Allow icons to be defined asynchronously from when the property is configured
private async resolveIconAsync(icon: string): Promise<void> {
try {
// Clear the current resolution while waiting for async resolution
this.resolvedIcon = undefined;
await customElements.whenDefined(icon);
} catch (ex) {
// If any error (i.e. invalid custom element name) don't continue
// Don't update the resolvedIcon as it was already set to undefined before async resolution
// (in case other async resolutions were started)
return;
}

if (icon !== this.icon) {
// Possible the icon has changed while waiting for async resolution
// Don't update the resolvedIcon as it was already set to undefined before async resolution
// (in case other async resolutions were started)
return;
}

const elementClass = customElements.get(icon)!;
this.resolvedIcon = isIconClass(elementClass) ? icon : undefined;
}

private iconChanged(): void {
const icon = this.icon;
if (!icon) {
this.resolvedIcon = undefined;
return;
}
const elementClass = customElements.get(icon);
if (elementClass) {
this.resolvedIcon = isIconClass(elementClass) ? icon : undefined;
return;
}
void this.resolveIconAsync(icon);
}
}

const iconMapping = MappingIcon.compose({
baseName: 'mapping-icon',
template
});
DesignSystem.getOrCreate().withPrefix('nimble').register(iconMapping());
export const mappingIconTag = DesignSystem.tagFor(MappingIcon);
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { html } from '@microsoft/fast-element';
import { MappingIcon, mappingIconTag } from '..';
import {
fixture,
uniqueElementName,
type Fixture
} from '../../../utilities/tests/fixture';
import { waitForUpdatesAsync } from '../../../testing/async-helpers';
import { Icon, registerIcon } from '../../../icon-base';

describe('Icon Mapping', () => {
const testIconElementName = uniqueElementName();
class TestIcon extends Icon {}

let element: MappingIcon;
let connect: () => Promise<void>;
let disconnect: () => Promise<void>;

// prettier-ignore
async function setup(): Promise<Fixture<MappingIcon>> {
return fixture<MappingIcon>(html`
<${mappingIconTag}
key="foo"
text="foo"
icon="nimble-${testIconElementName}">
</${mappingIconTag}>`);
}

it('should export its tag', () => {
expect(mappingIconTag).toBe('nimble-mapping-icon');
});

it('can construct an element instance', () => {
expect(document.createElement('nimble-mapping-icon')).toBeInstanceOf(
MappingIcon
);
});

it('resolves icon after it is defined', async () => {
({ element, connect, disconnect } = await setup());
await connect();
await waitForUpdatesAsync();

expect(element.resolvedIcon).toBeUndefined();

registerIcon(testIconElementName, TestIcon);
await waitForUpdatesAsync();

expect(element.resolvedIcon).toBeDefined();

await disconnect();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { html } from '@microsoft/fast-element';
import type { Meta, StoryObj } from '@storybook/html';
import { createUserSelectedThemeStory } from '../../../utilities/tests/storybook';
import { hiddenWrapper } from '../../../utilities/tests/hidden';
import { mappingKeyDescription } from '../../base/tests/story-helpers';

const metadata: Meta = {
title: 'Internal/Mappings',
parameters: {
docs: {
description: {
component:
'The `nimble-mapping-icon` element defines a mapping from a data value to an icon representation to use for that value. It is meant to be used as content of the `nimble-table-column-icon` element.'
}
}
}
};

export default metadata;

export const iconMapping: StoryObj = {
render: createUserSelectedThemeStory(hiddenWrapper(html`<style></style>`)),
argTypes: {
key: {
description: mappingKeyDescription('the mapped icon'),
control: { type: 'none' }
},
icon: {
control: { type: 'none' },
description:
'The tag name of the Nimble icon to render, e.g. `nimble-icon-check`.'
},
severity: {
description:
'Must be one of the values in the `IconSeverity` enum. Controls the color of the icon.'
},
text: {
description:
'A textual description of the value which will be used as the tooltip and accessible name of the icon. The text is also displayed next to the icon in a group header. This attribute is required.'
}
},
args: {}
};
23 changes: 23 additions & 0 deletions packages/nimble-components/src/mapping/spinner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { DesignSystem } from '@microsoft/fast-foundation';
import { Mapping } from '../base';
import { template } from '../base/template';

declare global {
interface HTMLElementTagNameMap {
'nimble-mapping-spinner': MappingSpinner;
}
}

/**
* Maps data values to a spinner.
* One or more may be added as children of a nimble-table-column-icon element to define
* which specific data values should be displayed as spinners in that column's cells.
*/
export class MappingSpinner extends Mapping {}

const spinnerMapping = MappingSpinner.compose({
baseName: 'mapping-spinner',
template
});
DesignSystem.getOrCreate().withPrefix('nimble').register(spinnerMapping());
export const mappingSpinnerTag = DesignSystem.tagFor(MappingSpinner);
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MappingSpinner, mappingSpinnerTag } from '..';

describe('Spinner Mapping', () => {
it('should export its tag', () => {
expect(mappingSpinnerTag).toBe('nimble-mapping-spinner');
});

it('can construct an element instance', () => {
expect(document.createElement('nimble-mapping-spinner')).toBeInstanceOf(
MappingSpinner
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { html } from '@microsoft/fast-element';
import type { Meta, StoryObj } from '@storybook/html';
import { createUserSelectedThemeStory } from '../../../utilities/tests/storybook';
import { hiddenWrapper } from '../../../utilities/tests/hidden';
import { mappingKeyDescription } from '../../base/tests/story-helpers';

const metadata: Meta = {
title: 'Internal/Mappings',
parameters: {
docs: {
description: {
component:
'The `nimble-mapping-spinner` element defines a mapping from a data value to the Nimble spinner. It is meant to be used as content of the `nimble-table-column-icon` element.'
}
}
}
};

export default metadata;

export const spinnerMapping: StoryObj = {
render: createUserSelectedThemeStory(hiddenWrapper(html`<style></style>`)),
argTypes: {
key: {
description: mappingKeyDescription('a spinner'),
control: { type: 'none' }
},
text: {
description:
'A textual description of the value which will be used as the tooltip and accessible name of the spinner. The text is also displayed next to the spinner in a group header. This attribute is required.'
}
},
args: {}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { MappingText, mappingTextTag } from '..';

describe('Text Mapping', () => {
it('should export its tag', () => {
expect(mappingTextTag).toBe('nimble-mapping-text');
});

it('can construct an element instance', () => {
expect(document.createElement('nimble-mapping-text')).toBeInstanceOf(
MappingText
);
});
});
6 changes: 4 additions & 2 deletions packages/nimble-components/src/spinner/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const styles = css`
:host {
height: ${spinnerSmallHeight};
aspect-ratio: 1 / 1;
--ni-private-spinner-animation-play-state: running;
}
div.container {
Expand Down Expand Up @@ -48,7 +47,10 @@ export const styles = css`
margin: auto;
animation-duration: 1600ms;
animation-iteration-count: infinite;
animation-play-state: var(--ni-private-spinner-animation-play-state);
animation-play-state: var(
--ni-private-spinner-animation-play-state,
running
);
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 0);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ describe('TableColumnAnchor', () => {
await disconnect();
});

it('should export its tag', () => {
expect(tableColumnAnchorTag).toBe('nimble-table-column-anchor');
});

it('can construct an element instance', () => {
expect(
document.createElement('nimble-table-column-anchor')
).toBeInstanceOf(TableColumnAnchor);
});

it('reports column configuration valid', async () => {
await connect();
await waitForUpdatesAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ describe('TableColumnDateText', () => {
await disconnect();
});

it('should export its tag', () => {
expect(tableColumnDateTextTag).toBe(
'nimble-table-column-date-text'
);
});

it('can construct an element instance', () => {
expect(
document.createElement('nimble-table-column-date-text')
).toBeInstanceOf(TableColumnDateText);
});

it('reports column configuration valid', () => {
expect(column.checkValidity()).toBeTrue();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export abstract class TableColumnEnumBase<

/**
* Called when any Mapping related state has changed.
* Implementations should run validation before updating the column config.
*/
private updateColumnConfig(): void {
this.validator.validate(this.mappings, this.keyType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
* Common state shared across Mapping Config
*/
export abstract class MappingConfig {
public constructor(public readonly text: string) {}
public constructor(public readonly text: string | undefined) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { html, type ViewTemplate } from '@microsoft/fast-element';
import type { IconSeverity } from '../../../icon-base/types';
import { MappingConfig } from './mapping-config';

export interface IconView {
severity: IconSeverity;
text?: string;
}
const createIconTemplate = (icon: string): ViewTemplate<IconView> => html`
<${icon}
title="${x => x.text}"
aria-label="${x => x.text}"
severity="${x => x.severity}"
class="no-shrink"
>
</${icon}>`;

/**
* Mapping configuration corresponding to a icon mapping
*/
export class MappingIconConfig extends MappingConfig {
public readonly iconTemplate: ViewTemplate<IconView>;
public constructor(
resolvedIcon: string,
public readonly severity: IconSeverity,
text: string | undefined
) {
super(text);
this.iconTemplate = createIconTemplate(resolvedIcon);
}
}
Loading

0 comments on commit 4f0f474

Please sign in to comment.