diff --git a/change/@ni-nimble-components-56e4142d-0498-4095-8df5-2b8e3c2a30cd.json b/change/@ni-nimble-components-56e4142d-0498-4095-8df5-2b8e3c2a30cd.json new file mode 100644 index 0000000000..c1cfebfb6c --- /dev/null +++ b/change/@ni-nimble-components-56e4142d-0498-4095-8df5-2b8e3c2a30cd.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "Mapping table column spec", + "packageName": "@ni/nimble-components", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/nimble-components/src/table/specs/README.md b/packages/nimble-components/src/table/specs/README.md index 7def9aaaba..4b9b371368 100644 --- a/packages/nimble-components/src/table/specs/README.md +++ b/packages/nimble-components/src/table/specs/README.md @@ -107,6 +107,7 @@ The various APIs/features of the `nimble-table` will be split up amongst several - [TableColumnText](table-column-specs/table-column-text-field.md) - [Formatted Text Columns](table-column-specs/table-column-formatted-text.md) - [TableColumnAnchor](table-column-specs/table-column-anchor-hld.md) + - [TableColumnMapping](table-column-specs/table-column-mapping.md) - Headers - Define the anatomy of headers in the table DOM - What is the component to use for interaction? Outline Button? Ghost button? diff --git a/packages/nimble-components/src/table/specs/table-column-specs/table-column-mapping.md b/packages/nimble-components/src/table/specs/table-column-specs/table-column-mapping.md new file mode 100644 index 0000000000..477f9d25b0 --- /dev/null +++ b/packages/nimble-components/src/table/specs/table-column-specs/table-column-mapping.md @@ -0,0 +1,341 @@ +# Mapping Table Column + +## Overview + +The `nimble-table-column-mapping` is a component that supports rendering specific number, boolean, or string values as mapped text. `nimble-table-column-icon` is a specialized version of `nimble-table-column-mapping` that instead maps values to icons and/or spinners and has a minimal, fixed width. The actual mappings are defined by child elements `nimble-mapping-icon`, `nimble-mapping-spinner`, and `nimble-mapping-text`. + +### Background + +[Icon column type issue](https://github.com/ni/nimble/issues/1013) + +[Boolean column type issue](https://github.com/ni/nimble/issues/1103) + +### Features + +- Supported input: + - string + - number + - boolean +- Supported output: + - Text + - Icon + - Spinner + - (empty) + +### Non-goals + +- Non-Nimble icon support +- Arbitrary icon colors +- Hyperlink icons +- Mixed text and icons +- Non-icon, non-spinner Nimble components + +--- + +## Design + +Below is an example of how these elements would be used within a `nimble-table`: + +```HTML + + + Status + + + + + + + Error Code + + + + + + Archived + + + +``` + +Each column contains mapping elements that define what to render when the cell's value matches the given `key` value. + +When none of the given mappings match the record value for a cell, that cell will be empty. Alternatively, if one of the mappings has the `default-mapping` attribute, it will match when no other mappings have. This is equivalent to the `placeholder` configuration we provide on `nimble-table-column-text` and `nimble-table-column-anchor`. + +The column will translate its contained mapping elements into a map that is stored in the `columnConfig`. + +Validation will be performed to ensure each mapping's key value can be converted to the `key-type` of the column. If not, an error flag will be set on the column's validation object. Note that whenever an error flag is set on the column's validation object, a generic `invalidColumnConfiguration` flag is also set on the table, putting it in an invalid state as well. + +If multiple mappings in a column have the same key, an error flag will be set on the column's validity object. + +If an invalid `icon` value is passed to `nimble-mapping-icon`, an error flag will be set on the column's validity object. An invalid `icon` value is any element that cannot be resolved or that does not derive from `Icon`. + +`nimble-table-column-icon` supports only `nimble-mapping-icon` and `nimble-mapping-spinner` as mapping elements. `nimble-table-column-mapping` supports only `nimble-mapping-text`. Unsupported mappings will result in an error flag being set on the column's validity object. + +Text in a grouping header or in the cell will be ellipsized and gain a tooltip when the full text is too long to display. + +**Alternatives:** + +An earlier version of this spec proposed mapping elements with `template` elements as content instead of relying on attribute configuration. The template element would define the mapped html to render. We would impose restrictions on the types of supported elements that could be provided in the template. + +Pros: + +- It would not require updates to the API if we needed to support new types of mapped content (e.g. icon with text), or if the mapped content itself got new configuration options (e.g. a scaling factor for icons). +- Undefined element types caught at compile time. + +Cons: + +- Verbose. Requires user to create `template` element and wrap text in `span`s for styling purposes. +- Requires difficult validation to ensure only supported elements are present in the `template`. +- Could allow users to provide inline styling. +- Blazor: cannot put Blazor components inside `template`--must use raw Nimble elements without type safety + +### API + +#### Icon column element: + +_Component Name_ + +- `nimble-table-column-icon` + +_Props/Attrs_ + +- `field-name`: string +- `key-type`: 'string' | 'number' | 'boolean' +- `pixel-width`: number (set to the desired fixed column width, else will use a default fixed width) + +_Content_ + +- column title (text or icon) +- 1 or more `nimble-mapping-icon` or `nimble-mapping-spinner` elements + +#### General mapping column element: + +_Component Name_ + +- `nimble-table-column-mapping` + +_Props/Attrs_ + +- `field-name`: string +- `key-type`: 'string' | 'number' | 'boolean' +- `fractional-width`: number (defaults to 1) +- `min-pixel-width`: number (defaults to minimum supported by table) + +_Content_ + +- column title (text or icon) +- 1 or more `nimble-mapping-text` elements + +#### Mapping element (icon): + +_Component Name_ + +- `nimble-mapping-icon` + +_Props/Attrs_ + +- `key`: string | number | boolean | undefined +- `icon`: string - name of the Nimble icon element +- `severity`: string - one of the supported enum values. Controls color of the icon. +- `label`: string - localized value used as the accessible name and `title` of the icon. Will also be displayed in the group header. +- `default-mapping`: boolean - presence causes this mapping to be used when no others match the value + +#### Mapping element (spinner): + +_Component Name_ + +- `nimble-mapping-spinner` + +_Props/Attrs_ + +- `key`: string | number | boolean | undefined +- `label`: string - localized value used as the accessible name and `title` of the spinner. Will also be displayed in the group header. +- `default-mapping`: boolean - presence causes this mapping to be used when no others match the value + +#### Mapping element (text): + +_Component Name_ + +- `nimble-mapping-text` + +_Props/Attrs_ + +- `key`: string | number | boolean | undefined +- `label`: string - display text +- `default-mapping`: boolean - presence causes this mapping to be used when no others match the value + +### Anatomy + +#### `nimble-table-column-mapping` + +```HTML + +``` + +Cell view: + +The cell view relies on the matched mapping to provide a template to render. + +```HTML +html`${x => x.getMappingToRender().cellViewTemplate}` +``` + +Group header view: + +Similarly, the group header view relies on the matched mapping to provide a template to render. + +```HTML +html`${x => x.getMappingToRender().groupHeaderViewTemplate}` +``` + +#### `nimble-mapping-*` + +```HTML + +``` + +#### `nimble-mapping-icon` + +`mapping.cellViewTemplate`: + +```HTML +<${this.icon} + title="${x => x.label}" + aria-label="${x => x.label}" + severity="${x => x.severity}"> + +``` + +`mapping.groupHeaderViewTemplate`: + +```HTML +<${this.icon} + title="${x => x.label}" + aria-label="${x => x.label}" + severity="${x => x.severity}"> + + (x.isValidContentAndHasOverflow ? x.label : null)}> + ${x => x.label} +`; +``` + +### Grouping + +Grouping will be based on the record value. The grouping header will display the rendered icon/spinner/text. In the case of an icon/spinner, it will also be followed by the `label` text. This will disambiguate cases where multiple record values map to the same icon (assuming the labels are different). + +For values that do not match any mapping, we will display the raw data value. While this introduces inconsistency, it seems preferable to the alternative, which is having multiple, separate groupings with a blank header (well, with just the item count in parens). Even in the case where there is a default mapping, we would still end up with separate groups with the identical default mapped icon and/or text, which is just as bad. + +Text in a grouping header will be ellipsized and gain a tooltip if there is not enough room to display it all. + +### Sorting + +Sorting will be based on the record value. For boolean and number values, a basic sort (just using basic comparison/equality operators) is the clear choice. For string values, it is less clear. In the case where the strings are enum values, they are likely to be non-localized, English strings. Using a basic (alphabetical) sort is not unreasonable. However, if there is a semantically meaningful sort order (e.g. "NOT_STARTED" < "RUNNING" < "DONE"), it would be nice to sort by that. We can only infer that semantic order from the order in which the mappings are given, e.g.: + +```HTML + + + +``` + +We would need new support for sorting this way. We could define a new sorting option, "enumerated sort", where the column provides an ordered list of values, and the table sorts the column based on that given order. To compare the relative order of two values, we have to search the list, making the sort operation a bit more expensive, but still probably reasonable except in the case of enums with many values. + +Our options are to use a basic sort (for all three value types), use an enumerated sort (again, for all three value types), or to start with a basic sort and implement the enumerated sort at a later time. Settling for a basic sort is reasonable and the easiest solution, but it would not give as nice an experience as an enumerated sort. The enumerated sort would expand the scope of this feature and could easily be implemented as a standalone change. For those reasons, I propose we use a basic sort for the initial submission of the column type, and add the enumerated sort and adopt that for the mapping/icon column as follow-on work. + +For icons, if multiple values map to the same icon, it is possible that sorting will result in the instances of a certain icon not being all together in one span of rows. Users will be expected to provide visually distinct icons for each mapping as the column will not enforce or validate distinct icons for each mapping. + +### Sizing + +`nimble-table-column-icon` will support only a fixed width. We will introduce a new mixin for fixed-width support that exposes a `pixel-width` property. The default value will be the minimum supported by the table, which is still significantly larger than the width of an icon. + +`nimble-table-column-mapping` will support fixed or fractional widths. If `pixel-width` is set, the column will have a fixed width, otherwise it defaults to a fractional width of 1. The client may configure `fractional-width` and/or `min-pixel-width`. + +### Angular integration + +Angular directives will be created for the column components and the mapping components. No component has form association, so a `ControlValueAccessor` will not be created. + +### Blazor integration + +Blazor wrappers will be created for the components. Columns will be generic in the type of the key, and will cascade that type parameter to contained mapping elements (see [`CascadingTypeParameter`](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/generic-type-support?view=aspnetcore-7.0#cascaded-generic-type-support)): + +```HTML + + + + +``` + +### Visual Appearance + +The cell view (and group header view) will be responsible for styling the templates returned by the mappings. This will include alignment and spacing (`--ni-nimble-small-padding`). + +--- + +## Implementation + +### States + +N/A + +### Accessibility + +Text, icons, and spinner are not interactive and cannot receive keyboard focus. These items already have proper accessibility roles, and we will set accessible names (`aria-label`) based on the `label` value provided by the client. + +### Globalization + +All text will be provided by the client and is expected to be localized. + +### Security + +N/A + +### Performance + +N/A + +### Dependencies + +None + +### Test Plan + +- Unit tests will be written verifying the usual component expectations, plus: + - renders mapping matching the cell value (string, number, and boolean) + - nothing rendered when value matches no mappings + - validation error when non-unique mapping keys exist + - validation error when multiple mappings marked as default + - validation error when mapping key is null and not marked default + - validation error when invalid icon name given + - validation error when icon column has a `nimble-mapping-text` element + - grouping header for icon column includes label +- Verify manually that the column content appears in the accessibility tree and can be read by a screen reader. +- Verify manually that several mapping columns with thousands of elements scrolls performantly. +- Visual Chromatic tests will be created + +### Tooling + +N/A + +### Documentation + +Documented in Storybook + +--- + +## Open Issues diff --git a/packages/nimble-components/src/table/specs/table-columns-hld.md b/packages/nimble-components/src/table/specs/table-columns-hld.md index 679c3c4ca2..c76d0d3059 100644 --- a/packages/nimble-components/src/table/specs/table-columns-hld.md +++ b/packages/nimble-components/src/table/specs/table-columns-hld.md @@ -437,6 +437,89 @@ Clients should be allowed to use arbitrary content for the display part of a hea ``` +### Validation + +A table column's public validation API consists of a `checkValidity()` function and a `validity` property. The `checkValidity()` function simply returns the value of a `validConfiguration` flag from the column's internals which should be `true` when the column's configuration is valid, and `false` when it is not. The `validity` property's value is an object that describes the specific ways the configuration may be invalid. By default, it returns an empty object. If a column type has configuration which can be invalid, it should define a column validator object to manage this state. There is a base `ColumnValidator` type that manages the state of the `columnInternals.validConfiguration` flag. It also manages an object suitable to be returned by the `validity` property. It is up to the column author to override the `validity` accessor to return this object. + +```TS +export class ColumnValidator { + protected configValidity: ObjectFromList; + + public isValid(): boolean { + return Object.values(this.configValidity).every(x => !x); + } + + public getValidity(): ValidityObject { + return { + ...this.configValidity + }; + } + + protected setConditionValue( + name: ValidityFlagNames extends readonly (infer U)[] ? U : never, + isInvalid: boolean + ): void { + this.configValidity[name] = isInvalid; + this.updateColumnInternalsFlag(); + } +``` + +By deriving from this base type, a column can easily validate specific conditions of its validity: + +```TS +const configValidity = [ + 'hasMultipleDefaultMappings', + 'hasUnsupportedMappingTypes', + ... +] as const; + +class TableColumnIconValidator extends ColumnValidator { + public constructor(columnInternals: ColumnInternals) { + super(columnInternals, configValidity); + } + + public validateNoMultipleDefaultMappings(mappings: Mapping[]): void { + ... + this.setConditionValue('hasMultipleDefaultMappings', foundMultiple); + } + + public validateNoUnsupportedMappingTypes(mappings: Mapping[]): void { + ... + this.setConditionValue('hasUnsupportedMappingTypes', foundUnsupported); + } + ... +} +``` + +The column type will respond to changes in properties by calling the validator's validation functions: + +```TS +private mappingsChanged(): void { + this.validator.validateNoMultipleDefaultMappings(this.mappings); + this.validator.validateNoUnsupportedMappingTypes(this.mappings); +} +``` + +The table's validity object has a property to represent the validity of all of its columns: + +```TS +export class TableValidator { + private invalidColumnConfiguration: boolean; // true if one or more invalid columns + public isValid(): boolean { + ... + && !this.invalidColumnConfiguration + ... + } + + public validateColumns(columns: TableColumn[]): boolean { + this.invalidColumnConfiguration = columns.some(x => !x.checkValidity()); + return !this.invalidColumnConfiguration; + } +} +``` + +The `validateColumns()` function is one of the multiple validation functions called from `validate()`, which in turn is called when a queued update is executed. + ## Alternative Implementations / Designs ### Programmatic API