diff --git a/change/@ni-ok-blazor-39bf770b-9e85-4d46-ba51-c915ecd4c543.json b/change/@ni-ok-blazor-39bf770b-9e85-4d46-ba51-c915ecd4c543.json new file mode 100644 index 0000000000..bd8503ae99 --- /dev/null +++ b/change/@ni-ok-blazor-39bf770b-9e85-4d46-ba51-c915ecd4c543.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "add breakpoint column", + "packageName": "@ni/ok-blazor", + "email": "5265744+hellovolcano@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-ok-components-744c8788-8d43-48b6-9bff-7f72757113b5.json b/change/@ni-ok-components-744c8788-8d43-48b6-9bff-7f72757113b5.json new file mode 100644 index 0000000000..6e948dd9a7 --- /dev/null +++ b/change/@ni-ok-components-744c8788-8d43-48b6-9bff-7f72757113b5.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "add breakpoint column", + "packageName": "@ni/ok-components", + "email": "5265744+hellovolcano@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/blazor-workspace/CodeAnalysisDictionary.xml b/packages/blazor-workspace/CodeAnalysisDictionary.xml index 1dad826e57..b2c2aafaf6 100644 --- a/packages/blazor-workspace/CodeAnalysisDictionary.xml +++ b/packages/blazor-workspace/CodeAnalysisDictionary.xml @@ -4,8 +4,8 @@ args blazor - breakpoint bool + breakpoint clearable combobox dropdown diff --git a/packages/blazor-workspace/Examples/Demo.Shared/Pages/ComponentsDemo.razor b/packages/blazor-workspace/Examples/Demo.Shared/Pages/ComponentsDemo.razor index fc01301b41..12ac3706c7 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/ComponentsDemo.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/ComponentsDemo.razor @@ -45,5 +45,6 @@ + diff --git a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor new file mode 100644 index 0000000000..42f7a22bd0 --- /dev/null +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor @@ -0,0 +1,40 @@ +@namespace Demo.Shared.Pages.Sections + +
+ + + Name + + + Line + + + @if (_contextMenuRecordState == OkBlazor.BreakpointState.Off) + { + Add breakpoint + Add conditional breakpoint + } + else + { + Remove breakpoint + @if (_contextMenuRecordState == OkBlazor.BreakpointState.Enabled + || _contextMenuRecordState == OkBlazor.BreakpointState.Conditional + || _contextMenuRecordState == OkBlazor.BreakpointState.Hit) + { + Disable breakpoint + } + @if (_contextMenuRecordState == OkBlazor.BreakpointState.Disabled + || _contextMenuRecordState == OkBlazor.BreakpointState.HitDisabled) + { + Enable breakpoint + } + } + + + + +
diff --git a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor.cs b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor.cs new file mode 100644 index 0000000000..d2ee3351d1 --- /dev/null +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor.cs @@ -0,0 +1,99 @@ +using NimbleBlazor; +using OkBlazor; + +namespace Demo.Shared.Pages.Sections; + +public partial class TsBreakpointTableSection +{ + private NimbleTable? _table; + private string? _contextMenuRecordId; + private string _contextMenuRecordState = BreakpointState.Off; + + private List _tableData = new() + { + new("1", null, "Main.cs", 12, BreakpointState.Enabled), + new("2", "1", "Helper.cs", 45, BreakpointState.Off), + new("3", "1", "Service.cs", 78, BreakpointState.Disabled), + new("4", null, "Controller.cs", 23, BreakpointState.Hit), + new("5", "4", "Model.cs", 91, BreakpointState.Conditional), + new("6", null, "Startup.cs", 5, BreakpointState.HitDisabled), + new("7", "6", "Program.cs", 1, BreakpointState.Off), + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await _table!.SetDataAsync(_tableData); + await base.OnAfterRenderAsync(firstRender); + } + + private void OnBreakpointToggle(BreakpointColumnToggleEventArgs e) + { + var record = _tableData.FirstOrDefault(r => r.Id == e.RecordId); + if (record != null) + { + record.BreakpointState = e.NewState; + } + StateHasChanged(); + } + + private void OnBreakpointContextMenu(BreakpointColumnContextMenuEventArgs e) + { + _contextMenuRecordId = e.RecordId; + _contextMenuRecordState = e.CurrentState; + + StateHasChanged(); + } + + private void OnAddBreakpoint() + { + SetRecordState(BreakpointState.Enabled); + } + + private void OnAddConditionalBreakpoint() + { + SetRecordState(BreakpointState.Conditional); + } + + private void OnRemoveBreakpoint() + { + SetRecordState(BreakpointState.Off); + } + + private void OnDisableBreakpoint() + { + SetRecordState(BreakpointState.Disabled); + } + + private void OnEnableBreakpoint() + { + SetRecordState(BreakpointState.Enabled); + } + + private void SetRecordState(string newState) + { + var record = _tableData.FirstOrDefault(r => r.Id == _contextMenuRecordId); + if (record != null) + { + record.BreakpointState = newState; + } + StateHasChanged(); + } +} + +public class BreakpointTableRecord +{ + public BreakpointTableRecord(string id, string? parentId, string name, int lineNumber, string breakpointState) + { + Id = id; + ParentId = parentId; + Name = name; + LineNumber = lineNumber; + BreakpointState = breakpointState; + } + + public string Id { get; set; } + public string? ParentId { get; set; } + public string Name { get; set; } + public int LineNumber { get; set; } + public string BreakpointState { get; set; } +} diff --git a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsSection.razor b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsSection.razor new file mode 100644 index 0000000000..e16222eed9 --- /dev/null +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsSection.razor @@ -0,0 +1,3 @@ +@namespace Demo.Shared.Pages.Sections + + diff --git a/packages/blazor-workspace/OkBlazor/Source/Patterns/EventHandlers.cs b/packages/blazor-workspace/OkBlazor/Source/Patterns/EventHandlers.cs index 3e0ca5e06a..aaf17b7436 100644 --- a/packages/blazor-workspace/OkBlazor/Source/Patterns/EventHandlers.cs +++ b/packages/blazor-workspace/OkBlazor/Source/Patterns/EventHandlers.cs @@ -2,6 +2,8 @@ namespace OkBlazor; +[EventHandler("onokbreakpointcolumntoggle", typeof(BreakpointColumnToggleEventArgs), enableStopPropagation: true, enablePreventDefault: false)] +[EventHandler("onokbreakpointcolumncontextmenu", typeof(BreakpointColumnContextMenuEventArgs), enableStopPropagation: true, enablePreventDefault: false)] public static class EventHandlers { } diff --git a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs new file mode 100644 index 0000000000..1bcc12bdb3 --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs @@ -0,0 +1,33 @@ +namespace OkBlazor; + +/// +/// The possible states of a breakpoint indicator. +/// +public static class BreakpointState +{ + public const string Off = "off"; + public const string Enabled = "enabled"; + public const string Disabled = "disabled"; + public const string Hit = "hit"; + public const string Conditional = "conditional"; + public const string HitDisabled = "hit-disabled"; +} + +/// +/// Event args for the breakpoint-column-toggle event. +/// +public class BreakpointColumnToggleEventArgs : EventArgs +{ + public string RecordId { get; set; } = string.Empty; + public string NewState { get; set; } = string.Empty; + public string OldState { get; set; } = string.Empty; +} + +/// +/// Event args for the breakpoint-column-context-menu event. +/// +public class BreakpointColumnContextMenuEventArgs : EventArgs +{ + public string RecordId { get; set; } = string.Empty; + public string CurrentState { get; set; } = string.Empty; +} diff --git a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor new file mode 100644 index 0000000000..ebe94a213c --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor @@ -0,0 +1,11 @@ +@namespace OkBlazor + + + diff --git a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs new file mode 100644 index 0000000000..d4bdd39caa --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs @@ -0,0 +1,50 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; + +namespace OkBlazor; + +public partial class OkTsTableColumnBreakpoint : ComponentBase +{ + /// + /// The ID of the column. + /// + [Parameter] + public string? ColumnId { get; set; } + + /// + /// Gets or sets the field in the data record that contains the breakpoint state value. + /// + [Parameter] + [DisallowNull] + public string FieldName { get; set; } = null!; + + /// + /// The name of the slot in which to render the context menu for this breakpoint column. If not provided, no context menu will be rendered. + /// + [Parameter] + public string? MenuSlot { get; set; } + + /// + /// Whether or not the column should be hidden. + /// + [Parameter] + public bool? ColumnHidden { get; set; } + + /// + /// Gets or sets a callback invoked when a breakpoint is toggled (clicked). + /// + [Parameter] + public EventCallback BreakpointToggle { get; set; } + + /// + /// Gets or sets a callback invoked when a context menu is requested on a breakpoint. + /// + [Parameter] + public EventCallback BreakpointContextMenu { get; set; } + + /// + /// Any additional attributes that did not match known properties. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary? AdditionalAttributes { get; set; } +} diff --git a/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js b/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js index 77525ed3e3..010ed5b69e 100644 --- a/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js +++ b/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js @@ -23,17 +23,26 @@ function registerEvents(Blazor) { hasRegisteredEvents = true; - /* Register any custom events here - Blazor.registerCustomEventType('okeventname', { - browserEventName: 'foo', + Blazor.registerCustomEventType('okbreakpointcolumntoggle', { + browserEventName: 'breakpoint-column-toggle', createEventArgs: event => { return { + recordId: event.detail.recordId, newState: event.detail.newState, oldState: event.detail.oldState }; } }); - */ + + Blazor.registerCustomEventType('okbreakpointcolumncontextmenu', { + browserEventName: 'breakpoint-column-context-menu', + createEventArgs: event => { + return { + recordId: event.detail.recordId, + currentState: event.detail.currentState + }; + } + }); } function handleRuntimeStarted() { diff --git a/packages/ok-components/src/ts/all-ts.ts b/packages/ok-components/src/ts/all-ts.ts index 815f43d4ba..0f446e829b 100644 --- a/packages/ok-components/src/ts/all-ts.ts +++ b/packages/ok-components/src/ts/all-ts.ts @@ -1 +1,2 @@ import './icon-dynamic'; +import './table-column/breakpoint'; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/cell-view/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/index.ts new file mode 100644 index 0000000000..8b568132a7 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/index.ts @@ -0,0 +1,333 @@ +import { DesignSystem } from '@ni/fast-foundation'; +import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; +import { attr, observable } from '@ni/fast-element'; +import type { AnchoredRegion } from '@ni/nimble-components/dist/esm/anchored-region'; +import { MenuButtonPosition, type MenuButtonPosition as BreakpointMenuPosition } from '@ni/nimble-components/dist/esm/menu-button/types'; +import type { CellViewSlotRequestEventDetail } from '@ni/nimble-components/dist/esm/table/types'; +import { template } from './template'; +import { styles } from './styles'; +import { + BreakpointState, + breakpointCellViewMenuSlotName, + type BreakpointToggleEventDetail, + type BreakpointContextMenuEventDetail +} from '../types'; +import type { TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig } from '..'; + +declare global { + interface HTMLElementTagNameMap { + 'ok-ts-table-column-breakpoint-cell-view': TsTableColumnBreakpointCellView; + } +} + +/** + * Cell view for the breakpoint column that renders a clickable breakpoint indicator. + */ +export class TsTableColumnBreakpointCellView extends TableCellView< + TsTableColumnBreakpointCellRecord, + TsTableColumnBreakpointColumnConfig +> { + private static readonly menuKeyAlias = 'Menu'; + + private static readonly contextMenuKeyAlias = 'ContextMenu'; + + /** @internal */ + public button?: HTMLButtonElement; + + /** + * Specifies whether or not the menu is open. + */ + @attr({ mode: 'boolean' }) + public open = false; + + /** @internal */ + @observable + public readonly region?: AnchoredRegion; + + /** @internal */ + @observable + public readonly slottedMenus?: HTMLElement[]; + + private focusLastItemWhenOpened = false; + + /** @internal */ + @observable + private readonly breakpointEnabledString = 'Breakpoint enabled'; + + /** @internal */ + @observable + private readonly breakpointDisabledString = 'Breakpoint disabled'; + + /** @internal */ + @observable + private readonly breakpointHitString = 'Breakpoint hit'; + + /** @internal */ + @observable + private readonly breakpointConditionalString = 'Conditional breakpoint'; + + /** @internal */ + @observable + private readonly breakpointHitDisabledString = 'Breakpoint hit (disabled)'; + + /** @internal */ + @observable + private readonly breakpointAddString = 'Add breakpoint'; + + /** @internal */ + @observable + private readonly breakpointRemoveString = 'Remove breakpoint'; + + /** @internal */ + public get currentState(): BreakpointState { + const value = this.cellRecord?.value; + if (value && Object.values(BreakpointState).includes(value as BreakpointState)) { + return value as BreakpointState; + } + return BreakpointState.off; + } + + /** @internal */ + public get tooltipText(): string { + if (this.currentState === BreakpointState.off) { + return this.breakpointAddString; + } + return this.breakpointRemoveString; + } + + /** @internal */ + public get ariaLabelText(): string { + switch (this.currentState) { + case BreakpointState.enabled: + return this.breakpointEnabledString; + case BreakpointState.disabled: + return this.breakpointDisabledString; + case BreakpointState.hit: + return this.breakpointHitString; + case BreakpointState.conditional: + return this.breakpointConditionalString; + case BreakpointState.hitDisabled: + return this.breakpointHitDisabledString; + default: + return this.breakpointAddString; + } + } + + /** @internal */ + public get menuPosition(): BreakpointMenuPosition { + return this.columnConfig?.position ?? MenuButtonPosition.auto; + } + + public override get tabbableChildren(): HTMLElement[] { + if (this.button) { + return [this.button]; + } + return []; + } + + /** @internal */ + public onButtonClick(event: Event): void { + event.stopPropagation(); + const oldState = this.currentState; + const newState = oldState === BreakpointState.off + ? BreakpointState.enabled + : BreakpointState.off; + this.emitToggle(oldState, newState); + } + + /** @internal */ + public onContextMenu(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.requestContextMenu(); + } + + /** @internal */ + public onKeyDown(event: KeyboardEvent): boolean { + if ((event.key === 'F10' && event.shiftKey) + || event.key === TsTableColumnBreakpointCellView.menuKeyAlias + || (event.key === TsTableColumnBreakpointCellView.contextMenuKeyAlias)) { + event.preventDefault(); + event.stopPropagation(); + this.requestContextMenu(); + return false; + } + + if (event.key === 'F9' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'b')) { + event.preventDefault(); + event.stopPropagation(); + this.onButtonClick(event); + return false; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + this.focusLastItemWhenOpened = false; + this.requestContextMenu(); + return false; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + this.focusLastItemWhenOpened = true; + this.requestContextMenu(); + return false; + } + + return true; + } + + public regionLoadedHandler(): void { + if (this.focusLastItemWhenOpened) { + this.focusLastItemWhenOpened = false; + this.focusLastMenuItem(); + } else { + this.focusMenu(); + } + } + + public regionChanged(prev: AnchoredRegion | undefined, _next: AnchoredRegion | undefined): void { + if (prev) { + prev.removeEventListener('change', this.menuChangeHandler, { capture: true }); + } + + if (this.region) { + this.region.anchorElement = this.button ?? this; + this.region.addEventListener('change', this.menuChangeHandler, { capture: true }); + } + } + + public buttonChanged(): void { + if (this.region) { + this.region.anchorElement = this.button ?? this; + } + } + + public focusoutHandler(e: FocusEvent): boolean { + if (!this.open) { + return true; + } + + const focusTarget = e.relatedTarget as HTMLElement; + if ( + !this.contains(focusTarget) + && !this.region?.contains(focusTarget) + && !this.getMenu()?.contains(focusTarget) + ) { + this.open = false; + return false; + } + + return true; + } + + public contextMenuKeyDownHandler(e: KeyboardEvent): boolean { + switch (e.key) { + case 'Escape': + this.open = false; + this.button?.focus(); + return false; + default: + return true; + } + } + + private getMenu(): HTMLElement | undefined { + if (!this.slottedMenus || this.slottedMenus.length === 0) { + return undefined; + } + + let currentItem: HTMLElement | undefined = this.slottedMenus[0]; + while (currentItem) { + if (currentItem.getAttribute('role') === 'menu') { + return currentItem; + } + + if (this.isSlotElement(currentItem)) { + const firstNode = currentItem.assignedNodes()[0]; + if (firstNode instanceof HTMLElement) { + currentItem = firstNode; + } else { + currentItem = undefined; + } + } else { + return undefined; + } + } + + return undefined; + } + + private isSlotElement( + element: HTMLElement | undefined + ): element is HTMLSlotElement { + return element?.nodeName === 'SLOT'; + } + + private focusMenu(): void { + this.getMenu()?.focus(); + } + + private focusLastMenuItem(): void { + const menuItems = this.getMenu()?.querySelectorAll('[role=menuitem]'); + if (menuItems && menuItems.length > 0) { + const lastMenuItem = menuItems[menuItems.length - 1] as HTMLElement; + lastMenuItem.focus(); + } + } + + private emitToggle( + oldState: BreakpointState, + newState: BreakpointState + ): void { + const detail: BreakpointToggleEventDetail = { + recordId: this.recordId!, + newState, + oldState + }; + this.$emit('breakpoint-column-toggle', detail); + } + + private requestContextMenu(): void { + this.openMenuFromColumnSlot(); + + const detail: BreakpointContextMenuEventDetail = { + recordId: this.recordId!, + currentState: this.currentState + }; + this.$emit('breakpoint-column-context-menu', detail); + } + + private openMenuFromColumnSlot(): void { + const configuredSlotName = this.columnConfig?.menuSlot; + if (!configuredSlotName) { + return; + } + + const eventDetail: CellViewSlotRequestEventDetail = { + slots: [ + { + name: configuredSlotName, + slot: breakpointCellViewMenuSlotName + } + ] + }; + this.$emit('cell-view-slots-request', eventDetail); + this.open = true; + } + + private readonly menuChangeHandler = (): void => { + this.open = false; + this.button?.focus(); + }; +} + +const tsTableColumnBreakpointCellView = TsTableColumnBreakpointCellView.compose({ + baseName: 'ts-table-column-breakpoint-cell-view', + template, + styles +}); +DesignSystem.getOrCreate().withPrefix('ok').register(tsTableColumnBreakpointCellView()); +export const tsTableColumnBreakpointCellViewTag = 'ok-ts-table-column-breakpoint-cell-view'; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/cell-view/styles.ts b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/styles.ts new file mode 100644 index 0000000000..6fafa3606a --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/styles.ts @@ -0,0 +1,52 @@ +import { css } from '@ni/fast-element'; +import { display } from '@ni/nimble-components/dist/esm/utilities/style/display'; +import { + borderHoverColor, + borderWidth, + iconSize, +} from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; + +export const styles = css` + ${display('inline-flex')} + + :host { + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .breakpoint-button { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + outline-offset: -1px; + width: ${iconSize}; + height: ${iconSize}; + } + + .breakpoint-button:focus-visible { + outline: calc(2 * ${borderWidth}) solid ${borderHoverColor}; + outline-offset: -2px; + } + + .breakpoint-button svg { + width: ${iconSize}; + height: ${iconSize}; + } + + .breakpoint-button.state-off > * { + opacity: 0; + } + + .breakpoint-button.state-off:hover > *, + .breakpoint-button.state-off:focus-visible > * { + opacity: 1; + } + +`; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/cell-view/template.ts b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/template.ts new file mode 100644 index 0000000000..ba16095627 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/template.ts @@ -0,0 +1,58 @@ +import { html, ref, when, slotted } from '@ni/fast-element'; +import { iconBreakpointConditionalTag } from '@ni/spright-components/dist/esm/icons/breakpoint-conditional'; +import { iconBreakpointDisabledTag } from '@ni/spright-components/dist/esm/icons/breakpoint-disabled'; +import { iconBreakpointHitTag } from '@ni/spright-components/dist/esm/icons/breakpoint-hit'; +import { iconBreakpointHitDisabledTag } from '@ni/spright-components/dist/esm/icons/breakpoint-hit-disabled'; +import { iconBreakpointEnabledTag } from '@ni/spright-components/dist/esm/icons/breakpoint-enabled'; +import { iconBreakpointHoverTag } from '@ni/spright-components/dist/esm/icons/breakpoint-hover'; +import { anchoredRegionTag } from '@ni/nimble-components/dist/esm/anchored-region'; +import { MenuButtonPosition } from '@ni/nimble-components/dist/esm/menu-button/types'; +import { BreakpointState, breakpointCellViewMenuSlotName } from '../types'; +import type { TsTableColumnBreakpointCellView } from '.'; + +export const template = html` + +`; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts new file mode 100644 index 0000000000..db9565225f --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -0,0 +1,113 @@ +import { DesignSystem } from '@ni/fast-foundation'; +import { attr } from '@ni/fast-element'; +import type { TableStringField } from '@ni/nimble-components/dist/esm/table/types'; +import type { ColumnInternalsOptions } from '@ni/nimble-components/dist/esm/table-column/base/models/column-internals'; +import { singleIconColumnWidth } from '@ni/nimble-components/dist/esm/table-column/base/types'; +import { ColumnValidator } from '@ni/nimble-components/dist/esm/table-column/base/models/column-validator'; +import { TableColumn } from '@ni/nimble-components/dist/esm/table-column/base'; +import { styles } from '@ni/nimble-components/dist/esm/table-column/base/styles'; +import type { DelegatedEventEventDetails } from '@ni/nimble-components/dist/esm/table-column/base/types'; +import { MenuButtonPosition, type MenuButtonPosition as BreakpointMenuPosition } from '@ni/nimble-components/dist/esm/menu-button/types'; +import type { BreakpointToggleEventDetail, BreakpointContextMenuEventDetail } from './types'; +import { breakpointCellViewMenuSlotName } from './types'; +import { tsTableColumnBreakpointCellViewTag } from './cell-view'; +import { template } from './template'; + +export type TsTableColumnBreakpointCellRecord = TableStringField<'value'>; + +export interface TsTableColumnBreakpointColumnConfig { + menuSlot?: string; + position?: BreakpointMenuPosition; +} + +declare global { + interface HTMLElementTagNameMap { + 'ok-ts-table-column-breakpoint': TsTableColumnBreakpoint; + } +} + +/** + * A table column that displays a breakpoint indicator with toggle functionality. + */ +export class TsTableColumnBreakpoint extends TableColumn { + @attr({ attribute: 'field-name' }) + public fieldName?: string; + + @attr({ attribute: 'menu-slot' }) + public menuSlot?: string; + + @attr + public position: BreakpointMenuPosition = MenuButtonPosition.auto; + + public constructor() { + super(); + // Breakpoint columns are icon-only and should remain fixed-size and non-resizable. + this.columnInternals.resizingDisabled = true; + this.columnInternals.pixelWidth = singleIconColumnWidth; + this.columnInternals.minPixelWidth = singleIconColumnWidth; + } + + /** @internal */ + public onDelegatedEvent(e: Event): void { + e.stopImmediatePropagation(); + + const event = e as CustomEvent; + + if (event.detail.originalEvent.type === 'breakpoint-column-toggle') { + const originalEvent = event.detail.originalEvent as CustomEvent; + const detail: BreakpointToggleEventDetail = { + ...originalEvent.detail, + recordId: event.detail.recordId + }; + this.$emit('breakpoint-column-toggle', detail); + } else if (event.detail.originalEvent.type === 'breakpoint-column-context-menu') { + const originalEvent = event.detail.originalEvent as CustomEvent; + const detail: BreakpointContextMenuEventDetail = { + ...originalEvent.detail, + recordId: event.detail.recordId + }; + this.$emit('breakpoint-column-context-menu', detail); + } + } + + protected override getColumnInternalsOptions(): ColumnInternalsOptions { + return { + cellRecordFieldNames: ['value'], + cellViewTag: tsTableColumnBreakpointCellViewTag, + delegatedEvents: ['breakpoint-column-toggle', 'breakpoint-column-context-menu'], + slotNames: [breakpointCellViewMenuSlotName], + validator: new ColumnValidator<[]>([]) + }; + } + + protected fieldNameChanged(): void { + this.columnInternals.dataRecordFieldNames = [this.fieldName]; + this.columnInternals.operandDataRecordFieldName = this.fieldName; + } + + protected menuSlotChanged(): void { + this.updateColumnConfig(); + } + + protected positionChanged(): void { + this.updateColumnConfig(); + } + + private updateColumnConfig(): void { + this.columnInternals.columnConfig = { + menuSlot: this.menuSlot, + position: this.position + }; + } +} + +const tsTableColumnBreakpoint = TsTableColumnBreakpoint.compose({ + baseName: 'ts-table-column-breakpoint', + template, + styles +}); + +DesignSystem.getOrCreate() + .withPrefix('ok') + .register(tsTableColumnBreakpoint()); +export const tsTableColumnBreakpointTag = 'ok-ts-table-column-breakpoint'; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/template.ts b/packages/ok-components/src/ts/table-column/breakpoint/template.ts new file mode 100644 index 0000000000..02bb11910d --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/template.ts @@ -0,0 +1,9 @@ +import { html } from '@ni/fast-element'; +import { template as baseTemplate } from '@ni/nimble-components/dist/esm/table-column/base/template'; +import type { TsTableColumnBreakpoint } from '.'; + +export const template = html` + +`; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.pageobject.ts b/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.pageobject.ts new file mode 100644 index 0000000000..64ec7b58f8 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.pageobject.ts @@ -0,0 +1,87 @@ +import type { TablePageObject } from '@ni/nimble-components/dist/esm/table/testing/table.pageobject'; +import type { TableRecord } from '@ni/nimble-components/dist/esm/table/types'; +import { BreakpointState } from '../types'; +import { TsTableColumnBreakpointCellView } from '../cell-view'; + +/** + * Page object for ts-table-column-breakpoint tests. + */ +export class TsTableColumnBreakpointPageObject { + public constructor(private readonly tablePageObject: TablePageObject) {} + + public clickBreakpointButton(rowIndex: number, columnIndex: number): void { + this.getBreakpointButton(rowIndex, columnIndex).click(); + } + + public rightClickBreakpointButton(rowIndex: number, columnIndex: number): void { + this.getBreakpointButton(rowIndex, columnIndex).dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true }) + ); + } + + public focusBreakpointButton(rowIndex: number, columnIndex: number): void { + this.getBreakpointButton(rowIndex, columnIndex).focus(); + } + + public pressBreakpointButtonKey( + rowIndex: number, + columnIndex: number, + eventInit: KeyboardEventInit + ): boolean { + return this.getBreakpointButton(rowIndex, columnIndex).dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + ...eventInit + }) + ); + } + + public getBreakpointButtonIconTag( + rowIndex: number, + columnIndex: number + ): string { + const iconTag = this + .getBreakpointButton(rowIndex, columnIndex) + .querySelector(':scope > *') + ?.tagName; + return iconTag?.toLocaleLowerCase() ?? ''; + } + + public getCurrentState(rowIndex: number, columnIndex: number): BreakpointState { + return this.getRenderedCellView(rowIndex, columnIndex).currentState; + } + + public getTooltipText(rowIndex: number, columnIndex: number): string { + return this.getRenderedCellView(rowIndex, columnIndex).tooltipText; + } + + public getTabbableChildrenCount(rowIndex: number, columnIndex: number): number { + return this.getRenderedCellView(rowIndex, columnIndex).tabbableChildren.length; + } + + private getRenderedCellView( + rowIndex: number, + columnIndex: number + ): TsTableColumnBreakpointCellView { + return this.tablePageObject.getRenderedCellView( + rowIndex, + columnIndex + ) as TsTableColumnBreakpointCellView; + } + + private getBreakpointButton( + rowIndex: number, + columnIndex: number + ): HTMLButtonElement { + const button = this.getRenderedCellView( + rowIndex, + columnIndex + ).shadowRoot!.querySelector('.breakpoint-button'); + if (!button) { + throw new Error( + `Expected breakpoint button at cell ${rowIndex},${columnIndex}` + ); + } + return button; + } +} \ No newline at end of file diff --git a/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.spec.ts b/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.spec.ts new file mode 100644 index 0000000000..b3bf9ea3f9 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.spec.ts @@ -0,0 +1,470 @@ +import { html, ref } from '@ni/fast-element'; +import { parameterizeSpec } from '@ni/jasmine-parameterized'; +import { tableTag, type Table } from '@ni/nimble-components/dist/esm/table'; +import { menuTag } from '@ni/nimble-components/dist/esm/menu'; +import { menuItemTag, type MenuItem } from '@ni/nimble-components/dist/esm/menu-item'; +import { waitForUpdatesAsync } from '@ni/nimble-components/dist/esm/testing/async-helpers'; +import { TablePageObject } from '@ni/nimble-components/dist/esm/table/testing/table.pageobject'; +import type { TableRecord } from '@ni/nimble-components/dist/esm/table/types'; +import { singleIconColumnWidth } from '@ni/nimble-components/dist/esm/table-column/base/types'; +import { fixture, type Fixture } from '../../../../utilities/tests/fixture'; +import { TsTableColumnBreakpoint, tsTableColumnBreakpointTag } from '..'; +import { TsTableColumnBreakpointPageObject } from './ts-table-column-breakpoint.pageobject'; +import { + BreakpointState, + type BreakpointToggleEventDetail, + type BreakpointContextMenuEventDetail +} from '../types'; + +interface SimpleTableRecord extends TableRecord { + id?: string; + parentId?: string; + breakpointState?: string | null; +} + +class ElementReferences { + public table!: Table; + public column!: TsTableColumnBreakpoint; + public firstMenuItem!: MenuItem; + public secondMenuItem!: MenuItem; + public lastMenuItem!: MenuItem; +} + +describe('TsTableColumnBreakpoint', () => { + let table: Table; + let connect: () => Promise; + let disconnect: () => Promise; + let elementReferences: ElementReferences; + let tablePageObject: TablePageObject; + let breakpointPageObject: TsTableColumnBreakpointPageObject; + + async function setup( + source: ElementReferences + ): Promise>> { + return await fixture>( + html`<${tableTag} ${ref('table')} id-field-name="id" style="width: 700px"> + <${tsTableColumnBreakpointTag} + ${ref('column')} + field-name="breakpointState" + menu-slot="breakpoint-menu" + > + + + <${menuTag} slot="breakpoint-menu"> + <${menuItemTag} ${ref('firstMenuItem')}>Toggle + <${menuItemTag} ${ref('secondMenuItem')}>Disable + <${menuItemTag} ${ref('lastMenuItem')}>Edit + + `, + { source } + ); + } + + function getContextMenuEventDetail( + contextMenuSpy: jasmine.Spy + ): BreakpointContextMenuEventDetail { + return ( + contextMenuSpy.calls.first().args[0] as CustomEvent + ).detail; + } + + beforeEach(async () => { + elementReferences = new ElementReferences(); + ({ connect, disconnect } = await setup(elementReferences)); + table = elementReferences.table; + tablePageObject = new TablePageObject(table); + breakpointPageObject = new TsTableColumnBreakpointPageObject(tablePageObject); + await connect(); + await waitForUpdatesAsync(); + }); + + afterEach(async () => { + await disconnect(); + }); + + it('can construct an element instance', () => { + expect( + document.createElement(tsTableColumnBreakpointTag) + ).toBeInstanceOf(TsTableColumnBreakpoint); + }); + + it('reports column configuration valid', () => { + expect(elementReferences.column.checkValidity()).toBeTrue(); + }); + + it('uses fixed icon width and disables resizing', () => { + expect(elementReferences.column.columnInternals.resizingDisabled).toBeTrue(); + expect(elementReferences.column.columnInternals.pixelWidth).toBe(singleIconColumnWidth); + expect(elementReferences.column.columnInternals.minPixelWidth).toBe(singleIconColumnWidth); + }); + + describe('rendering breakpoint states', () => { + const stateRenderTests = [ + { + name: 'renders off state when field value is "off"', + fieldValue: BreakpointState.off, + expectedState: BreakpointState.off + }, + { + name: 'renders enabled state when field value is "enabled"', + fieldValue: BreakpointState.enabled, + expectedState: BreakpointState.enabled + }, + { + name: 'renders disabled state when field value is "disabled"', + fieldValue: BreakpointState.disabled, + expectedState: BreakpointState.disabled + }, + { + name: 'renders hit state when field value is "hit"', + fieldValue: BreakpointState.hit, + expectedState: BreakpointState.hit + }, + { + name: 'renders off state when field value is null', + fieldValue: null, + expectedState: BreakpointState.off + }, + { + name: 'renders off state when field value is undefined', + fieldValue: undefined, + expectedState: BreakpointState.off + }, + { + name: 'renders off state when field value is invalid', + fieldValue: 'invalid-state', + expectedState: BreakpointState.off + } + ] as const; + + parameterizeSpec(stateRenderTests, (spec, name, value) => { + spec(name, async () => { + await table.setData([ + { id: '1', breakpointState: value.fieldValue } + ]); + await waitForUpdatesAsync(); + + expect(breakpointPageObject.getCurrentState(0, 0)).toBe( + value.expectedState + ); + }); + }); + }); + + describe('click-to-toggle', () => { + const clickToggleTests = [ + { + name: 'emits toggle event from off to enabled on click', + initialState: BreakpointState.off, + expectedNewState: BreakpointState.enabled + }, + { + name: 'emits toggle event from enabled to off on click', + initialState: BreakpointState.enabled, + expectedNewState: BreakpointState.off + }, + { + name: 'emits toggle event from hit to off on click', + initialState: BreakpointState.hit, + expectedNewState: BreakpointState.off + } + ] as const; + + parameterizeSpec(clickToggleTests, (spec, name, value) => { + spec(name, async () => { + await table.setData([ + { id: '1', breakpointState: value.initialState } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + breakpointPageObject.clickBreakpointButton(0, 0); + await waitForUpdatesAsync(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + const eventDetail = ( + toggleSpy.calls.first().args[0] as CustomEvent + ).detail; + expect(eventDetail.oldState).toBe(value.initialState); + expect(eventDetail.newState).toBe(value.expectedNewState); + expect(eventDetail.recordId).toBe('1'); + }); + }); + + it('emits toggle event for child rows in hierarchical data', async () => { + table.parentIdFieldName = 'parentId'; + await table.setData([ + { id: 'parent', breakpointState: BreakpointState.off }, + { id: 'child', parentId: 'parent', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + breakpointPageObject.clickBreakpointButton(1, 0); + await waitForUpdatesAsync(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + const eventDetail = ( + toggleSpy.calls.first().args[0] as CustomEvent + ).detail; + expect(eventDetail.recordId).toBe('child'); + expect(eventDetail.oldState).toBe(BreakpointState.off); + expect(eventDetail.newState).toBe(BreakpointState.enabled); + }); + }); + + describe('table selection does not change', () => { + beforeEach(async () => { + table.selectionMode = 'multiple'; + await waitForUpdatesAsync(); + + await table.setData([{ id: '1', breakpointState: BreakpointState.off }]); + await waitForUpdatesAsync(); + + breakpointPageObject.focusBreakpointButton(0, 0); + }); + + it('when clicking a breakpoint button', async () => { + breakpointPageObject.clickBreakpointButton(0, 0); + await waitForUpdatesAsync(); + + const selection = await table.getSelectedRecordIds(); + expect(selection.length).toBe(0); + }); + + it('when toggling a breakpoint button by pressing Enter', async () => { + breakpointPageObject.pressBreakpointButtonKey(0, 0, { key: 'Enter' }); + await waitForUpdatesAsync(); + + const selection = await table.getSelectedRecordIds(); + expect(selection.length).toBe(0); + }); + }); + + describe('tooltip text', () => { + it('shows "Add breakpoint" when state is off', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + expect(breakpointPageObject.getTooltipText(0, 0)).toBe('Add breakpoint'); + }); + + it('shows "Remove breakpoint" when state is enabled', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + expect(breakpointPageObject.getTooltipText(0, 0)).toBe('Remove breakpoint'); + }); + }); + + describe('tabbable children', () => { + it('cell view has one tabbable child (the button)', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + expect(breakpointPageObject.getTabbableChildrenCount(0, 0)).toBe(1); + }); + }); + + describe('context menu', () => { + it('emits breakpoint-column-context-menu on right-click when state is off', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const contextMenuSpy = jasmine.createSpy('contextmenu'); + elementReferences.column.addEventListener( + 'breakpoint-column-context-menu', + contextMenuSpy + ); + + breakpointPageObject.rightClickBreakpointButton(0, 0); + await waitForUpdatesAsync(); + + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = getContextMenuEventDetail(contextMenuSpy); + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.off); + }); + + it('emits breakpoint-column-context-menu on right-click when state is enabled', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const contextMenuSpy = jasmine.createSpy('contextmenu'); + elementReferences.column.addEventListener( + 'breakpoint-column-context-menu', + contextMenuSpy + ); + + breakpointPageObject.rightClickBreakpointButton(0, 0); + await waitForUpdatesAsync(); + + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = getContextMenuEventDetail(contextMenuSpy); + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.enabled); + }); + + it('emits breakpoint-column-context-menu on Shift+F10', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const contextMenuSpy = jasmine.createSpy('contextmenu'); + elementReferences.column.addEventListener( + 'breakpoint-column-context-menu', + contextMenuSpy + ); + + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'F10', + shiftKey: true + }); + await waitForUpdatesAsync(); + + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = getContextMenuEventDetail(contextMenuSpy); + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.enabled); + }); + + it('emits breakpoint-column-context-menu on Menu key', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const contextMenuSpy = jasmine.createSpy('contextmenu'); + elementReferences.column.addEventListener( + 'breakpoint-column-context-menu', + contextMenuSpy + ); + + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'Menu' + }); + await waitForUpdatesAsync(); + + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = getContextMenuEventDetail(contextMenuSpy); + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.enabled); + }); + + it('emits breakpoint-column-context-menu when right-clicking while menu is already open', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const contextMenuSpy = jasmine.createSpy('contextmenu'); + elementReferences.column.addEventListener( + 'breakpoint-column-context-menu', + contextMenuSpy + ); + + breakpointPageObject.rightClickBreakpointButton(0, 0); + await waitForUpdatesAsync(); + + breakpointPageObject.rightClickBreakpointButton(0, 0); + await waitForUpdatesAsync(); + + expect(contextMenuSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('keyboard shortcuts', () => { + it('toggles breakpoint on F9 when focused in breakpoint cell', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'F9' + }); + await waitForUpdatesAsync(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + const eventDetail = ( + toggleSpy.calls.first().args[0] as CustomEvent + ).detail; + expect(eventDetail.oldState).toBe(BreakpointState.off); + expect(eventDetail.newState).toBe(BreakpointState.enabled); + }); + + it('toggles breakpoint on Ctrl+B when focused in breakpoint cell', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'b', + ctrlKey: true + }); + await waitForUpdatesAsync(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + const eventDetail = ( + toggleSpy.calls.first().args[0] as CustomEvent + ).detail; + expect(eventDetail.oldState).toBe(BreakpointState.enabled); + expect(eventDetail.newState).toBe(BreakpointState.off); + }); + }); + + describe('field-name attribute', () => { + it('updating fieldName updates cell rendering', async () => { + await table.setData([ + { + id: '1', + breakpointState: BreakpointState.enabled + } + ]); + await waitForUpdatesAsync(); + + expect(breakpointPageObject.getCurrentState(0, 0)).toBe( + BreakpointState.enabled + ); + + elementReferences.column.fieldName = undefined; + await waitForUpdatesAsync(); + + expect(breakpointPageObject.getCurrentState(0, 0)).toBe(BreakpointState.off); + }); + }); +}); diff --git a/packages/ok-components/src/ts/table-column/breakpoint/types.ts b/packages/ok-components/src/ts/table-column/breakpoint/types.ts new file mode 100644 index 0000000000..e3ffea578f --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/types.ts @@ -0,0 +1,32 @@ +/** + * The possible states of a breakpoint indicator. + */ +export const BreakpointState = { + off: 'off', + enabled: 'enabled', + disabled: 'disabled', + hit: 'hit', + conditional: 'conditional', + hitDisabled: 'hit-disabled' +} as const; +export type BreakpointState = (typeof BreakpointState)[keyof typeof BreakpointState]; + +/** + * The event detail for the `breakpoint-column-toggle` event. + */ +export interface BreakpointToggleEventDetail { + recordId: string; + newState: BreakpointState; + oldState: BreakpointState; +} + +/** + * The event detail for the `breakpoint-column-context-menu` event. + */ +export interface BreakpointContextMenuEventDetail { + recordId: string; + currentState: BreakpointState; +} + +/** @internal */ +export const breakpointCellViewMenuSlotName = 'menu'; diff --git a/packages/storybook/src/docs/component-data/ok/ts/index.ts b/packages/storybook/src/docs/component-data/ok/ts/index.ts index 3715b7ae23..ee5ea48d36 100644 --- a/packages/storybook/src/docs/component-data/ok/ts/index.ts +++ b/packages/storybook/src/docs/component-data/ok/ts/index.ts @@ -9,5 +9,14 @@ export const componentDataOkTs = [ angularStatus: ComponentFrameworkStatus.doesNotExist, blazorStatus: ComponentFrameworkStatus.ready, reactStatus: ComponentFrameworkStatus.doesNotExist + }, + { + componentName: 'Ts Table Column Breakpoint', + componentHref: './?path=/docs/ok-ts-table-column-breakpoint--docs', + library: 'ok', + componentStatus: ComponentFrameworkStatus.ready, + angularStatus: ComponentFrameworkStatus.doesNotExist, + blazorStatus: ComponentFrameworkStatus.ready, + reactStatus: ComponentFrameworkStatus.doesNotExist } ] as const; diff --git a/packages/storybook/src/ok/ts/icon-dynamic/ts-icon-dynamic.stories.ts b/packages/storybook/src/ok/ts/icon-dynamic/ts-icon-dynamic.stories.ts index 0cf81dede0..19f8c38804 100644 --- a/packages/storybook/src/ok/ts/icon-dynamic/ts-icon-dynamic.stories.ts +++ b/packages/storybook/src/ok/ts/icon-dynamic/ts-icon-dynamic.stories.ts @@ -3,7 +3,8 @@ import { html } from '@ni/fast-element'; import { TsIconDynamic } from '@ni/ok-components/dist/esm/ts/icon-dynamic'; import { apiCategory, - createUserSelectedThemeStory + createUserSelectedThemeStory, + okWarning } from '../../../utilities/storybook'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -21,6 +22,10 @@ const metadata: Meta = { chromatic: { disableSnapshot: true }, }, render: createUserSelectedThemeStory(html` + ${okWarning({ + componentName: 'ts icon dynamic', + statusLink: './?path=/docs/component-status--docs#ok-components' + })} <${tagName}> `), args: { diff --git a/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint-matrix.stories.ts b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint-matrix.stories.ts new file mode 100644 index 0000000000..7d0bf794e9 --- /dev/null +++ b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint-matrix.stories.ts @@ -0,0 +1,76 @@ +import type { StoryFn, Meta } from '@storybook/html-vite'; +import { html, ViewTemplate } from '@ni/fast-element'; +import { tableTag } from '@ni/nimble-components/dist/esm/table'; +import { tsTableColumnBreakpointTag } from '@ni/ok-components/dist/esm/ts/table-column/breakpoint'; +import { BreakpointState } from '@ni/ok-components/dist/esm/ts/table-column/breakpoint/types'; +import { + createMatrixThemeStory, + createMatrix, + sharedMatrixParameters +} from '../../../utilities/matrix'; + +const data = [ + { + id: '0', + breakpointState: BreakpointState.off + }, + { + id: '1', + breakpointState: BreakpointState.enabled + }, + { + id: '2', + breakpointState: BreakpointState.disabled + }, + { + id: '3', + breakpointState: BreakpointState.hit + }, + { + id: '4', + breakpointState: BreakpointState.conditional + }, + { + id: '5', + breakpointState: BreakpointState.hitDisabled + }, + { + id: '6', + breakpointState: null + }, + { + id: '7', + breakpointState: undefined + } +] as const; + +const metadata: Meta = { + title: 'Tests Ok/Ts Table Column: Breakpoint', + parameters: { + ...sharedMatrixParameters() + } +}; + +export default metadata; + +const component = (): ViewTemplate => html` + <${tableTag} id-field-name="id" style="height: 320px"> + <${tsTableColumnBreakpointTag} + field-name="breakpointState" + > + BP + + +`; + +export const themeMatrix: StoryFn = createMatrixThemeStory( + createMatrix(component) +); + +themeMatrix.play = async (): Promise => { + await Promise.all( + Array.from(document.querySelectorAll(tableTag)).map(async table => { + await table.setData(data); + }) + ); +}; diff --git a/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.mdx b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.mdx new file mode 100644 index 0000000000..0271dc47a4 --- /dev/null +++ b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.mdx @@ -0,0 +1,41 @@ +import { Controls, Canvas, Meta, Title } from '@storybook/addon-docs/blocks'; +import * as breakpointColumnStories from './ts-table-column-breakpoint.stories'; +import ComponentApisLink from '../../../docs/component-apis-link.mdx'; +import { tsTableColumnBreakpointTag } from '@ni/ok-components/dist/esm/ts/table-column/breakpoint'; +import { Tag } from '../../../utilities/story-layout'; + + + + +The <Tag name={tsTableColumnBreakpointTag}/> renders a breakpoint indicator in each row that +can be toggled between breakpoint states by click or keyboard interaction. + +The column is intended for code-centric table displays where debugging breakpoints need to be +visually indicated and interactively managed. + +The column is neither sortable nor groupable. It has a fixed width of 32 pixels and is not resizable. + +<Canvas of={breakpointColumnStories.breakpointColumn} /> + +## API + +<Controls of={breakpointColumnStories.breakpointColumn} /> +<ComponentApisLink /> + +## Usage + +### Breakpoint States + +| State | Description | +|-------|-------------| +| `off` | No breakpoint set | +| `enabled` | Active breakpoint | +| `disabled` | Inactive breakpoint | +| `hit` | Breakpoint currently being hit during debugging | +| `conditional` | Conditional breakpoint | +| `hit-disabled` | Hit breakpoint that is disabled | + +### Keyboard Interactions + +- Press `Ctrl/Cmd + B` or `F9` to add a breakpoint. +- Press `Shift + F10` or the `Menu` key to emit the `oncontextmenu` event. diff --git a/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.stories.ts b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.stories.ts new file mode 100644 index 0000000000..576ee713ae --- /dev/null +++ b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.stories.ts @@ -0,0 +1,197 @@ +import { html, ref } from '@ni/fast-element'; +import type { HtmlRenderer, Meta, StoryObj } from '@storybook/html-vite'; +import { withActions } from 'storybook/actions/decorator'; +import { tableTag } from '@ni/nimble-components/dist/esm/table'; +import type { TableRecord } from '@ni/nimble-components/dist/esm/table/types'; +import { tableColumnTextTag } from '@ni/nimble-components/dist/esm/table-column/text'; +import { menuTag } from '@ni/nimble-components/dist/esm/menu'; +import { menuItemTag } from '@ni/nimble-components/dist/esm/menu-item'; +import { tsTableColumnBreakpointTag } from '@ni/ok-components/dist/esm/ts/table-column/breakpoint'; +import { BreakpointState, type BreakpointToggleEventDetail } from '@ni/ok-components/dist/esm/ts/table-column/breakpoint/types'; +import { + type SharedTableArgs, + sharedTableActions, + sharedTableArgTypes, + sharedTableArgs +} from '../../../nimble/table-column/base/table-column-stories-utils'; +import { + apiCategory, + createUserSelectedThemeStory, + disableStorybookZoomTransform, + okWarning +} from '../../../utilities/storybook'; + +interface CodeRecord extends TableRecord { + id: string; + lineNumber: number; + code: string; + breakpointState: string; +} + +const simpleData: CodeRecord[] = [ + { + id: '1', + lineNumber: 1, + code: 'function hello() {', + breakpointState: BreakpointState.off + }, + { + id: '2', + lineNumber: 2, + code: ' console.log("Hello");', + breakpointState: BreakpointState.enabled + }, + { + id: '3', + lineNumber: 3, + code: ' const x = 42;', + breakpointState: BreakpointState.disabled + }, + { + id: '4', + lineNumber: 4, + code: ' return x;', + breakpointState: BreakpointState.hit + }, + { + id: '5', + lineNumber: 5, + code: ' if (x > 0) {', + breakpointState: BreakpointState.conditional + }, + { + id: '6', + lineNumber: 6, + code: ' throw new Error("hit-disabled");', + breakpointState: BreakpointState.hitDisabled + }, + { + id: '7', + lineNumber: 7, + code: ' }', + breakpointState: BreakpointState.off + }, + { + id: '8', + lineNumber: 8, + code: '}', + breakpointState: BreakpointState.off + } +]; + +const metadata: Meta<SharedTableArgs> = { + title: 'Ok/Ts Table Column: Breakpoint', + decorators: [withActions<HtmlRenderer>], + parameters: { + actions: { + handles: [ + ...sharedTableActions, + 'breakpoint-column-toggle', + 'breakpoint-column-context-menu' + ] + } + }, + argTypes: { + ...sharedTableArgTypes, + selectionMode: { + table: { + disable: true + } + } + }, + args: { + ...sharedTableArgs(simpleData) + } +}; + +export default metadata; + +interface BreakpointColumnTableArgs extends SharedTableArgs { + fieldName: string; + menuSlot: string; + toggleEvent: never; + contextMenuEvent: never; + currentData: CodeRecord[]; +} + +export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { + parameters: {}, + render: createUserSelectedThemeStory(html<BreakpointColumnTableArgs>` + ${okWarning({ + componentName: 'ts table column breakpoint', + statusLink: './?path=/docs/component-status--docs#ok-components' + })} + ${disableStorybookZoomTransform} + <${tableTag} + ${ref('tableRef')} + data-unused="${x => x.updateData(x)}" + id-field-name="id" + style="height: 320px" + > + <${tsTableColumnBreakpointTag} + field-name="${x => x.fieldName}" + menu-slot="${x => x.menuSlot}" + @breakpoint-column-toggle="${(x, c) => { + const event = c.event as CustomEvent<BreakpointToggleEventDetail>; + const detail = event.detail; + x.currentData = x.currentData.map(record => (record.id === detail.recordId + ? { ...record, breakpointState: detail.newState } + : record)); + void x.tableRef.setData(x.currentData); + }}" + > + </${tsTableColumnBreakpointTag}> + <${tableColumnTextTag} + field-name="code" + sorting-disabled="true" + > + Code + </${tableColumnTextTag}> + <${menuTag} slot="${x => x.menuSlot}"> + <${menuItemTag}>Enable breakpoint</${menuItemTag}> + <${menuItemTag}>Disable breakpoint</${menuItemTag}> + <${menuItemTag}>Remove breakpoint</${menuItemTag}> + </${menuTag}> + </${tableTag}> + `), + argTypes: { + fieldName: { + name: 'field-name', + description: + 'Set this attribute to identify which field in the data record contains the breakpoint state value for each row. See the **Usage** section below for valid breakpoint states.', + control: false, + table: { category: apiCategory.attributes } + }, + menuSlot: { + name: 'menu-slot', + description: + 'The name of the slot within the table where context menu content is provided. When configured, context menu requests render this slotted content inside an anchored region in the active breakpoint cell.', + control: false, + table: { category: apiCategory.attributes } + }, + toggleEvent: { + name: 'breakpoint-column-toggle', + description: + 'Emitted when a breakpoint is toggled via click or keyboard. The event detail includes `recordId`, `oldState`, and `newState`.', + control: false, + table: { category: apiCategory.events } + }, + contextMenuEvent: { + name: 'breakpoint-column-context-menu', + description: + 'Emitted when the breakpoint context menu is requested. The event detail includes `recordId` and `currentState`.', + control: false, + table: { category: apiCategory.events } + }, + currentData: { + table: { + disable: true + } + } + }, + args: { + fieldName: 'breakpointState', + menuSlot: 'breakpoint-menu', + currentData: [...simpleData] + } +};