From 03735acac068fd5a74a9236a40d4ff973b5559ad Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 14 May 2026 15:44:01 -0500 Subject: [PATCH 01/23] add breakpoint column --- packages/ok-components/src/ts/all-ts.ts | 1 + .../breakpoint/cell-view/index.ts | 227 +++++++ .../breakpoint/cell-view/styles.ts | 65 ++ .../breakpoint/cell-view/template.ts | 61 ++ .../src/ts/table-column/breakpoint/index.ts | 80 +++ .../src/ts/table-column/breakpoint/styles.ts | 12 + .../ts/table-column/breakpoint/template.ts | 9 + .../tests/ts-table-column-breakpoint.spec.ts | 561 ++++++++++++++++++ .../src/ts/table-column/breakpoint/types.ts | 20 + ...-table-column-breakpoint-matrix.stories.ts | 68 +++ .../ts-table-column-breakpoint.mdx | 54 ++ .../ts-table-column-breakpoint.stories.ts | 142 +++++ 12 files changed, 1300 insertions(+) create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/cell-view/index.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/cell-view/styles.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/cell-view/template.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/index.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/styles.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/template.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.spec.ts create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/types.ts create mode 100644 packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint-matrix.stories.ts create mode 100644 packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.mdx create mode 100644 packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.stories.ts 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..65754a0fb7 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/index.ts @@ -0,0 +1,227 @@ +import { DesignSystem } from '@ni/fast-foundation'; +import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; +import type { MenuButton } from '@ni/nimble-components/dist/esm/menu-button'; +import type { Table } from '@ni/nimble-components/dist/esm/table'; +import { template } from './template'; +import { styles } from './styles'; +import { BreakpointState, type BreakpointToggleEventDetail } from '../types'; +import type { TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig } from '../index'; + +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 contextMenuKey = 'ContextMenu'; + + private static readonly legacyContextMenuKey = 'Apps'; + + private static readonly menuKeyAlias = 'Menu'; + + /** @internal */ + public contextMenuButton?: MenuButton; + + /** @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 'Add breakpoint'; + } + return 'Remove breakpoint'; + } + + /** @internal */ + public get ariaLabelText(): string { + switch (this.currentState) { + case BreakpointState.enabled: + return 'Breakpoint enabled'; + case BreakpointState.disabled: + return 'Breakpoint disabled'; + case BreakpointState.hit: + return 'Breakpoint hit'; + default: + return 'Add breakpoint'; + } + } + + public override get tabbableChildren(): HTMLElement[] { + const button = this.shadowRoot?.querySelector('.breakpoint-button') as HTMLElement | null; + if (button) { + return [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.openContextMenu(); + } + + /** @internal */ + public onKeyDown(event: KeyboardEvent): void { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + this.tryFocusSiblingBreakpoint(event.key === 'ArrowUp'); + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + event.stopPropagation(); + this.onButtonClick(event); + return; + } + + if ((event.key === 'F10' && event.shiftKey) + || event.key === TsTableColumnBreakpointCellView.contextMenuKey + || event.key === TsTableColumnBreakpointCellView.legacyContextMenuKey + || event.key === TsTableColumnBreakpointCellView.menuKeyAlias) { + event.preventDefault(); + event.stopPropagation(); + this.openContextMenu(); + return; + } + + if (event.key === 'F9' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'b')) { + event.preventDefault(); + event.stopPropagation(); + this.onButtonClick(event); + } + } + + /** @internal */ + public onDisableMenuItemSelected(): void { + this.emitToggle(this.currentState, BreakpointState.disabled); + } + + /** @internal */ + public onAddMenuItemSelected(): void { + this.emitToggle(this.currentState, BreakpointState.enabled); + } + + /** @internal */ + public onEnableMenuItemSelected(): void { + this.emitToggle(this.currentState, BreakpointState.enabled); + } + + /** @internal */ + public onRemoveMenuItemSelected(): void { + this.emitToggle(this.currentState, BreakpointState.off); + } + + private emitToggle( + oldState: BreakpointState, + newState: BreakpointState + ): void { + const detail: BreakpointToggleEventDetail = { + recordId: this.recordId ?? '', + newState, + oldState + }; + this.$emit('breakpoint-column-toggle', detail); + } + + private openContextMenu(): void { + if (this.contextMenuButton && !this.contextMenuButton.open) { + this.contextMenuButton.open = true; + } + } + + private tryFocusSiblingBreakpoint(backward: boolean): boolean { + const currentCell = this.getContainingHost(this) as { + getRootNode: () => Node; + } | undefined; + if (!currentCell) { + return false; + } + + const currentRow = this.getContainingHost(currentCell) as { + getFocusableElements: () => { cells: Array<{ cell: { cellView: TableCellView } }> }; + getRootNode: () => Node; + } | undefined; + if (!currentRow) { + return false; + } + + const table = this.getContainingHost(currentRow) as Table | undefined; + if (!table) { + return false; + } + + const rowElements = table.rowElements; + const rowIndex = rowElements.findIndex(row => row === currentRow); + if (rowIndex < 0) { + return false; + } + + const currentRowCells = currentRow.getFocusableElements().cells; + const columnIndex = currentRowCells.findIndex(cellInfo => cellInfo.cell === currentCell as unknown as typeof cellInfo.cell); + if (columnIndex < 0) { + return false; + } + + const delta = backward ? -1 : 1; + for (let i = rowIndex + delta; i >= 0 && i < rowElements.length; i += delta) { + const row = rowElements[i] as { + getFocusableElements?: () => { cells: Array<{ cell: { cellView: TableCellView } }> }; + }; + if (!row.getFocusableElements) { + continue; + } + + const cellInfo = row.getFocusableElements().cells[columnIndex]; + const target = cellInfo?.cell.cellView.tabbableChildren[0]; + if (target) { + target.focus(); + return true; + } + } + + return false; + } + + private getContainingHost(element: { getRootNode: () => Node }): HTMLElement | undefined { + const root = element.getRootNode(); + if (root instanceof ShadowRoot && root.host instanceof HTMLElement) { + return root.host; + } + return undefined; + } +} + +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..d610eec58e --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/styles.ts @@ -0,0 +1,65 @@ +import { css } from '@ni/fast-element'; + +export const styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: relative; + } + + .breakpoint-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + margin: 0; + border: none; + background: transparent; + cursor: pointer; + border-radius: 50%; + outline-offset: -2px; + } + + .breakpoint-button:focus-visible { + outline: 2px solid Highlight; + } + + .breakpoint-button svg { + width: 12px; + height: 12px; + } + + .breakpoint-button.state-off svg { + opacity: 0; + } + + .breakpoint-button.state-off:hover svg, + .breakpoint-button.state-off:focus-visible svg { + opacity: 1; + } + + .context-menu-button { + position: absolute; + width: 24px; + height: 24px; + pointer-events: none; + } + + .context-menu-button::part(button) { + opacity: 0; + pointer-events: none; + } + + .context-menu-button[open] { + pointer-events: auto; + } + + .context-menu-button::part(menu) { + z-index: 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..c32ce4cf72 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/template.ts @@ -0,0 +1,61 @@ +import { html, ref, when } from '@ni/fast-element'; +import type { TsTableColumnBreakpointCellView } from './index'; +import { BreakpointState } from '../types'; +import { menuButtonTag } from '@ni/nimble-components/dist/esm/menu-button'; +import { menuTag } from '@ni/nimble-components/dist/esm/menu'; +import { menuItemTag } from '@ni/nimble-components/dist/esm/menu-item'; + +// Placeholder SVGs for breakpoint states - to be replaced with proper icons later +const offSvg = html``; +const enabledSvg = html``; +const disabledSvg = html``; +const hitSvg = html``; + +export const template = html` + + <${menuButtonTag} + ${ref('contextMenuButton')} + class="context-menu-button" + content-hidden + position="above" + tabindex="-1" + @click="${(_, c) => c.event.stopPropagation()}" + > + <${menuTag} slot="menu"> + ${when(x => x.currentState === BreakpointState.off, html` + <${menuItemTag} @change="${x => x.onAddMenuItemSelected()}"> + Add breakpoint + + `)} + ${when(x => x.currentState === BreakpointState.enabled || x.currentState === BreakpointState.hit, html` + <${menuItemTag} @change="${x => x.onDisableMenuItemSelected()}"> + Disable breakpoint + + <${menuItemTag} @change="${x => x.onRemoveMenuItemSelected()}"> + Remove breakpoint + + `)} + ${when(x => x.currentState === BreakpointState.disabled, html` + <${menuItemTag} @change="${x => x.onEnableMenuItemSelected()}"> + Enable breakpoint + + <${menuItemTag} @change="${x => x.onRemoveMenuItemSelected()}"> + Remove breakpoint + + `)} + + +`; 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..e30be40c45 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -0,0 +1,80 @@ +import { DesignSystem } from '@ni/fast-foundation'; +import { attr } from '@ni/fast-element'; +import { template } from './template'; +import { styles } from './styles'; +import type { TableStringField } from '@ni/nimble-components/dist/esm/table/types'; +import { tsTableColumnBreakpointCellViewTag } from './cell-view'; +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 { type BreakpointToggleEventDetail } from './types'; +import type { DelegatedEventEventDetails } from '@ni/nimble-components/dist/esm/table-column/base/types'; + +export type TsTableColumnBreakpointCellRecord = TableStringField<'value'>; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TsTableColumnBreakpointColumnConfig {} + +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; + + 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; + const originalEvent = event.detail.originalEvent as CustomEvent; + + if (originalEvent.type === 'breakpoint-column-toggle') { + const detail: BreakpointToggleEventDetail = { + ...originalEvent.detail, + recordId: event.detail.recordId + }; + this.$emit('breakpoint-column-toggle', detail); + } + } + + protected override getColumnInternalsOptions(): ColumnInternalsOptions { + return { + cellRecordFieldNames: ['value'], + cellViewTag: tsTableColumnBreakpointCellViewTag, + delegatedEvents: ['breakpoint-column-toggle'], + validator: new ColumnValidator<[]>([]) + }; + } + + protected fieldNameChanged(): void { + this.columnInternals.dataRecordFieldNames = [this.fieldName]; + this.columnInternals.operandDataRecordFieldName = this.fieldName; + } +} + +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/styles.ts b/packages/ok-components/src/ts/table-column/breakpoint/styles.ts new file mode 100644 index 0000000000..a567158eb9 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/styles.ts @@ -0,0 +1,12 @@ +import { css } from '@ni/fast-element'; +import { display } from '../../../utilities/style/display'; + +export const styles = css` + ${display('contents')} + + .header-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`; 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..b6b37cbd6f --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/template.ts @@ -0,0 +1,9 @@ +import { html } from '@ni/fast-element'; +import type { TsTableColumnBreakpoint } from '.'; +import { template as baseTemplate } from '@ni/nimble-components/dist/esm/table-column/base/template'; + +export const template = html` + +`; 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..f8fca68b0c --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.spec.ts @@ -0,0 +1,561 @@ +import { html, ref } from '@ni/fast-element'; +import { tableTag, type Table } from '@ni/nimble-components/dist/esm/table'; +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 { fixture, type Fixture } from '../../../../utilities/tests/fixture'; +import { TsTableColumnBreakpoint, tsTableColumnBreakpointTag } from '..'; +import { TsTableColumnBreakpointCellView } from '../cell-view'; +import { BreakpointState, type BreakpointToggleEventDetail } from '../types'; +import { singleIconColumnWidth } from '@ni/nimble-components/dist/esm/table-column/base/types'; + +interface SimpleTableRecord extends TableRecord { + id?: string; + breakpointState?: string | null; +} + +class ElementReferences { + public table!: Table; + public column!: TsTableColumnBreakpoint; +} + +describe('TsTableColumnBreakpoint', () => { + let table: Table; + let connect: () => Promise; + let disconnect: () => Promise; + let elementReferences: ElementReferences; + let tablePageObject: TablePageObject; + + 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"> + + `, + { source } + ); + } + + beforeEach(async () => { + elementReferences = new ElementReferences(); + ({ connect, disconnect } = await setup(elementReferences)); + table = elementReferences.table; + tablePageObject = new TablePageObject(table); + 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', () => { + it('renders off state when field value is "off"', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.off); + }); + + it('renders enabled state when field value is "enabled"', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.enabled); + }); + + it('renders disabled state when field value is "disabled"', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.disabled } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.disabled); + }); + + it('renders hit state when field value is "hit"', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.hit } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.hit); + }); + + it('renders off state when field value is null', async () => { + await table.setData([{ id: '1', breakpointState: null }]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.off); + }); + + it('renders off state when field value is undefined', async () => { + await table.setData([{ id: '1', breakpointState: undefined }]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.off); + }); + + it('renders off state when field value is invalid', async () => { + await table.setData([ + { id: '1', breakpointState: 'invalid-state' } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.off); + }); + }); + + describe('click-to-toggle', () => { + it('emits toggle event from off to enabled on click', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + button.click(); + 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); + expect(eventDetail.recordId).toBe('1'); + }); + + it('emits toggle event from enabled to off on click', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + button.click(); + 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); + expect(eventDetail.recordId).toBe('1'); + }); + + it('emits toggle event from hit to off on click', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.hit } + ]); + await waitForUpdatesAsync(); + + const toggleSpy = jasmine.createSpy('toggle'); + elementReferences.column.addEventListener( + 'breakpoint-column-toggle', + toggleSpy + ); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + button.click(); + await waitForUpdatesAsync(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + const eventDetail = ( + toggleSpy.calls.first().args[0] as CustomEvent + ).detail; + expect(eventDetail.oldState).toBe(BreakpointState.hit); + expect(eventDetail.newState).toBe(BreakpointState.off); + }); + }); + + describe('tooltip text', () => { + it('shows "Add breakpoint" when state is off', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.tooltipText).toBe('Add breakpoint'); + }); + + it('shows "Remove breakpoint" when state is enabled', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.tooltipText).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(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.tabbableChildren.length).toBe(1); + }); + }); + + describe('context menu', () => { + it('opens internal nimble context menu when state is off', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + button.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true }) + ); + await waitForUpdatesAsync(); + + expect(cellView.contextMenuButton?.open).toBeTrue(); + }); + + it('opens internal nimble context menu when state is enabled', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + button.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true }) + ); + await waitForUpdatesAsync(); + + expect(cellView.contextMenuButton?.open).toBeTrue(); + }); + + it('opens internal nimble context menu on Shift+F10', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + + button.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'F10', + shiftKey: true, + bubbles: true + }) + ); + await waitForUpdatesAsync(); + + expect(cellView.contextMenuButton?.open).toBeTrue(); + }); + + it('opens internal nimble context menu on Menu key', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + + button.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ContextMenu', + bubbles: true + }) + ); + await waitForUpdatesAsync(); + + expect(cellView.contextMenuButton?.open).toBeTrue(); + }); + }); + + 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 + ); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + + button.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'F9', + bubbles: true + }) + ); + 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 + ); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = cellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + + button.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'b', + ctrlKey: true, + bubbles: 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); + }); + + it('moves focus to next row breakpoint on ArrowDown', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.enabled }, + { id: '2', breakpointState: BreakpointState.off } + ]); + await waitForUpdatesAsync(); + + const firstCellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const secondCellView = tablePageObject.getRenderedCellView( + 1, + 0 + ) as TsTableColumnBreakpointCellView; + const firstButton = firstCellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + const secondButton = secondCellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + + firstButton.focus(); + firstButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true + }) + ); + await waitForUpdatesAsync(); + + expect(secondButton.matches(':focus')).toBeTrue(); + }); + + it('moves focus to previous row breakpoint on ArrowUp', async () => { + await table.setData([ + { id: '1', breakpointState: BreakpointState.off }, + { id: '2', breakpointState: BreakpointState.enabled } + ]); + await waitForUpdatesAsync(); + + const firstCellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const secondCellView = tablePageObject.getRenderedCellView( + 1, + 0 + ) as TsTableColumnBreakpointCellView; + const firstButton = firstCellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + const secondButton = secondCellView.shadowRoot!.querySelector( + '.breakpoint-button' + ) as HTMLButtonElement; + + secondButton.focus(); + secondButton.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + bubbles: true + }) + ); + await waitForUpdatesAsync(); + + expect(firstButton.matches(':focus')).toBeTrue(); + }); + + }); + + describe('field-name attribute', () => { + it('updating fieldName updates cell rendering', async () => { + await table.setData([ + { + id: '1', + breakpointState: BreakpointState.enabled + } + ]); + await waitForUpdatesAsync(); + + let cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).toBe(BreakpointState.enabled); + + elementReferences.column.fieldName = undefined; + await waitForUpdatesAsync(); + + cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + expect(cellView.currentState).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..0304690018 --- /dev/null +++ b/packages/ok-components/src/ts/table-column/breakpoint/types.ts @@ -0,0 +1,20 @@ +/** + * The possible states of a breakpoint indicator. + */ +export const BreakpointState = { + off: 'off', + enabled: 'enabled', + disabled: 'disabled', + hit: 'hit' +} 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; +} 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..620eadfe6a --- /dev/null +++ b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint-matrix.stories.ts @@ -0,0 +1,68 @@ +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: null + }, + { + id: '5', + 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..9d2b50d464 --- /dev/null +++ b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.mdx @@ -0,0 +1,54 @@ +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 different states (Off, Enabled, Disabled, Hit) via click or context menu. + +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 + +### State Transitions + +- **Off → Enabled:** Click on the breakpoint indicator +- **Enabled → Off:** Click on the active breakpoint indicator +- **Enabled → Disabled:** Right-click and select "Disable breakpoint" +- **Disabled → Enabled:** Right-click and select "Enable breakpoint" +- **Disabled → Off:** Right-click and select "Remove breakpoint" +- **Hit → Off:** Click on the hit breakpoint indicator + +### Breakpoint States + +| State | Description | +|-------|-------------| +| `off` | No breakpoint set (empty outline shown on hover) | +| `enabled` | Active breakpoint (filled red circle) | +| `disabled` | Inactive breakpoint (outlined red circle with slash) | +| `hit` | Breakpoint currently being hit during debugging (red circle with gold border) | + +### Event Handling + +Listen to the `breakpoint-column-toggle` event on the column to handle state changes: + +```ts +column.addEventListener('breakpoint-column-toggle', (event) => { + const { recordId, oldState, newState } = event.detail; + // Update your data source with the new breakpoint state +}); +``` 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..72767b451f --- /dev/null +++ b/packages/storybook/src/ok/ts/table-column-breakpoint/ts-table-column-breakpoint.stories.ts @@ -0,0 +1,142 @@ +import { html, ref } from '@ni/fast-element'; +import type { HtmlRenderer, Meta, StoryObj } from '@storybook/html-vite'; +import { withActions } from 'storybook/actions/decorator'; +import { tableTag, type Table } 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 { 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 +} 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: '}', + breakpointState: BreakpointState.off + } +]; + +const metadata: Meta<SharedTableArgs> = { + title: 'Ok/Ts Table Column: Breakpoint', + decorators: [withActions<HtmlRenderer>], + parameters: { + actions: { + handles: [...sharedTableActions, 'breakpoint-column-toggle'] + } + }, + argTypes: { + ...sharedTableArgTypes, + selectionMode: { + table: { + disable: true + } + } + }, + args: { + ...sharedTableArgs(simpleData) + } +}; + +export default metadata; + +interface BreakpointColumnTableArgs extends SharedTableArgs { + fieldName: string; + toggleEvent: never; + currentData: CodeRecord[]; +} + +export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { + parameters: {}, + render: createUserSelectedThemeStory(html<BreakpointColumnTableArgs>` + ${disableStorybookZoomTransform} + <${tableTag} + ${ref('tableRef')} + data-unused="${x => x.updateData(x)}" + id-field-name="id" + style="height: 320px" + > + <${tsTableColumnBreakpointTag} + field-name="${x => x.fieldName}" + @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"> + Code + </${tableColumnTextTag}> + </${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.', + control: false, + table: { category: apiCategory.attributes } + }, + toggleEvent: { + name: 'breakpoint-column-toggle', + description: + 'Emitted when a breakpoint is toggled via click, keyboard, or context menu. The event detail includes `recordId`, `oldState`, and `newState`.', + control: false, + table: { category: apiCategory.events } + }, + currentData: { + table: { + disable: true + } + } + }, + args: { + fieldName: 'breakpointState', + currentData: [...simpleData] + } +}; From c4482c8183c8fcb6cdde1d0992746c8fec0bd692 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Wed, 20 May 2026 19:28:13 -0500 Subject: [PATCH 02/23] add events for context menu, cleanup accessibility --- .../CodeAnalysisDictionary.xml | 1 + .../OkBlazor/Source/Patterns/EventHandlers.cs | 2 + .../BreakpointEventArgs.cs | 33 ++++ .../OkTsTableColumnBreakpoint.razor | 10 + .../OkTsTableColumnBreakpoint.razor.cs | 54 +++++ .../OkBlazor/wwwroot/OkBlazor.lib.module.js | 17 +- .../breakpoint/cell-view/index.ts | 184 ++++++++++++++---- .../breakpoint/cell-view/styles.ts | 23 +-- .../breakpoint/cell-view/template.ts | 61 +++--- .../src/ts/table-column/breakpoint/index.ts | 16 +- .../tests/ts-table-column-breakpoint.spec.ts | 52 ++++- .../src/ts/table-column/breakpoint/types.ts | 12 +- 12 files changed, 356 insertions(+), 109 deletions(-) create mode 100644 packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs create mode 100644 packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor create mode 100644 packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs diff --git a/packages/blazor-workspace/CodeAnalysisDictionary.xml b/packages/blazor-workspace/CodeAnalysisDictionary.xml index 76b55dd8e0..b2c2aafaf6 100644 --- a/packages/blazor-workspace/CodeAnalysisDictionary.xml +++ b/packages/blazor-workspace/CodeAnalysisDictionary.xml @@ -5,6 +5,7 @@ <Word>args</Word> <Word>blazor</Word> <Word>bool</Word> + <Word>breakpoint</Word> <Word>clearable</Word> <Word>combobox</Word> <Word>dropdown</Word> 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; + +/// <summary> +/// The possible states of a breakpoint indicator. +/// </summary> +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"; +} + +/// <summary> +/// Event args for the breakpoint-column-toggle event. +/// </summary> +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; +} + +/// <summary> +/// Event args for the breakpoint-column-context-menu event. +/// </summary> +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..0031c4385f --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor @@ -0,0 +1,10 @@ +@namespace OkBlazor + +<ok-ts-table-column-breakpoint + column-id="@ColumnId" + field-name="@FieldName" + column-hidden="@ColumnHidden" + @onokbreakpointcolumntoggle="HandleBreakpointToggle" + @onokbreakpointcolumncontextmenu="HandleBreakpointContextMenu" + @attributes="AdditionalAttributes"> +</ok-ts-table-column-breakpoint> 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..98cd975b93 --- /dev/null +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs @@ -0,0 +1,54 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; + +namespace OkBlazor; + +public partial class OkTsTableColumnBreakpoint : ComponentBase +{ + /// <summary> + /// The ID of the column. + /// </summary> + [Parameter] + public string? ColumnId { get; set; } + + /// <summary> + /// Gets or sets the field in the data record that contains the breakpoint state value. + /// </summary> + [Parameter] + [DisallowNull] + public string FieldName { get; set; } = null!; + + /// <summary> + /// Whether or not the column should be hidden. + /// </summary> + [Parameter] + public bool? ColumnHidden { get; set; } + + /// <summary> + /// Gets or sets a callback invoked when a breakpoint is toggled (clicked). + /// </summary> + [Parameter] + public EventCallback<BreakpointColumnToggleEventArgs> BreakpointToggle { get; set; } + + /// <summary> + /// Gets or sets a callback invoked when a context menu is requested on a breakpoint. + /// </summary> + [Parameter] + public EventCallback<BreakpointColumnContextMenuEventArgs> BreakpointContextMenu { get; set; } + + /// <summary> + /// Any additional attributes that did not match known properties. + /// </summary> + [Parameter(CaptureUnmatchedValues = true)] + public IDictionary<string, object>? AdditionalAttributes { get; set; } + + protected async void HandleBreakpointToggle(BreakpointColumnToggleEventArgs eventArgs) + { + await BreakpointToggle.InvokeAsync(eventArgs); + } + + protected async void HandleBreakpointContextMenu(BreakpointColumnContextMenuEventArgs eventArgs) + { + await BreakpointContextMenu.InvokeAsync(eventArgs); + } +} 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/table-column/breakpoint/cell-view/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/cell-view/index.ts index 65754a0fb7..d1dc4e7e58 100644 --- 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 @@ -1,10 +1,13 @@ import { DesignSystem } from '@ni/fast-foundation'; +import { observable } from '@ni/fast-element'; +import { eventChange, keyEscape } from '@ni/fast-web-utilities'; import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; -import type { MenuButton } from '@ni/nimble-components/dist/esm/menu-button'; import type { Table } from '@ni/nimble-components/dist/esm/table'; +import type { AnchoredRegion } from '@ni/nimble-components/dist/esm/anchored-region'; +import type { CellViewSlotRequestEventDetail } from '@ni/nimble-components/dist/esm/table/types'; import { template } from './template'; import { styles } from './styles'; -import { BreakpointState, type BreakpointToggleEventDetail } from '../types'; +import { BreakpointState, type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from '../types'; import type { TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig } from '../index'; declare global { @@ -20,15 +23,27 @@ export class TsTableColumnBreakpointCellView extends TableCellView< TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig > { + /** @internal */ + @observable + public open = false; + + /** @internal */ + public button?: HTMLButtonElement; + + /** @internal */ + @observable + public region?: AnchoredRegion; + + /** @internal */ + @observable + public slottedMenus?: HTMLElement[]; + private static readonly contextMenuKey = 'ContextMenu'; private static readonly legacyContextMenuKey = 'Apps'; private static readonly menuKeyAlias = 'Menu'; - /** @internal */ - public contextMenuButton?: MenuButton; - /** @internal */ public get currentState(): BreakpointState { const value = this.cellRecord?.value; @@ -55,19 +70,77 @@ export class TsTableColumnBreakpointCellView extends TableCellView< return 'Breakpoint disabled'; case BreakpointState.hit: return 'Breakpoint hit'; + case BreakpointState.conditional: + return 'Conditional breakpoint'; + case BreakpointState.hitDisabled: + return 'Breakpoint hit (disabled)'; default: return 'Add breakpoint'; } } public override get tabbableChildren(): HTMLElement[] { - const button = this.shadowRoot?.querySelector('.breakpoint-button') as HTMLElement | null; - if (button) { - return [button]; + if (this.button) { + return [this.button]; } return []; } + public regionChanged( + prev: AnchoredRegion | undefined, + _next: AnchoredRegion | undefined + ): void { + if (prev) { + prev.removeEventListener(eventChange, this.menuChangeHandler); + } + + if (this.region && this.button) { + this.region.anchorElement = this.button; + this.region.addEventListener(eventChange, this.menuChangeHandler, { + capture: true + }); + } + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.region) { + this.region.removeEventListener(eventChange, this.menuChangeHandler); + } + } + + /** @internal */ + public onContextMenuKeyDown(e: KeyboardEvent): boolean { + switch (e.key) { + case keyEscape: + this.setContextMenuOpen(false); + this.button?.focus(); + return false; + default: + return true; + } + } + + /** @internal */ + public regionLoadedHandler(): void { + this.focusMenu(); + } + + /** @internal */ + public onContextMenuFocusOut(e: FocusEvent): boolean { + if (!this.open) { + return true; + } + + const focusTarget = e.relatedTarget as HTMLElement; + if (!this.contains(focusTarget) && !this.getMenu()?.contains(focusTarget)) { + this.setContextMenuOpen(false); + return false; + } + + return true; + } + /** @internal */ public onButtonClick(event: Event): void { event.stopPropagation(); @@ -82,7 +155,7 @@ export class TsTableColumnBreakpointCellView extends TableCellView< public onContextMenu(event: Event): void { event.preventDefault(); event.stopPropagation(); - this.openContextMenu(); + this.emitContextMenu(); } /** @internal */ @@ -107,7 +180,7 @@ export class TsTableColumnBreakpointCellView extends TableCellView< || event.key === TsTableColumnBreakpointCellView.menuKeyAlias) { event.preventDefault(); event.stopPropagation(); - this.openContextMenu(); + this.emitContextMenu(); return; } @@ -118,26 +191,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< } } - /** @internal */ - public onDisableMenuItemSelected(): void { - this.emitToggle(this.currentState, BreakpointState.disabled); - } - - /** @internal */ - public onAddMenuItemSelected(): void { - this.emitToggle(this.currentState, BreakpointState.enabled); - } - - /** @internal */ - public onEnableMenuItemSelected(): void { - this.emitToggle(this.currentState, BreakpointState.enabled); - } - - /** @internal */ - public onRemoveMenuItemSelected(): void { - this.emitToggle(this.currentState, BreakpointState.off); - } - private emitToggle( oldState: BreakpointState, newState: BreakpointState @@ -150,10 +203,75 @@ export class TsTableColumnBreakpointCellView extends TableCellView< this.$emit('breakpoint-column-toggle', detail); } - private openContextMenu(): void { - if (this.contextMenuButton && !this.contextMenuButton.open) { - this.contextMenuButton.open = true; + private emitContextMenu(): void { + const slotRequestDetail: CellViewSlotRequestEventDetail = { + slots: [{ name: 'menu', slot: 'menu' }] + }; + this.$emit('cell-view-slots-request', slotRequestDetail); + this.setContextMenuOpen(true); + } + + private setContextMenuOpen(newValue: boolean): void { + if (this.open === newValue) { + return; + } + + const detail: BreakpointContextMenuEventDetail = { + recordId: this.recordId ?? '', + currentState: this.currentState + }; + + if (newValue) { + // Emit beforetoggle when opening + this.$emit('breakpoint-column-beforetoggle', detail); } + + this.open = newValue; + + if (newValue) { + // Emit context-menu event only when opening. + this.$emit('breakpoint-column-context-menu', detail); + } + } + + private getMenu(): HTMLElement | undefined { + // Resolve nested slot forwarding (table -> row -> cell-view) to find the actual menu. + 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 readonly menuChangeHandler = (): void => { + this.setContextMenuOpen(false); + this.button?.focus(); + }; + + private focusMenu(): void { + this.getMenu()?.focus(); } private tryFocusSiblingBreakpoint(backward: boolean): boolean { 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 index d610eec58e..14c34d73e8 100644 --- 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 @@ -21,8 +21,7 @@ export const styles = css` border: none; background: transparent; cursor: pointer; - border-radius: 50%; - outline-offset: -2px; + outline-offset: -1px; } .breakpoint-button:focus-visible { @@ -42,24 +41,4 @@ export const styles = css` .breakpoint-button.state-off:focus-visible svg { opacity: 1; } - - .context-menu-button { - position: absolute; - width: 24px; - height: 24px; - pointer-events: none; - } - - .context-menu-button::part(button) { - opacity: 0; - pointer-events: none; - } - - .context-menu-button[open] { - pointer-events: auto; - } - - .context-menu-button::part(menu) { - z-index: 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 index c32ce4cf72..0df0e0c08e 100644 --- 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 @@ -1,18 +1,20 @@ -import { html, ref, when } from '@ni/fast-element'; +import { html, ref, slotted, when } from '@ni/fast-element'; import type { TsTableColumnBreakpointCellView } from './index'; import { BreakpointState } from '../types'; -import { menuButtonTag } from '@ni/nimble-components/dist/esm/menu-button'; -import { menuTag } from '@ni/nimble-components/dist/esm/menu'; -import { menuItemTag } from '@ni/nimble-components/dist/esm/menu-item'; +import { anchoredRegionTag } from '@ni/nimble-components/dist/esm/anchored-region'; // Placeholder SVGs for breakpoint states - to be replaced with proper icons later const offSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="none" stroke="#888" stroke-width="1.5"/></svg>`; const enabledSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#E51400"/></svg>`; const disabledSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="none" stroke="#E51400" stroke-width="1.5"/><line x1="2" y1="10" x2="10" y2="2" stroke="#E51400" stroke-width="1.5"/></svg>`; const hitSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#E51400"/><circle cx="6" cy="6" r="5" fill="none" stroke="#FFD700" stroke-width="1.5"/></svg>`; +const conditionalSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><polygon points="6,1 11,6 6,11 1,6" fill="#FFD700"/></svg>`; +const hitDisabledSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#E51400"/><circle cx="6" cy="6" r="5" fill="none" stroke="#FFD700" stroke-width="1.5"/><line x1="2" y1="10" x2="10" y2="2" stroke="#888" stroke-width="1.5"/></svg>`; export const template = html<TsTableColumnBreakpointCellView>` <button + ${ref('button')} + part="button" class="breakpoint-button state-${x => x.currentState}" @click="${(x, c) => x.onButtonClick(c.event)}" @contextmenu="${(x, c) => x.onContextMenu(c.event)}" @@ -25,37 +27,22 @@ export const template = html<TsTableColumnBreakpointCellView>` ${when(x => x.currentState === BreakpointState.enabled, enabledSvg)} ${when(x => x.currentState === BreakpointState.disabled, disabledSvg)} ${when(x => x.currentState === BreakpointState.hit, hitSvg)} + ${when(x => x.currentState === BreakpointState.conditional, conditionalSvg)} + ${when(x => x.currentState === BreakpointState.hitDisabled, hitDisabledSvg)} </button> - <${menuButtonTag} - ${ref('contextMenuButton')} - class="context-menu-button" - content-hidden - position="above" - tabindex="-1" - @click="${(_, c) => c.event.stopPropagation()}" - > - <${menuTag} slot="menu"> - ${when(x => x.currentState === BreakpointState.off, html<TsTableColumnBreakpointCellView>` - <${menuItemTag} @change="${x => x.onAddMenuItemSelected()}"> - Add breakpoint - </${menuItemTag}> - `)} - ${when(x => x.currentState === BreakpointState.enabled || x.currentState === BreakpointState.hit, html<TsTableColumnBreakpointCellView>` - <${menuItemTag} @change="${x => x.onDisableMenuItemSelected()}"> - Disable breakpoint - </${menuItemTag}> - <${menuItemTag} @change="${x => x.onRemoveMenuItemSelected()}"> - Remove breakpoint - </${menuItemTag}> - `)} - ${when(x => x.currentState === BreakpointState.disabled, html<TsTableColumnBreakpointCellView>` - <${menuItemTag} @change="${x => x.onEnableMenuItemSelected()}"> - Enable breakpoint - </${menuItemTag}> - <${menuItemTag} @change="${x => x.onRemoveMenuItemSelected()}"> - Remove breakpoint - </${menuItemTag}> - `)} - </${menuTag}> - </${menuButtonTag}> -`; + ${when(x => x.open, html<TsTableColumnBreakpointCellView>` + <${anchoredRegionTag} + ${ref('region')} + part="menu" + fixed-placement="true" + auto-update-mode="auto" + horizontal-inset="true" + horizontal-positioning-mode="dynamic" + vertical-positioning-mode="dynamic" + @loaded="${x => x.regionLoadedHandler()}" + @focusout="${(x, c) => x.onContextMenuFocusOut(c.event as FocusEvent)}" + @keydown="${(x, c) => x.onContextMenuKeyDown(c.event as KeyboardEvent)}" + > + <slot name="menu" ${slotted({ property: 'slottedMenus' })}></slot> + </${anchoredRegionTag}> + `)}`; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts index e30be40c45..2f6a39d9b4 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/index.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -8,7 +8,7 @@ import type { ColumnInternalsOptions } from '@ni/nimble-components/dist/esm/tabl 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 { type BreakpointToggleEventDetail } from './types'; +import { type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from './types'; import type { DelegatedEventEventDetails } from '@ni/nimble-components/dist/esm/table-column/base/types'; export type TsTableColumnBreakpointCellRecord = TableStringField<'value'>; @@ -42,14 +42,21 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint e.stopImmediatePropagation(); const event = e as CustomEvent<DelegatedEventEventDetails>; - const originalEvent = event.detail.originalEvent as CustomEvent<BreakpointToggleEventDetail>; - if (originalEvent.type === 'breakpoint-column-toggle') { + if (event.detail.originalEvent.type === 'breakpoint-column-toggle') { + const originalEvent = event.detail.originalEvent as CustomEvent<BreakpointToggleEventDetail>; 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<BreakpointContextMenuEventDetail>; + const detail: BreakpointContextMenuEventDetail = { + ...originalEvent.detail, + recordId: event.detail.recordId + }; + this.$emit('breakpoint-column-context-menu', detail); } } @@ -57,7 +64,8 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint return { cellRecordFieldNames: ['value'], cellViewTag: tsTableColumnBreakpointCellViewTag, - delegatedEvents: ['breakpoint-column-toggle'], + delegatedEvents: ['breakpoint-column-toggle', 'breakpoint-column-beforetoggle', 'breakpoint-column-context-menu'], + slotNames: ['menu'], validator: new ColumnValidator<[]>([]) }; } 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 index f8fca68b0c..3d2f4200b6 100644 --- 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 @@ -294,12 +294,18 @@ describe('TsTableColumnBreakpoint', () => { }); describe('context menu', () => { - it('opens internal nimble context menu when state is off', async () => { + 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 + ); + const cellView = tablePageObject.getRenderedCellView( 0, 0 @@ -312,15 +318,24 @@ describe('TsTableColumnBreakpoint', () => { ); await waitForUpdatesAsync(); - expect(cellView.contextMenuButton?.open).toBeTrue(); + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = contextMenuSpy.calls.first().args[0].detail; + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.off); }); - it('opens internal nimble context menu when state is enabled', async () => { + 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 + ); + const cellView = tablePageObject.getRenderedCellView( 0, 0 @@ -333,15 +348,24 @@ describe('TsTableColumnBreakpoint', () => { ); await waitForUpdatesAsync(); - expect(cellView.contextMenuButton?.open).toBeTrue(); + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = contextMenuSpy.calls.first().args[0].detail; + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); - it('opens internal nimble context menu on Shift+F10', async () => { + 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 + ); + const cellView = tablePageObject.getRenderedCellView( 0, 0 @@ -359,15 +383,24 @@ describe('TsTableColumnBreakpoint', () => { ); await waitForUpdatesAsync(); - expect(cellView.contextMenuButton?.open).toBeTrue(); + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = contextMenuSpy.calls.first().args[0].detail; + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); - it('opens internal nimble context menu on Menu key', async () => { + it('emits breakpoint-column-context-menu on ContextMenu 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 + ); + const cellView = tablePageObject.getRenderedCellView( 0, 0 @@ -384,7 +417,10 @@ describe('TsTableColumnBreakpoint', () => { ); await waitForUpdatesAsync(); - expect(cellView.contextMenuButton?.open).toBeTrue(); + expect(contextMenuSpy).toHaveBeenCalledTimes(1); + const eventDetail = contextMenuSpy.calls.first().args[0].detail; + expect(eventDetail.recordId).toBe('1'); + expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); }); diff --git a/packages/ok-components/src/ts/table-column/breakpoint/types.ts b/packages/ok-components/src/ts/table-column/breakpoint/types.ts index 0304690018..0569c070b7 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/types.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/types.ts @@ -5,7 +5,9 @@ export const BreakpointState = { off: 'off', enabled: 'enabled', disabled: 'disabled', - hit: 'hit' + hit: 'hit', + conditional: 'conditional', + hitDisabled: 'hit-disabled' } as const; export type BreakpointState = (typeof BreakpointState)[keyof typeof BreakpointState]; @@ -18,3 +20,11 @@ export interface BreakpointToggleEventDetail { newState: BreakpointState; oldState: BreakpointState; } + +/** + * The event detail for the `breakpoint-column-context-menu` event. + */ +export interface BreakpointContextMenuEventDetail { + recordId: string; + currentState: BreakpointState; +} From 5e9df873467c50248dbeb4c324e25f109d0bf33d Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Wed, 20 May 2026 19:59:32 -0500 Subject: [PATCH 03/23] lint errors --- .../breakpoint/cell-view/index.ts | 22 ++--- .../breakpoint/cell-view/template.ts | 4 +- .../src/ts/table-column/breakpoint/index.ts | 8 +- .../ts/table-column/breakpoint/template.ts | 2 +- .../tests/ts-table-column-breakpoint.spec.ts | 89 +++++++++---------- .../src/ts/table-column/breakpoint/types.ts | 3 +- .../ts-table-column-breakpoint.stories.ts | 16 ++-- 7 files changed, 70 insertions(+), 74 deletions(-) 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 index d1dc4e7e58..2da033cd40 100644 --- 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 @@ -8,7 +8,7 @@ import type { CellViewSlotRequestEventDetail } from '@ni/nimble-components/dist/ import { template } from './template'; import { styles } from './styles'; import { BreakpointState, type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from '../types'; -import type { TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig } from '../index'; +import type { TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig } from '..'; declare global { interface HTMLElementTagNameMap { @@ -23,6 +23,12 @@ export class TsTableColumnBreakpointCellView extends TableCellView< TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig > { + private static readonly contextMenuKey = 'ContextMenu'; + + private static readonly legacyContextMenuKey = 'Apps'; + + private static readonly menuKeyAlias = 'Menu'; + /** @internal */ @observable public open = false; @@ -38,12 +44,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< @observable public slottedMenus?: HTMLElement[]; - private static readonly contextMenuKey = 'ContextMenu'; - - private static readonly legacyContextMenuKey = 'Apps'; - - private static readonly menuKeyAlias = 'Menu'; - /** @internal */ public get currentState(): BreakpointState { const value = this.cellRecord?.value; @@ -276,15 +276,15 @@ export class TsTableColumnBreakpointCellView extends TableCellView< private tryFocusSiblingBreakpoint(backward: boolean): boolean { const currentCell = this.getContainingHost(this) as { - getRootNode: () => Node; + getRootNode: () => Node } | undefined; if (!currentCell) { return false; } const currentRow = this.getContainingHost(currentCell) as { - getFocusableElements: () => { cells: Array<{ cell: { cellView: TableCellView } }> }; - getRootNode: () => Node; + getFocusableElements: () => { cells: { cell: { cellView: TableCellView } }[] }, + getRootNode: () => Node } | undefined; if (!currentRow) { return false; @@ -310,7 +310,7 @@ export class TsTableColumnBreakpointCellView extends TableCellView< const delta = backward ? -1 : 1; for (let i = rowIndex + delta; i >= 0 && i < rowElements.length; i += delta) { const row = rowElements[i] as { - getFocusableElements?: () => { cells: Array<{ cell: { cellView: TableCellView } }> }; + getFocusableElements?: () => { cells: { cell: { cellView: TableCellView } }[] } }; if (!row.getFocusableElements) { continue; 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 index 0df0e0c08e..c9deb28ed1 100644 --- 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 @@ -1,7 +1,7 @@ import { html, ref, slotted, when } from '@ni/fast-element'; -import type { TsTableColumnBreakpointCellView } from './index'; -import { BreakpointState } from '../types'; import { anchoredRegionTag } from '@ni/nimble-components/dist/esm/anchored-region'; +import type { TsTableColumnBreakpointCellView } from '.'; +import { BreakpointState } from '../types'; // Placeholder SVGs for breakpoint states - to be replaced with proper icons later const offSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="none" stroke="#888" stroke-width="1.5"/></svg>`; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts index 2f6a39d9b4..1394ff46bd 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/index.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -1,15 +1,15 @@ import { DesignSystem } from '@ni/fast-foundation'; import { attr } from '@ni/fast-element'; -import { template } from './template'; -import { styles } from './styles'; import type { TableStringField } from '@ni/nimble-components/dist/esm/table/types'; -import { tsTableColumnBreakpointCellViewTag } from './cell-view'; 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 { type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from './types'; import type { DelegatedEventEventDetails } from '@ni/nimble-components/dist/esm/table-column/base/types'; +import type { BreakpointToggleEventDetail, BreakpointContextMenuEventDetail } from './types'; +import { tsTableColumnBreakpointCellViewTag } from './cell-view'; +import { styles } from './styles'; +import { template } from './template'; export type TsTableColumnBreakpointCellRecord = TableStringField<'value'>; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/template.ts b/packages/ok-components/src/ts/table-column/breakpoint/template.ts index b6b37cbd6f..02bb11910d 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/template.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/template.ts @@ -1,6 +1,6 @@ import { html } from '@ni/fast-element'; -import type { TsTableColumnBreakpoint } from '.'; import { template as baseTemplate } from '@ni/nimble-components/dist/esm/table-column/base/template'; +import type { TsTableColumnBreakpoint } from '.'; export const template = html<TsTableColumnBreakpoint>` <template @delegated-event="${(x, c) => x.onDelegatedEvent(c.event)}" 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 index 3d2f4200b6..36d7526737 100644 --- 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 @@ -3,11 +3,15 @@ import { tableTag, type Table } from '@ni/nimble-components/dist/esm/table'; 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 { TsTableColumnBreakpointCellView } from '../cell-view'; -import { BreakpointState, type BreakpointToggleEventDetail } from '../types'; -import { singleIconColumnWidth } from '@ni/nimble-components/dist/esm/table-column/base/types'; +import { + BreakpointState, + type BreakpointToggleEventDetail, + type BreakpointContextMenuEventDetail +} from '../types'; interface SimpleTableRecord extends TableRecord { id?: string; @@ -38,6 +42,26 @@ describe('TsTableColumnBreakpoint', () => { ); } + function getBreakpointButton( + cellView: TsTableColumnBreakpointCellView + ): HTMLButtonElement { + const button = cellView.shadowRoot!.querySelector<HTMLButtonElement>( + '.breakpoint-button' + ); + if (!button) { + throw new Error('Expected breakpoint button'); + } + return button; + } + + function getContextMenuEventDetail( + contextMenuSpy: jasmine.Spy + ): BreakpointContextMenuEventDetail { + return ( + contextMenuSpy.calls.first().args[0] as CustomEvent<BreakpointContextMenuEventDetail> + ).detail; + } + beforeEach(async () => { elementReferences = new ElementReferences(); ({ connect, disconnect } = await setup(elementReferences)); @@ -173,9 +197,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.click(); await waitForUpdatesAsync(); @@ -204,9 +226,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.click(); await waitForUpdatesAsync(); @@ -235,9 +255,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.click(); await waitForUpdatesAsync(); @@ -310,16 +328,14 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true }) ); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); - const eventDetail = contextMenuSpy.calls.first().args[0].detail; + const eventDetail = getContextMenuEventDetail(contextMenuSpy); expect(eventDetail.recordId).toBe('1'); expect(eventDetail.currentState).toBe(BreakpointState.off); }); @@ -340,16 +356,14 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.dispatchEvent( new MouseEvent('contextmenu', { bubbles: true }) ); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); - const eventDetail = contextMenuSpy.calls.first().args[0].detail; + const eventDetail = getContextMenuEventDetail(contextMenuSpy); expect(eventDetail.recordId).toBe('1'); expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); @@ -370,9 +384,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.dispatchEvent( new KeyboardEvent('keydown', { @@ -384,7 +396,7 @@ describe('TsTableColumnBreakpoint', () => { await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); - const eventDetail = contextMenuSpy.calls.first().args[0].detail; + const eventDetail = getContextMenuEventDetail(contextMenuSpy); expect(eventDetail.recordId).toBe('1'); expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); @@ -405,9 +417,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.dispatchEvent( new KeyboardEvent('keydown', { @@ -418,7 +428,7 @@ describe('TsTableColumnBreakpoint', () => { await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); - const eventDetail = contextMenuSpy.calls.first().args[0].detail; + const eventDetail = getContextMenuEventDetail(contextMenuSpy); expect(eventDetail.recordId).toBe('1'); expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); @@ -441,9 +451,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.dispatchEvent( new KeyboardEvent('keydown', { @@ -477,9 +485,7 @@ describe('TsTableColumnBreakpoint', () => { 0, 0 ) as TsTableColumnBreakpointCellView; - const button = cellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const button = getBreakpointButton(cellView); button.dispatchEvent( new KeyboardEvent('keydown', { @@ -513,12 +519,8 @@ describe('TsTableColumnBreakpoint', () => { 1, 0 ) as TsTableColumnBreakpointCellView; - const firstButton = firstCellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; - const secondButton = secondCellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const firstButton = getBreakpointButton(firstCellView); + const secondButton = getBreakpointButton(secondCellView); firstButton.focus(); firstButton.dispatchEvent( @@ -547,12 +549,8 @@ describe('TsTableColumnBreakpoint', () => { 1, 0 ) as TsTableColumnBreakpointCellView; - const firstButton = firstCellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; - const secondButton = secondCellView.shadowRoot!.querySelector( - '.breakpoint-button' - ) as HTMLButtonElement; + const firstButton = getBreakpointButton(firstCellView); + const secondButton = getBreakpointButton(secondCellView); secondButton.focus(); secondButton.dispatchEvent( @@ -565,7 +563,6 @@ describe('TsTableColumnBreakpoint', () => { expect(firstButton.matches(':focus')).toBeTrue(); }); - }); describe('field-name attribute', () => { diff --git a/packages/ok-components/src/ts/table-column/breakpoint/types.ts b/packages/ok-components/src/ts/table-column/breakpoint/types.ts index 0569c070b7..61afeb9612 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/types.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/types.ts @@ -9,8 +9,7 @@ export const BreakpointState = { conditional: 'conditional', hitDisabled: 'hit-disabled' } as const; -export type BreakpointState = - (typeof BreakpointState)[keyof typeof BreakpointState]; +export type BreakpointState = (typeof BreakpointState)[keyof typeof BreakpointState]; /** * The event detail for the `breakpoint-column-toggle` 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 index 72767b451f..5294ff0cab 100644 --- 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 @@ -1,7 +1,7 @@ import { html, ref } from '@ni/fast-element'; import type { HtmlRenderer, Meta, StoryObj } from '@storybook/html-vite'; import { withActions } from 'storybook/actions/decorator'; -import { tableTag, type Table } from '@ni/nimble-components/dist/esm/table'; +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 { tsTableColumnBreakpointTag } from '@ni/ok-components/dist/esm/ts/table-column/breakpoint'; @@ -100,13 +100,13 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { <${tsTableColumnBreakpointTag} field-name="${x => x.fieldName}" @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); - }}" + 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"> From 6a899174d2fc05384fa780cd2fd61818ef722c22 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Wed, 20 May 2026 20:00:18 -0500 Subject: [PATCH 04/23] Change files --- ...@ni-ok-blazor-39bf770b-9e85-4d46-ba51-c915ecd4c543.json | 7 +++++++ ...ok-components-744c8788-8d43-48b6-9bff-7f72757113b5.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 change/@ni-ok-blazor-39bf770b-9e85-4d46-ba51-c915ecd4c543.json create mode 100644 change/@ni-ok-components-744c8788-8d43-48b6-9bff-7f72757113b5.json 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" +} From 0dfed811649247fd9c402b004fd0d80838a048ed Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 21 May 2026 16:45:32 -0500 Subject: [PATCH 05/23] fix context menu event --- .../breakpoint/cell-view/index.ts | 12 ++++--- .../breakpoint/cell-view/styles.ts | 11 ++++--- .../tests/ts-table-column-breakpoint.spec.ts | 31 +++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) 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 index 2da033cd40..d6ec909a3a 100644 --- 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 @@ -208,7 +208,14 @@ export class TsTableColumnBreakpointCellView extends TableCellView< slots: [{ name: 'menu', slot: 'menu' }] }; this.$emit('cell-view-slots-request', slotRequestDetail); + + const detail: BreakpointContextMenuEventDetail = { + recordId: this.recordId ?? '', + currentState: this.currentState + }; + this.setContextMenuOpen(true); + this.$emit('breakpoint-column-context-menu', detail); } private setContextMenuOpen(newValue: boolean): void { @@ -227,11 +234,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< } this.open = newValue; - - if (newValue) { - // Emit context-menu event only when opening. - this.$emit('breakpoint-column-context-menu', detail); - } } private getMenu(): HTMLElement | undefined { 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 index 14c34d73e8..9920c7a267 100644 --- 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 @@ -1,4 +1,7 @@ import { css } from '@ni/fast-element'; +import { + iconSize, +} from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; export const styles = css` :host { @@ -14,8 +17,8 @@ export const styles = css` display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 100%; + height: 100%; padding: 0; margin: 0; border: none; @@ -29,8 +32,8 @@ export const styles = css` } .breakpoint-button svg { - width: 12px; - height: 12px; + width: ${iconSize}; + height: ${iconSize}; } .breakpoint-button.state-off svg { 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 index 36d7526737..644e660b37 100644 --- 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 @@ -432,6 +432,37 @@ describe('TsTableColumnBreakpoint', () => { 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 + ); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + const button = getBreakpointButton(cellView); + + button.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true }) + ); + await waitForUpdatesAsync(); + + button.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true }) + ); + await waitForUpdatesAsync(); + + expect(contextMenuSpy).toHaveBeenCalledTimes(2); + }); }); describe('keyboard shortcuts', () => { From c70a4a4d40ac0e67b81c1ae6f1be8881c2df3aa5 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 21 May 2026 17:04:46 -0500 Subject: [PATCH 06/23] add blazor example --- .../Demo.Shared/Pages/ComponentsDemo.razor | 1 + .../Ts/TsBreakpointTableSection.razor | 38 +++++++ .../Ts/TsBreakpointTableSection.razor.cs | 106 ++++++++++++++++++ .../Pages/Sections/Ts/TsSection.razor | 3 + .../ts-table-column-breakpoint.stories.ts | 16 ++- 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor create mode 100644 packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor.cs create mode 100644 packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsSection.razor 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 @@ <RectangleSection /> <ExSection /> <FvSection /> + <TsSection /> </div> </div> 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..40197086bf --- /dev/null +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor @@ -0,0 +1,38 @@ +@namespace Demo.Shared.Pages.Sections + +<div> + <SubContainer Label="Breakpoint Table Column (Ok)"> + <NimbleTable TData="BreakpointTableRecord" @ref="_table" IdFieldName="Id" class="breakpoint-table"> + <OkTsTableColumnBreakpoint + FieldName="BreakpointState" + BreakpointToggle="OnBreakpointToggle" + BreakpointContextMenu="OnBreakpointContextMenu"> + </OkTsTableColumnBreakpoint> + <NimbleMenu slot="menu" class="breakpoint-menu"> + @if (_contextMenuRecordState == OkBlazor.BreakpointState.Off) + { + <NimbleMenuItem @onchange="@(_ => OnAddBreakpoint())">Add breakpoint</NimbleMenuItem> + <NimbleMenuItem @onchange="@(_ => OnAddConditionalBreakpoint())">Add conditional breakpoint</NimbleMenuItem> + } + else + { + <NimbleMenuItem @onchange="@(_ => OnRemoveBreakpoint())">Remove breakpoint</NimbleMenuItem> + @if (_contextMenuRecordState == OkBlazor.BreakpointState.Enabled + || _contextMenuRecordState == OkBlazor.BreakpointState.Conditional + || _contextMenuRecordState == OkBlazor.BreakpointState.Hit) + { + <NimbleMenuItem @onchange="@(_ => OnDisableBreakpoint())">Disable breakpoint</NimbleMenuItem> + } + @if (_contextMenuRecordState == OkBlazor.BreakpointState.Disabled + || _contextMenuRecordState == OkBlazor.BreakpointState.HitDisabled) + { + <NimbleMenuItem @onchange="@(_ => OnEnableBreakpoint())">Enable breakpoint</NimbleMenuItem> + } + } + </NimbleMenu> + <NimbleTableColumnText FieldName="Name" FractionalWidth="2">Name</NimbleTableColumnText> + <NimbleTableColumnNumberText FieldName="LineNumber">Line</NimbleTableColumnNumberText> + </NimbleTable> + <p><strong>Last event:</strong> @_lastEvent</p> + </SubContainer> +</div> 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..a93b398035 --- /dev/null +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor.cs @@ -0,0 +1,106 @@ +using NimbleBlazor; +using OkBlazor; + +namespace Demo.Shared.Pages.Sections; + +public partial class TsBreakpointTableSection +{ + private NimbleTable<BreakpointTableRecord>? _table; + private string? _contextMenuRecordId; + private string _contextMenuRecordState = BreakpointState.Off; + private string _lastEvent = "(none)"; + + private List<BreakpointTableRecord> _tableData = new() + { + new("1", "Main.cs", 12, BreakpointState.Enabled), + new("2", "Helper.cs", 45, BreakpointState.Off), + new("3", "Service.cs", 78, BreakpointState.Disabled), + new("4", "Controller.cs", 23, BreakpointState.Hit), + new("5", "Model.cs", 91, BreakpointState.Conditional), + new("6", "Startup.cs", 5, BreakpointState.HitDisabled), + new("7", "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; + } + _lastEvent = $"Toggle: record={e.RecordId}, {e.OldState} -> {e.NewState}"; + StateHasChanged(); + } + + private void OnBreakpointContextMenu(BreakpointColumnContextMenuEventArgs e) + { + _contextMenuRecordId = e.RecordId; + _contextMenuRecordState = e.CurrentState; + _lastEvent = $"ContextMenu: record={e.RecordId}, state={e.CurrentState}"; + StateHasChanged(); + } + + private void OnAddBreakpoint() + { + CloseContextMenuAndSetState(BreakpointState.Enabled); + } + + private void OnAddConditionalBreakpoint() + { + CloseContextMenuAndSetState(BreakpointState.Conditional); + } + + private void OnRemoveBreakpoint() + { + CloseContextMenuAndSetState(BreakpointState.Off); + } + + private void OnDisableBreakpoint() + { + CloseContextMenuAndSetState(BreakpointState.Disabled); + } + + private void OnEnableBreakpoint() + { + CloseContextMenuAndSetState(BreakpointState.Enabled); + } + + private void CloseContextMenuAndSetState(string newState) + { + // Close the context menu by triggering Escape key on the element + // This will be handled by the cell-view's anchored-region + SetRecordState(newState); + } + + 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 name, int lineNumber, string breakpointState) + { + Id = id; + Name = name; + LineNumber = lineNumber; + BreakpointState = breakpointState; + } + + public string Id { 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 + +<TsBreakpointTableSection /> 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 index 5294ff0cab..7ae9cc8ac5 100644 --- 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 @@ -63,7 +63,11 @@ const metadata: Meta<SharedTableArgs> = { decorators: [withActions<HtmlRenderer>], parameters: { actions: { - handles: [...sharedTableActions, 'breakpoint-column-toggle'] + handles: [ + ...sharedTableActions, + 'breakpoint-column-toggle', + 'breakpoint-column-context-menu' + ] } }, argTypes: { @@ -84,6 +88,7 @@ export default metadata; interface BreakpointColumnTableArgs extends SharedTableArgs { fieldName: string; toggleEvent: never; + contextMenuEvent: never; currentData: CodeRecord[]; } @@ -125,7 +130,14 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { toggleEvent: { name: 'breakpoint-column-toggle', description: - 'Emitted when a breakpoint is toggled via click, keyboard, or context menu. The event detail includes `recordId`, `oldState`, and `newState`.', + '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 } }, From 380d34de9c2ba33bc27c7d3c60bd1549a8d4bb7b Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Fri, 22 May 2026 11:22:53 -0500 Subject: [PATCH 07/23] update storybook docs --- .../breakpoint/cell-view/index.ts | 79 --------------- .../src/ts/table-column/breakpoint/index.ts | 2 +- .../tests/ts-table-column-breakpoint.spec.ts | 99 ++++++++----------- ...-table-column-breakpoint-matrix.stories.ts | 10 +- .../ts-table-column-breakpoint.mdx | 24 +---- .../ts-table-column-breakpoint.stories.ts | 18 ++++ 6 files changed, 71 insertions(+), 161 deletions(-) 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 index d6ec909a3a..50e9a8531b 100644 --- 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 @@ -2,7 +2,6 @@ import { DesignSystem } from '@ni/fast-foundation'; import { observable } from '@ni/fast-element'; import { eventChange, keyEscape } from '@ni/fast-web-utilities'; import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; -import type { Table } from '@ni/nimble-components/dist/esm/table'; import type { AnchoredRegion } from '@ni/nimble-components/dist/esm/anchored-region'; import type { CellViewSlotRequestEventDetail } from '@ni/nimble-components/dist/esm/table/types'; import { template } from './template'; @@ -160,13 +159,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< /** @internal */ public onKeyDown(event: KeyboardEvent): void { - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - event.stopPropagation(); - this.tryFocusSiblingBreakpoint(event.key === 'ArrowUp'); - return; - } - if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); @@ -223,16 +215,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< return; } - const detail: BreakpointContextMenuEventDetail = { - recordId: this.recordId ?? '', - currentState: this.currentState - }; - - if (newValue) { - // Emit beforetoggle when opening - this.$emit('breakpoint-column-beforetoggle', detail); - } - this.open = newValue; } @@ -275,67 +257,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< private focusMenu(): void { this.getMenu()?.focus(); } - - private tryFocusSiblingBreakpoint(backward: boolean): boolean { - const currentCell = this.getContainingHost(this) as { - getRootNode: () => Node - } | undefined; - if (!currentCell) { - return false; - } - - const currentRow = this.getContainingHost(currentCell) as { - getFocusableElements: () => { cells: { cell: { cellView: TableCellView } }[] }, - getRootNode: () => Node - } | undefined; - if (!currentRow) { - return false; - } - - const table = this.getContainingHost(currentRow) as Table | undefined; - if (!table) { - return false; - } - - const rowElements = table.rowElements; - const rowIndex = rowElements.findIndex(row => row === currentRow); - if (rowIndex < 0) { - return false; - } - - const currentRowCells = currentRow.getFocusableElements().cells; - const columnIndex = currentRowCells.findIndex(cellInfo => cellInfo.cell === currentCell as unknown as typeof cellInfo.cell); - if (columnIndex < 0) { - return false; - } - - const delta = backward ? -1 : 1; - for (let i = rowIndex + delta; i >= 0 && i < rowElements.length; i += delta) { - const row = rowElements[i] as { - getFocusableElements?: () => { cells: { cell: { cellView: TableCellView } }[] } - }; - if (!row.getFocusableElements) { - continue; - } - - const cellInfo = row.getFocusableElements().cells[columnIndex]; - const target = cellInfo?.cell.cellView.tabbableChildren[0]; - if (target) { - target.focus(); - return true; - } - } - - return false; - } - - private getContainingHost(element: { getRootNode: () => Node }): HTMLElement | undefined { - const root = element.getRootNode(); - if (root instanceof ShadowRoot && root.host instanceof HTMLElement) { - return root.host; - } - return undefined; - } } const tsTableColumnBreakpointCellView = TsTableColumnBreakpointCellView.compose({ diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts index 1394ff46bd..4f0b990e92 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/index.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -64,7 +64,7 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint return { cellRecordFieldNames: ['value'], cellViewTag: tsTableColumnBreakpointCellViewTag, - delegatedEvents: ['breakpoint-column-toggle', 'breakpoint-column-beforetoggle', 'breakpoint-column-context-menu'], + delegatedEvents: ['breakpoint-column-toggle', 'breakpoint-column-context-menu'], slotNames: ['menu'], validator: new ColumnValidator<[]>([]) }; 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 index 644e660b37..b3cac05b5a 100644 --- 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 @@ -268,6 +268,46 @@ describe('TsTableColumnBreakpoint', () => { }); }); + describe('table selection does not change', () => { + let button: HTMLButtonElement; + + beforeEach(async () => { + table.selectionMode = 'multiple'; + await waitForUpdatesAsync(); + + await table.setData([{ id: '1', breakpointState: BreakpointState.off }]); + await waitForUpdatesAsync(); + + const cellView = tablePageObject.getRenderedCellView( + 0, + 0 + ) as TsTableColumnBreakpointCellView; + button = getBreakpointButton(cellView); + button.focus(); + }); + + it('when clicking a breakpoint button', async () => { + button.click(); + await waitForUpdatesAsync(); + + const selection = await table.getSelectedRecordIds(); + expect(selection.length).toBe(0); + }); + + it('when toggling a breakpoint button by pressing Enter', async () => { + button.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true + }) + ); + 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([ @@ -535,65 +575,6 @@ describe('TsTableColumnBreakpoint', () => { expect(eventDetail.newState).toBe(BreakpointState.off); }); - it('moves focus to next row breakpoint on ArrowDown', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.enabled }, - { id: '2', breakpointState: BreakpointState.off } - ]); - await waitForUpdatesAsync(); - - const firstCellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const secondCellView = tablePageObject.getRenderedCellView( - 1, - 0 - ) as TsTableColumnBreakpointCellView; - const firstButton = getBreakpointButton(firstCellView); - const secondButton = getBreakpointButton(secondCellView); - - firstButton.focus(); - firstButton.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowDown', - bubbles: true - }) - ); - await waitForUpdatesAsync(); - - expect(secondButton.matches(':focus')).toBeTrue(); - }); - - it('moves focus to previous row breakpoint on ArrowUp', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.off }, - { id: '2', breakpointState: BreakpointState.enabled } - ]); - await waitForUpdatesAsync(); - - const firstCellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const secondCellView = tablePageObject.getRenderedCellView( - 1, - 0 - ) as TsTableColumnBreakpointCellView; - const firstButton = getBreakpointButton(firstCellView); - const secondButton = getBreakpointButton(secondCellView); - - secondButton.focus(); - secondButton.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'ArrowUp', - bubbles: true - }) - ); - await waitForUpdatesAsync(); - - expect(firstButton.matches(':focus')).toBeTrue(); - }); }); describe('field-name attribute', () => { 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 index 620eadfe6a..7d0bf794e9 100644 --- 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 @@ -28,10 +28,18 @@ const data = [ }, { id: '4', - breakpointState: null + breakpointState: BreakpointState.conditional }, { id: '5', + breakpointState: BreakpointState.hitDisabled + }, + { + id: '6', + breakpointState: null + }, + { + id: '7', breakpointState: undefined } ] as const; 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 index 9d2b50d464..3f3a9dd794 100644 --- 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 @@ -8,7 +8,7 @@ import { Tag } from '../../../utilities/story-layout'; <Title of={breakpointColumnStories} /> The <Tag name={tsTableColumnBreakpointTag}/> renders a breakpoint indicator in each row that -can be toggled between different states (Off, Enabled, Disabled, Hit) via click or context menu. +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. @@ -24,15 +24,6 @@ The column is neither sortable nor groupable. It has a fixed width of 32 pixels ## Usage -### State Transitions - -- **Off → Enabled:** Click on the breakpoint indicator -- **Enabled → Off:** Click on the active breakpoint indicator -- **Enabled → Disabled:** Right-click and select "Disable breakpoint" -- **Disabled → Enabled:** Right-click and select "Enable breakpoint" -- **Disabled → Off:** Right-click and select "Remove breakpoint" -- **Hit → Off:** Click on the hit breakpoint indicator - ### Breakpoint States | State | Description | @@ -41,14 +32,5 @@ The column is neither sortable nor groupable. It has a fixed width of 32 pixels | `enabled` | Active breakpoint (filled red circle) | | `disabled` | Inactive breakpoint (outlined red circle with slash) | | `hit` | Breakpoint currently being hit during debugging (red circle with gold border) | - -### Event Handling - -Listen to the `breakpoint-column-toggle` event on the column to handle state changes: - -```ts -column.addEventListener('breakpoint-column-toggle', (event) => { - const { recordId, oldState, newState } = event.detail; - // Update your data source with the new breakpoint state -}); -``` +| `conditional` | Conditional breakpoint (diamond indicator) | +| `hit-disabled` | Hit breakpoint that is disabled | 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 index 7ae9cc8ac5..8840521642 100644 --- 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 @@ -53,6 +53,24 @@ const simpleData: CodeRecord[] = [ { 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 } From 172e23af2d64dca3762024ac55997d3532aeeedf Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Fri, 22 May 2026 11:25:19 -0500 Subject: [PATCH 08/23] update docs --- .../ts-table-column-breakpoint.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index 3f3a9dd794..fca00aebc6 100644 --- 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 @@ -28,9 +28,9 @@ The column is neither sortable nor groupable. It has a fixed width of 32 pixels | State | Description | |-------|-------------| -| `off` | No breakpoint set (empty outline shown on hover) | -| `enabled` | Active breakpoint (filled red circle) | -| `disabled` | Inactive breakpoint (outlined red circle with slash) | -| `hit` | Breakpoint currently being hit during debugging (red circle with gold border) | -| `conditional` | Conditional breakpoint (diamond indicator) | +| `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 | From ebdfba151abf00235431487acccffe861ba21278 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Fri, 22 May 2026 13:02:23 -0500 Subject: [PATCH 09/23] lint --- .../breakpoint/tests/ts-table-column-breakpoint.spec.ts | 1 - 1 file changed, 1 deletion(-) 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 index b3cac05b5a..d6527bbe2b 100644 --- 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 @@ -574,7 +574,6 @@ describe('TsTableColumnBreakpoint', () => { expect(eventDetail.oldState).toBe(BreakpointState.enabled); expect(eventDetail.newState).toBe(BreakpointState.off); }); - }); describe('field-name attribute', () => { From 04e871d4e2b09c550cdf2f9099aec175655a8d5f Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Wed, 27 May 2026 17:12:21 -0500 Subject: [PATCH 10/23] fix context menu --- .../Ts/TsBreakpointTableSection.razor | 15 +- .../Ts/TsBreakpointTableSection.razor.cs | 37 +++-- .../BreakpointEventArgs.cs | 2 + .../OkBlazor/wwwroot/OkBlazor.lib.module.js | 4 +- .../breakpoint/cell-view/index.ts | 140 ++---------------- .../breakpoint/cell-view/styles.ts | 2 +- .../breakpoint/cell-view/template.ts | 20 +-- .../src/ts/table-column/breakpoint/index.ts | 1 - .../tests/ts-table-column-breakpoint.spec.ts | 32 ++++ .../src/ts/table-column/breakpoint/types.ts | 2 + 10 files changed, 91 insertions(+), 164 deletions(-) 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 index 40197086bf..d9719bd51e 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor @@ -2,13 +2,20 @@ <div> <SubContainer Label="Breakpoint Table Column (Ok)"> - <NimbleTable TData="BreakpointTableRecord" @ref="_table" IdFieldName="Id" class="breakpoint-table"> + <NimbleTable TData="BreakpointTableRecord" @ref="_table" IdFieldName="Id" ParentIdFieldName="ParentId" class="breakpoint-table"> + <NimbleTableColumnText FieldName="Name" FractionalWidth="2">Name</NimbleTableColumnText> <OkTsTableColumnBreakpoint FieldName="BreakpointState" BreakpointToggle="OnBreakpointToggle" BreakpointContextMenu="OnBreakpointContextMenu"> </OkTsTableColumnBreakpoint> - <NimbleMenu slot="menu" class="breakpoint-menu"> + <NimbleTableColumnNumberText FieldName="LineNumber">Line</NimbleTableColumnNumberText> + </NimbleTable> + + @if (_contextMenuOpen) + { + <div style="position: fixed; inset: 0; z-index: 2147483646;" @onclick="CloseContextMenu"></div> + <NimbleMenu style="@ContextMenuStyle"> @if (_contextMenuRecordState == OkBlazor.BreakpointState.Off) { <NimbleMenuItem @onchange="@(_ => OnAddBreakpoint())">Add breakpoint</NimbleMenuItem> @@ -30,9 +37,7 @@ } } </NimbleMenu> - <NimbleTableColumnText FieldName="Name" FractionalWidth="2">Name</NimbleTableColumnText> - <NimbleTableColumnNumberText FieldName="LineNumber">Line</NimbleTableColumnNumberText> - </NimbleTable> + } <p><strong>Last event:</strong> @_lastEvent</p> </SubContainer> </div> 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 index a93b398035..855f4f506b 100644 --- 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 @@ -6,19 +6,24 @@ namespace Demo.Shared.Pages.Sections; public partial class TsBreakpointTableSection { private NimbleTable<BreakpointTableRecord>? _table; + private bool _contextMenuOpen; + private double _menuX; + private double _menuY; private string? _contextMenuRecordId; private string _contextMenuRecordState = BreakpointState.Off; private string _lastEvent = "(none)"; + private string ContextMenuStyle => $"position: fixed; left: {_menuX}px; top: {_menuY}px; z-index: 2147483647;"; + private List<BreakpointTableRecord> _tableData = new() { - new("1", "Main.cs", 12, BreakpointState.Enabled), - new("2", "Helper.cs", 45, BreakpointState.Off), - new("3", "Service.cs", 78, BreakpointState.Disabled), - new("4", "Controller.cs", 23, BreakpointState.Hit), - new("5", "Model.cs", 91, BreakpointState.Conditional), - new("6", "Startup.cs", 5, BreakpointState.HitDisabled), - new("7", "Program.cs", 1, BreakpointState.Off), + 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) @@ -42,7 +47,10 @@ private void OnBreakpointContextMenu(BreakpointColumnContextMenuEventArgs e) { _contextMenuRecordId = e.RecordId; _contextMenuRecordState = e.CurrentState; - _lastEvent = $"ContextMenu: record={e.RecordId}, state={e.CurrentState}"; + _menuX = e.AnchorX; + _menuY = e.AnchorY; + _contextMenuOpen = true; + _lastEvent = $"ContextMenu: record={e.RecordId}, state={e.CurrentState}, x={e.AnchorX}, y={e.AnchorY}"; StateHasChanged(); } @@ -71,10 +79,15 @@ private void OnEnableBreakpoint() CloseContextMenuAndSetState(BreakpointState.Enabled); } + private void CloseContextMenu() + { + _contextMenuOpen = false; + StateHasChanged(); + } + private void CloseContextMenuAndSetState(string newState) { - // Close the context menu by triggering Escape key on the element - // This will be handled by the cell-view's anchored-region + _contextMenuOpen = false; SetRecordState(newState); } @@ -91,15 +104,17 @@ private void SetRecordState(string newState) public class BreakpointTableRecord { - public BreakpointTableRecord(string id, string name, int lineNumber, string breakpointState) + 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/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs index 1bcc12bdb3..9d860d2f86 100644 --- a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs @@ -30,4 +30,6 @@ public class BreakpointColumnContextMenuEventArgs : EventArgs { public string RecordId { get; set; } = string.Empty; public string CurrentState { get; set; } = string.Empty; + public double AnchorX { get; set; } + public double AnchorY { 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 010ed5b69e..8a08697be2 100644 --- a/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js +++ b/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js @@ -39,7 +39,9 @@ function registerEvents(Blazor) { createEventArgs: event => { return { recordId: event.detail.recordId, - currentState: event.detail.currentState + currentState: event.detail.currentState, + anchorX: event.detail.anchorX, + anchorY: event.detail.anchorY }; } }); 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 index 50e9a8531b..de5433933f 100644 --- 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 @@ -1,9 +1,5 @@ import { DesignSystem } from '@ni/fast-foundation'; -import { observable } from '@ni/fast-element'; -import { eventChange, keyEscape } from '@ni/fast-web-utilities'; import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; -import type { AnchoredRegion } from '@ni/nimble-components/dist/esm/anchored-region'; -import type { CellViewSlotRequestEventDetail } from '@ni/nimble-components/dist/esm/table/types'; import { template } from './template'; import { styles } from './styles'; import { BreakpointState, type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from '../types'; @@ -28,21 +24,9 @@ export class TsTableColumnBreakpointCellView extends TableCellView< private static readonly menuKeyAlias = 'Menu'; - /** @internal */ - @observable - public open = false; - /** @internal */ public button?: HTMLButtonElement; - /** @internal */ - @observable - public region?: AnchoredRegion; - - /** @internal */ - @observable - public slottedMenus?: HTMLElement[]; - /** @internal */ public get currentState(): BreakpointState { const value = this.cellRecord?.value; @@ -85,61 +69,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< return []; } - public regionChanged( - prev: AnchoredRegion | undefined, - _next: AnchoredRegion | undefined - ): void { - if (prev) { - prev.removeEventListener(eventChange, this.menuChangeHandler); - } - - if (this.region && this.button) { - this.region.anchorElement = this.button; - this.region.addEventListener(eventChange, this.menuChangeHandler, { - capture: true - }); - } - } - - public override disconnectedCallback(): void { - super.disconnectedCallback(); - if (this.region) { - this.region.removeEventListener(eventChange, this.menuChangeHandler); - } - } - - /** @internal */ - public onContextMenuKeyDown(e: KeyboardEvent): boolean { - switch (e.key) { - case keyEscape: - this.setContextMenuOpen(false); - this.button?.focus(); - return false; - default: - return true; - } - } - - /** @internal */ - public regionLoadedHandler(): void { - this.focusMenu(); - } - - /** @internal */ - public onContextMenuFocusOut(e: FocusEvent): boolean { - if (!this.open) { - return true; - } - - const focusTarget = e.relatedTarget as HTMLElement; - if (!this.contains(focusTarget) && !this.getMenu()?.contains(focusTarget)) { - this.setContextMenuOpen(false); - return false; - } - - return true; - } - /** @internal */ public onButtonClick(event: Event): void { event.stopPropagation(); @@ -154,7 +83,8 @@ export class TsTableColumnBreakpointCellView extends TableCellView< public onContextMenu(event: Event): void { event.preventDefault(); event.stopPropagation(); - this.emitContextMenu(); + const mouseEvent = event as MouseEvent; + this.emitContextMenu(mouseEvent.clientX, mouseEvent.clientY); } /** @internal */ @@ -172,7 +102,7 @@ export class TsTableColumnBreakpointCellView extends TableCellView< || event.key === TsTableColumnBreakpointCellView.menuKeyAlias) { event.preventDefault(); event.stopPropagation(); - this.emitContextMenu(); + this.emitContextMenuFromButton(); return; } @@ -195,67 +125,23 @@ export class TsTableColumnBreakpointCellView extends TableCellView< this.$emit('breakpoint-column-toggle', detail); } - private emitContextMenu(): void { - const slotRequestDetail: CellViewSlotRequestEventDetail = { - slots: [{ name: 'menu', slot: 'menu' }] - }; - this.$emit('cell-view-slots-request', slotRequestDetail); - + private emitContextMenu(anchorX: number, anchorY: number): void { const detail: BreakpointContextMenuEventDetail = { recordId: this.recordId ?? '', - currentState: this.currentState + currentState: this.currentState, + anchorX, + anchorY }; - - this.setContextMenuOpen(true); this.$emit('breakpoint-column-context-menu', detail); } - private setContextMenuOpen(newValue: boolean): void { - if (this.open === newValue) { - return; + private emitContextMenuFromButton(): void { + const rect = this.button?.getBoundingClientRect(); + if (rect) { + this.emitContextMenu(rect.left, rect.bottom); + } else { + this.emitContextMenu(0, 0); } - - this.open = newValue; - } - - private getMenu(): HTMLElement | undefined { - // Resolve nested slot forwarding (table -> row -> cell-view) to find the actual menu. - 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 readonly menuChangeHandler = (): void => { - this.setContextMenuOpen(false); - this.button?.focus(); - }; - - private focusMenu(): void { - this.getMenu()?.focus(); } } 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 index 9920c7a267..51554ca2ee 100644 --- 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 @@ -10,7 +10,6 @@ export const styles = css` justify-content: center; width: 100%; height: 100%; - position: relative; } .breakpoint-button { @@ -44,4 +43,5 @@ export const styles = css` .breakpoint-button.state-off:focus-visible svg { 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 index c9deb28ed1..876bc17d33 100644 --- 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 @@ -1,5 +1,4 @@ -import { html, ref, slotted, when } from '@ni/fast-element'; -import { anchoredRegionTag } from '@ni/nimble-components/dist/esm/anchored-region'; +import { html, ref, when } from '@ni/fast-element'; import type { TsTableColumnBreakpointCellView } from '.'; import { BreakpointState } from '../types'; @@ -30,19 +29,4 @@ export const template = html<TsTableColumnBreakpointCellView>` ${when(x => x.currentState === BreakpointState.conditional, conditionalSvg)} ${when(x => x.currentState === BreakpointState.hitDisabled, hitDisabledSvg)} </button> - ${when(x => x.open, html<TsTableColumnBreakpointCellView>` - <${anchoredRegionTag} - ${ref('region')} - part="menu" - fixed-placement="true" - auto-update-mode="auto" - horizontal-inset="true" - horizontal-positioning-mode="dynamic" - vertical-positioning-mode="dynamic" - @loaded="${x => x.regionLoadedHandler()}" - @focusout="${(x, c) => x.onContextMenuFocusOut(c.event as FocusEvent)}" - @keydown="${(x, c) => x.onContextMenuKeyDown(c.event as KeyboardEvent)}" - > - <slot name="menu" ${slotted({ property: 'slottedMenus' })}></slot> - </${anchoredRegionTag}> - `)}`; +`; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts index 4f0b990e92..69b3dd14f4 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/index.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -65,7 +65,6 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint cellRecordFieldNames: ['value'], cellViewTag: tsTableColumnBreakpointCellViewTag, delegatedEvents: ['breakpoint-column-toggle', 'breakpoint-column-context-menu'], - slotNames: ['menu'], validator: new ColumnValidator<[]>([]) }; } 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 index d6527bbe2b..d85166215b 100644 --- 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 @@ -15,6 +15,7 @@ import { interface SimpleTableRecord extends TableRecord { id?: string; + parentId?: string; breakpointState?: string | null; } @@ -266,6 +267,37 @@ describe('TsTableColumnBreakpoint', () => { expect(eventDetail.oldState).toBe(BreakpointState.hit); expect(eventDetail.newState).toBe(BreakpointState.off); }); + + 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 + ); + + const cellView = tablePageObject.getRenderedCellView( + 1, + 0 + ) as TsTableColumnBreakpointCellView; + const button = getBreakpointButton(cellView); + button.click(); + await waitForUpdatesAsync(); + + expect(toggleSpy).toHaveBeenCalledTimes(1); + const eventDetail = ( + toggleSpy.calls.first().args[0] as CustomEvent<BreakpointToggleEventDetail> + ).detail; + expect(eventDetail.recordId).toBe('child'); + expect(eventDetail.oldState).toBe(BreakpointState.off); + expect(eventDetail.newState).toBe(BreakpointState.enabled); + }); }); describe('table selection does not change', () => { diff --git a/packages/ok-components/src/ts/table-column/breakpoint/types.ts b/packages/ok-components/src/ts/table-column/breakpoint/types.ts index 61afeb9612..f8f86e399c 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/types.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/types.ts @@ -26,4 +26,6 @@ export interface BreakpointToggleEventDetail { export interface BreakpointContextMenuEventDetail { recordId: string; currentState: BreakpointState; + anchorX: number; + anchorY: number; } From 5e64d97ffb370d38f5a1dc7a9cb999e5bfbf107a Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 28 May 2026 14:00:56 -0500 Subject: [PATCH 11/23] use new spright icons --- .../CodeAnalysisDictionary.xml | 1 - .../breakpoint/cell-view/styles.ts | 17 ++++++------ .../breakpoint/cell-view/template.ts | 26 +++++++++---------- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/blazor-workspace/CodeAnalysisDictionary.xml b/packages/blazor-workspace/CodeAnalysisDictionary.xml index 3b37eb365a..b2c2aafaf6 100644 --- a/packages/blazor-workspace/CodeAnalysisDictionary.xml +++ b/packages/blazor-workspace/CodeAnalysisDictionary.xml @@ -4,7 +4,6 @@ <Recognized> <Word>args</Word> <Word>blazor</Word> - <Word>breakpoint</Word> <Word>bool</Word> <Word>breakpoint</Word> <Word>clearable</Word> 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 index 51554ca2ee..a2eadad7d5 100644 --- 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 @@ -1,23 +1,24 @@ import { css } from '@ni/fast-element'; +import { display } from '@ni/nimble-components/dist/esm/utilities/style/display'; import { iconSize, } from '@ni/nimble-components/dist/esm/theme-provider/design-tokens'; export const styles = css` + ${display('inline-flex')} + :host { - display: flex; align-items: center; justify-content: center; - width: 100%; - height: 100%; } .breakpoint-button { display: flex; align-items: center; justify-content: center; - width: 100%; - height: 100%; + width: ${iconSize}; + height: ${iconSize}; + flex-shrink: 0; padding: 0; margin: 0; border: none; @@ -35,12 +36,12 @@ export const styles = css` height: ${iconSize}; } - .breakpoint-button.state-off svg { + .breakpoint-button.state-off > * { opacity: 0; } - .breakpoint-button.state-off:hover svg, - .breakpoint-button.state-off:focus-visible svg { + .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 index 876bc17d33..1b1b35385c 100644 --- 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 @@ -1,14 +1,12 @@ import { html, ref, when } from '@ni/fast-element'; import type { TsTableColumnBreakpointCellView } from '.'; import { BreakpointState } from '../types'; - -// Placeholder SVGs for breakpoint states - to be replaced with proper icons later -const offSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="none" stroke="#888" stroke-width="1.5"/></svg>`; -const enabledSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#E51400"/></svg>`; -const disabledSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="none" stroke="#E51400" stroke-width="1.5"/><line x1="2" y1="10" x2="10" y2="2" stroke="#E51400" stroke-width="1.5"/></svg>`; -const hitSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#E51400"/><circle cx="6" cy="6" r="5" fill="none" stroke="#FFD700" stroke-width="1.5"/></svg>`; -const conditionalSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><polygon points="6,1 11,6 6,11 1,6" fill="#FFD700"/></svg>`; -const hitDisabledSvg = html`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="6" cy="6" r="5" fill="#E51400"/><circle cx="6" cy="6" r="5" fill="none" stroke="#FFD700" stroke-width="1.5"/><line x1="2" y1="10" x2="10" y2="2" stroke="#888" stroke-width="1.5"/></svg>`; +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"; export const template = html<TsTableColumnBreakpointCellView>` <button @@ -22,11 +20,11 @@ export const template = html<TsTableColumnBreakpointCellView>` title="${x => x.tooltipText}" tabindex="-1" > - ${when(x => x.currentState === BreakpointState.off, offSvg)} - ${when(x => x.currentState === BreakpointState.enabled, enabledSvg)} - ${when(x => x.currentState === BreakpointState.disabled, disabledSvg)} - ${when(x => x.currentState === BreakpointState.hit, hitSvg)} - ${when(x => x.currentState === BreakpointState.conditional, conditionalSvg)} - ${when(x => x.currentState === BreakpointState.hitDisabled, hitDisabledSvg)} + ${when(x => x.currentState === BreakpointState.off, html`<${iconBreakpointHoverTag} />`)} + ${when(x => x.currentState === BreakpointState.enabled, html`<${iconBreakpointEnabledTag} />`)} + ${when(x => x.currentState === BreakpointState.disabled, html`<${iconBreakpointDisabledTag} />`)} + ${when(x => x.currentState === BreakpointState.hit, html`<${iconBreakpointHitTag} />`)} + ${when(x => x.currentState === BreakpointState.conditional, html`<${iconBreakpointConditionalTag} />`)} + ${when(x => x.currentState === BreakpointState.hitDisabled, html`<${iconBreakpointHitDisabledTag} />`)} </button> `; From 9d84a53cd9abc4f6af63ec0fdedd9ca969799cda Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 28 May 2026 14:16:03 -0500 Subject: [PATCH 12/23] fix imports --- .../table-column/breakpoint/cell-view/template.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 index 1b1b35385c..2fc89c8487 100644 --- 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 @@ -1,12 +1,12 @@ import { html, ref, when } from '@ni/fast-element'; -import type { TsTableColumnBreakpointCellView } from '.'; +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 { BreakpointState } from '../types'; -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 type { TsTableColumnBreakpointCellView } from '.'; export const template = html<TsTableColumnBreakpointCellView>` <button From 0f297713782c996cef9707bb3231b3461bfb7739 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 28 May 2026 15:11:39 -0500 Subject: [PATCH 13/23] cleanup --- .../src/ts/table-column/breakpoint/cell-view/index.ts | 6 ------ 1 file changed, 6 deletions(-) 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 index de5433933f..86e039f7f0 100644 --- 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 @@ -18,10 +18,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig > { - private static readonly contextMenuKey = 'ContextMenu'; - - private static readonly legacyContextMenuKey = 'Apps'; - private static readonly menuKeyAlias = 'Menu'; /** @internal */ @@ -97,8 +93,6 @@ export class TsTableColumnBreakpointCellView extends TableCellView< } if ((event.key === 'F10' && event.shiftKey) - || event.key === TsTableColumnBreakpointCellView.contextMenuKey - || event.key === TsTableColumnBreakpointCellView.legacyContextMenuKey || event.key === TsTableColumnBreakpointCellView.menuKeyAlias) { event.preventDefault(); event.stopPropagation(); From d727a82ebb7d1c31eb671f9922ccdddeddad9c3b Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Thu, 28 May 2026 16:13:15 -0500 Subject: [PATCH 14/23] fix test --- .../breakpoint/tests/ts-table-column-breakpoint.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index d85166215b..7adddc911c 100644 --- 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 @@ -473,7 +473,7 @@ describe('TsTableColumnBreakpoint', () => { expect(eventDetail.currentState).toBe(BreakpointState.enabled); }); - it('emits breakpoint-column-context-menu on ContextMenu key', async () => { + it('emits breakpoint-column-context-menu on Menu key', async () => { await table.setData([ { id: '1', breakpointState: BreakpointState.enabled } ]); @@ -493,7 +493,7 @@ describe('TsTableColumnBreakpoint', () => { button.dispatchEvent( new KeyboardEvent('keydown', { - key: 'ContextMenu', + key: 'Menu', bubbles: true }) ); From 7906497b392edebfe2e9bd6b562a840ed48b29b6 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:27:21 -0500 Subject: [PATCH 15/23] Apply suggestions from code review Co-authored-by: Jesse Attas <jattasNI@users.noreply.github.com> --- .../ts-table-column-breakpoint.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8840521642..9d3e8d5f1b 100644 --- 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 @@ -141,7 +141,7 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { fieldName: { name: 'field-name', description: - 'Set this attribute to identify which field in the data record contains the breakpoint state value for each row.', + '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 } }, From d4c470953dccfbe071c95cb260ebcf5965d721c3 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:18:53 -0500 Subject: [PATCH 16/23] pr feedback --- .../src/ts/table-column/breakpoint/index.ts | 2 +- .../src/ts/table-column/breakpoint/styles.ts | 12 ------------ .../storybook/src/docs/component-data/ok/ts/index.ts | 9 +++++++++ .../ok/ts/icon-dynamic/ts-icon-dynamic.stories.ts | 7 ++++++- .../ts-table-column-breakpoint.stories.ts | 7 ++++++- 5 files changed, 22 insertions(+), 15 deletions(-) delete mode 100644 packages/ok-components/src/ts/table-column/breakpoint/styles.ts diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts index 69b3dd14f4..494c0acf53 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/index.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -5,10 +5,10 @@ import type { ColumnInternalsOptions } from '@ni/nimble-components/dist/esm/tabl 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 type { BreakpointToggleEventDetail, BreakpointContextMenuEventDetail } from './types'; import { tsTableColumnBreakpointCellViewTag } from './cell-view'; -import { styles } from './styles'; import { template } from './template'; export type TsTableColumnBreakpointCellRecord = TableStringField<'value'>; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/styles.ts b/packages/ok-components/src/ts/table-column/breakpoint/styles.ts deleted file mode 100644 index a567158eb9..0000000000 --- a/packages/ok-components/src/ts/table-column/breakpoint/styles.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { css } from '@ni/fast-element'; -import { display } from '../../../utilities/style/display'; - -export const styles = css` - ${display('contents')} - - .header-content { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } -`; 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<OkTsIconDynamicArgs> = { chromatic: { disableSnapshot: true }, }, render: createUserSelectedThemeStory(html` + ${okWarning({ + componentName: 'ts icon dynamic', + statusLink: './?path=/docs/component-status--docs#ok-components' + })} <${tagName}></${tagName}> `), args: { 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 index 9d3e8d5f1b..dca214d1db 100644 --- 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 @@ -15,7 +15,8 @@ import { import { apiCategory, createUserSelectedThemeStory, - disableStorybookZoomTransform + disableStorybookZoomTransform, + okWarning } from '../../../utilities/storybook'; interface CodeRecord extends TableRecord { @@ -113,6 +114,10 @@ interface BreakpointColumnTableArgs extends SharedTableArgs { 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')} From 9cf5d1ae1eab0175cebb82099705bb1927b3c536 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:20:17 -0500 Subject: [PATCH 17/23] make code column unsortable --- .../ts-table-column-breakpoint.stories.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index dca214d1db..4487e8a0e4 100644 --- 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 @@ -137,7 +137,10 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { }}" > </${tsTableColumnBreakpointTag}> - <${tableColumnTextTag} field-name="code"> + <${tableColumnTextTag} + field-name="code" + sorting-disabled="true" + > Code </${tableColumnTextTag}> </${tableTag}> From f475ef12cd349683504a4400cd845a29c575aaed Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:31:29 -0500 Subject: [PATCH 18/23] fix keyboard nav --- .../ts/table-column/breakpoint/cell-view/index.ts | 14 +++++--------- .../ts/table-column/breakpoint/cell-view/styles.ts | 10 +++++++--- .../table-column/breakpoint/cell-view/template.ts | 1 + 3 files changed, 13 insertions(+), 12 deletions(-) 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 index 86e039f7f0..3ff2fe7051 100644 --- 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 @@ -84,27 +84,23 @@ export class TsTableColumnBreakpointCellView extends TableCellView< } /** @internal */ - public onKeyDown(event: KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - event.stopPropagation(); - this.onButtonClick(event); - return; - } - + public onKeyDown(event: KeyboardEvent): boolean { if ((event.key === 'F10' && event.shiftKey) || event.key === TsTableColumnBreakpointCellView.menuKeyAlias) { event.preventDefault(); event.stopPropagation(); this.emitContextMenuFromButton(); - return; + return false; } if (event.key === 'F9' || ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'b')) { event.preventDefault(); event.stopPropagation(); this.onButtonClick(event); + return false; } + + return true; } private emitToggle( 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 index a2eadad7d5..6fafa3606a 100644 --- 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 @@ -1,6 +1,8 @@ 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'; @@ -10,14 +12,13 @@ export const styles = css` :host { align-items: center; justify-content: center; + flex-shrink: 0; } .breakpoint-button { display: flex; align-items: center; justify-content: center; - width: ${iconSize}; - height: ${iconSize}; flex-shrink: 0; padding: 0; margin: 0; @@ -25,10 +26,13 @@ export const styles = css` background: transparent; cursor: pointer; outline-offset: -1px; + width: ${iconSize}; + height: ${iconSize}; } .breakpoint-button:focus-visible { - outline: 2px solid Highlight; + outline: calc(2 * ${borderWidth}) solid ${borderHoverColor}; + outline-offset: -2px; } .breakpoint-button svg { 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 index 2fc89c8487..b8c0c03f86 100644 --- 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 @@ -18,6 +18,7 @@ export const template = html<TsTableColumnBreakpointCellView>` @keydown="${(x, c) => x.onKeyDown(c.event as KeyboardEvent)}" aria-label="${x => x.ariaLabelText}" title="${x => x.tooltipText}" + ${'' /* tabindex managed dynamically by KeyboardNavigationManager */} tabindex="-1" > ${when(x => x.currentState === BreakpointState.off, html`<${iconBreakpointHoverTag} />`)} From 978f85acaff188c134b54232689fc4f1630f0fbf Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:46:59 -0500 Subject: [PATCH 19/23] make string observable --- .../breakpoint/cell-view/index.ts | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) 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 index 3ff2fe7051..1dd3a06c3f 100644 --- 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 @@ -1,5 +1,6 @@ import { DesignSystem } from '@ni/fast-foundation'; import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; +import { observable } from '@ni/fast-element'; import { template } from './template'; import { styles } from './styles'; import { BreakpointState, type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from '../types'; @@ -23,6 +24,34 @@ export class TsTableColumnBreakpointCellView extends TableCellView< /** @internal */ public button?: HTMLButtonElement; + /** @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; @@ -35,26 +64,26 @@ export class TsTableColumnBreakpointCellView extends TableCellView< /** @internal */ public get tooltipText(): string { if (this.currentState === BreakpointState.off) { - return 'Add breakpoint'; + return this.breakpointAddString; } - return 'Remove breakpoint'; + return this.breakpointRemoveString; } /** @internal */ public get ariaLabelText(): string { switch (this.currentState) { case BreakpointState.enabled: - return 'Breakpoint enabled'; + return this.breakpointEnabledString; case BreakpointState.disabled: - return 'Breakpoint disabled'; + return this.breakpointDisabledString; case BreakpointState.hit: - return 'Breakpoint hit'; + return this.breakpointHitString; case BreakpointState.conditional: - return 'Conditional breakpoint'; + return this.breakpointConditionalString; case BreakpointState.hitDisabled: - return 'Breakpoint hit (disabled)'; + return this.breakpointHitDisabledString; default: - return 'Add breakpoint'; + return this.breakpointAddString; } } From a4c9754b303871b625b8e58776c9a87de88e6906 Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:36:24 -0500 Subject: [PATCH 20/23] updating tests --- .../ts-table-column-breakpoint.pageobject.ts | 87 ++++ .../tests/ts-table-column-breakpoint.spec.ts | 430 +++++------------- 2 files changed, 211 insertions(+), 306 deletions(-) create mode 100644 packages/ok-components/src/ts/table-column/breakpoint/tests/ts-table-column-breakpoint.pageobject.ts 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<T extends TableRecord> { + public constructor(private readonly tablePageObject: TablePageObject<T>) {} + + 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<HTMLButtonElement>('.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 index 7adddc911c..f8b5c95a23 100644 --- 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 @@ -1,4 +1,5 @@ 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 { waitForUpdatesAsync } from '@ni/nimble-components/dist/esm/testing/async-helpers'; import { TablePageObject } from '@ni/nimble-components/dist/esm/table/testing/table.pageobject'; @@ -6,7 +7,7 @@ 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 { TsTableColumnBreakpointCellView } from '../cell-view'; +import { TsTableColumnBreakpointPageObject } from './ts-table-column-breakpoint.pageobject'; import { BreakpointState, type BreakpointToggleEventDetail, @@ -30,6 +31,7 @@ describe('TsTableColumnBreakpoint', () => { let disconnect: () => Promise<void>; let elementReferences: ElementReferences; let tablePageObject: TablePageObject<SimpleTableRecord>; + let breakpointPageObject: TsTableColumnBreakpointPageObject<SimpleTableRecord>; async function setup( source: ElementReferences @@ -43,18 +45,6 @@ describe('TsTableColumnBreakpoint', () => { ); } - function getBreakpointButton( - cellView: TsTableColumnBreakpointCellView - ): HTMLButtonElement { - const button = cellView.shadowRoot!.querySelector<HTMLButtonElement>( - '.breakpoint-button' - ); - if (!button) { - throw new Error('Expected breakpoint button'); - } - return button; - } - function getContextMenuEventDetail( contextMenuSpy: jasmine.Spy ): BreakpointContextMenuEventDetail { @@ -68,6 +58,7 @@ describe('TsTableColumnBreakpoint', () => { ({ connect, disconnect } = await setup(elementReferences)); table = elementReferences.table; tablePageObject = new TablePageObject<SimpleTableRecord>(table); + breakpointPageObject = new TsTableColumnBreakpointPageObject(tablePageObject); await connect(); await waitForUpdatesAsync(); }); @@ -93,179 +84,101 @@ describe('TsTableColumnBreakpoint', () => { }); describe('rendering breakpoint states', () => { - it('renders off state when field value is "off"', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.off } - ]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.off); - }); - - it('renders enabled state when field value is "enabled"', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.enabled } - ]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.enabled); - }); - - it('renders disabled state when field value is "disabled"', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.disabled } - ]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.disabled); - }); - - it('renders hit state when field value is "hit"', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.hit } - ]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.hit); - }); - - it('renders off state when field value is null', async () => { - await table.setData([{ id: '1', breakpointState: null }]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.off); - }); - - it('renders off state when field value is undefined', async () => { - await table.setData([{ id: '1', breakpointState: undefined }]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.off); - }); - - it('renders off state when field value is invalid', async () => { - await table.setData([ - { id: '1', breakpointState: 'invalid-state' } - ]); - await waitForUpdatesAsync(); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.off); + 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', () => { - it('emits toggle event from off to enabled on click', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.off } - ]); - await waitForUpdatesAsync(); - - const toggleSpy = jasmine.createSpy('toggle'); - elementReferences.column.addEventListener( - 'breakpoint-column-toggle', - toggleSpy - ); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - button.click(); - await waitForUpdatesAsync(); - - expect(toggleSpy).toHaveBeenCalledTimes(1); - const eventDetail = ( - toggleSpy.calls.first().args[0] as CustomEvent<BreakpointToggleEventDetail> - ).detail; - expect(eventDetail.oldState).toBe(BreakpointState.off); - expect(eventDetail.newState).toBe(BreakpointState.enabled); - expect(eventDetail.recordId).toBe('1'); - }); - - it('emits toggle event from enabled to off on click', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.enabled } - ]); - await waitForUpdatesAsync(); - - const toggleSpy = jasmine.createSpy('toggle'); - elementReferences.column.addEventListener( - 'breakpoint-column-toggle', - toggleSpy - ); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - button.click(); - await waitForUpdatesAsync(); - - expect(toggleSpy).toHaveBeenCalledTimes(1); - const eventDetail = ( - toggleSpy.calls.first().args[0] as CustomEvent<BreakpointToggleEventDetail> - ).detail; - expect(eventDetail.oldState).toBe(BreakpointState.enabled); - expect(eventDetail.newState).toBe(BreakpointState.off); - expect(eventDetail.recordId).toBe('1'); - }); - - it('emits toggle event from hit to off on click', async () => { - await table.setData([ - { id: '1', breakpointState: BreakpointState.hit } - ]); - await waitForUpdatesAsync(); - - const toggleSpy = jasmine.createSpy('toggle'); - elementReferences.column.addEventListener( - 'breakpoint-column-toggle', - toggleSpy - ); - - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - button.click(); - await waitForUpdatesAsync(); - - expect(toggleSpy).toHaveBeenCalledTimes(1); - const eventDetail = ( - toggleSpy.calls.first().args[0] as CustomEvent<BreakpointToggleEventDetail> - ).detail; - expect(eventDetail.oldState).toBe(BreakpointState.hit); - expect(eventDetail.newState).toBe(BreakpointState.off); + 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<BreakpointToggleEventDetail> + ).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 () => { @@ -282,12 +195,7 @@ describe('TsTableColumnBreakpoint', () => { toggleSpy ); - const cellView = tablePageObject.getRenderedCellView( - 1, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - button.click(); + breakpointPageObject.clickBreakpointButton(1, 0); await waitForUpdatesAsync(); expect(toggleSpy).toHaveBeenCalledTimes(1); @@ -301,8 +209,6 @@ describe('TsTableColumnBreakpoint', () => { }); describe('table selection does not change', () => { - let button: HTMLButtonElement; - beforeEach(async () => { table.selectionMode = 'multiple'; await waitForUpdatesAsync(); @@ -310,16 +216,11 @@ describe('TsTableColumnBreakpoint', () => { await table.setData([{ id: '1', breakpointState: BreakpointState.off }]); await waitForUpdatesAsync(); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - button = getBreakpointButton(cellView); - button.focus(); + breakpointPageObject.focusBreakpointButton(0, 0); }); it('when clicking a breakpoint button', async () => { - button.click(); + breakpointPageObject.clickBreakpointButton(0, 0); await waitForUpdatesAsync(); const selection = await table.getSelectedRecordIds(); @@ -327,12 +228,7 @@ describe('TsTableColumnBreakpoint', () => { }); it('when toggling a breakpoint button by pressing Enter', async () => { - button.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true - }) - ); + breakpointPageObject.pressBreakpointButtonKey(0, 0, { key: 'Enter' }); await waitForUpdatesAsync(); const selection = await table.getSelectedRecordIds(); @@ -347,11 +243,7 @@ describe('TsTableColumnBreakpoint', () => { ]); await waitForUpdatesAsync(); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.tooltipText).toBe('Add breakpoint'); + expect(breakpointPageObject.getTooltipText(0, 0)).toBe('Add breakpoint'); }); it('shows "Remove breakpoint" when state is enabled', async () => { @@ -360,11 +252,7 @@ describe('TsTableColumnBreakpoint', () => { ]); await waitForUpdatesAsync(); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.tooltipText).toBe('Remove breakpoint'); + expect(breakpointPageObject.getTooltipText(0, 0)).toBe('Remove breakpoint'); }); }); @@ -375,11 +263,7 @@ describe('TsTableColumnBreakpoint', () => { ]); await waitForUpdatesAsync(); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.tabbableChildren.length).toBe(1); + expect(breakpointPageObject.getTabbableChildrenCount(0, 0)).toBe(1); }); }); @@ -396,14 +280,7 @@ describe('TsTableColumnBreakpoint', () => { contextMenuSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - button.dispatchEvent( - new MouseEvent('contextmenu', { bubbles: true }) - ); + breakpointPageObject.rightClickBreakpointButton(0, 0); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); @@ -424,14 +301,7 @@ describe('TsTableColumnBreakpoint', () => { contextMenuSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - button.dispatchEvent( - new MouseEvent('contextmenu', { bubbles: true }) - ); + breakpointPageObject.rightClickBreakpointButton(0, 0); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); @@ -452,19 +322,10 @@ describe('TsTableColumnBreakpoint', () => { contextMenuSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - - button.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'F10', - shiftKey: true, - bubbles: true - }) - ); + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'F10', + shiftKey: true + }); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); @@ -485,18 +346,9 @@ describe('TsTableColumnBreakpoint', () => { contextMenuSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - - button.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Menu', - bubbles: true - }) - ); + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'Menu' + }); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(1); @@ -517,20 +369,10 @@ describe('TsTableColumnBreakpoint', () => { contextMenuSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - - button.dispatchEvent( - new MouseEvent('contextmenu', { bubbles: true }) - ); + breakpointPageObject.rightClickBreakpointButton(0, 0); await waitForUpdatesAsync(); - button.dispatchEvent( - new MouseEvent('contextmenu', { bubbles: true }) - ); + breakpointPageObject.rightClickBreakpointButton(0, 0); await waitForUpdatesAsync(); expect(contextMenuSpy).toHaveBeenCalledTimes(2); @@ -550,18 +392,9 @@ describe('TsTableColumnBreakpoint', () => { toggleSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - - button.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'F9', - bubbles: true - }) - ); + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'F9' + }); await waitForUpdatesAsync(); expect(toggleSpy).toHaveBeenCalledTimes(1); @@ -584,19 +417,10 @@ describe('TsTableColumnBreakpoint', () => { toggleSpy ); - const cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - const button = getBreakpointButton(cellView); - - button.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'b', - ctrlKey: true, - bubbles: true - }) - ); + breakpointPageObject.pressBreakpointButtonKey(0, 0, { + key: 'b', + ctrlKey: true + }); await waitForUpdatesAsync(); expect(toggleSpy).toHaveBeenCalledTimes(1); @@ -618,20 +442,14 @@ describe('TsTableColumnBreakpoint', () => { ]); await waitForUpdatesAsync(); - let cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.enabled); + expect(breakpointPageObject.getCurrentState(0, 0)).toBe( + BreakpointState.enabled + ); elementReferences.column.fieldName = undefined; await waitForUpdatesAsync(); - cellView = tablePageObject.getRenderedCellView( - 0, - 0 - ) as TsTableColumnBreakpointCellView; - expect(cellView.currentState).toBe(BreakpointState.off); + expect(breakpointPageObject.getCurrentState(0, 0)).toBe(BreakpointState.off); }); }); }); From c08ca4ed73fb048769f03fde69a3812f467fcd2f Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:06:30 -0500 Subject: [PATCH 21/23] document keyboard interactions --- .../table-column-breakpoint/ts-table-column-breakpoint.mdx | 5 +++++ 1 file changed, 5 insertions(+) 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 index fca00aebc6..0271dc47a4 100644 --- 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 @@ -34,3 +34,8 @@ The column is neither sortable nor groupable. It has a fixed width of 32 pixels | `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. From 184fac411831a73a78f3af83eca63b9adda3a1bc Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:00:55 -0500 Subject: [PATCH 22/23] refactor to use anchored region --- .../Ts/TsBreakpointTableSection.razor | 17 +- .../Ts/TsBreakpointTableSection.razor.cs | 40 +--- .../BreakpointEventArgs.cs | 2 - .../OkTsTableColumnBreakpoint.razor | 5 +- .../OkTsTableColumnBreakpoint.razor.cs | 16 +- .../OkBlazor/wwwroot/OkBlazor.lib.module.js | 4 +- .../breakpoint/cell-view/index.ts | 196 ++++++++++++++++-- .../breakpoint/cell-view/template.ts | 67 ++++-- .../src/ts/table-column/breakpoint/index.ts | 30 ++- .../tests/ts-table-column-breakpoint.spec.ts | 17 +- .../src/ts/table-column/breakpoint/types.ts | 5 +- .../ts-table-column-breakpoint.stories.ts | 17 ++ 12 files changed, 317 insertions(+), 99 deletions(-) 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 index d9719bd51e..42f7a22bd0 100644 --- a/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor +++ b/packages/blazor-workspace/Examples/Demo.Shared/Pages/Sections/Ts/TsBreakpointTableSection.razor @@ -6,16 +6,13 @@ <NimbleTableColumnText FieldName="Name" FractionalWidth="2">Name</NimbleTableColumnText> <OkTsTableColumnBreakpoint FieldName="BreakpointState" - BreakpointToggle="OnBreakpointToggle" - BreakpointContextMenu="OnBreakpointContextMenu"> + MenuSlot="breakpoint-menu" + BreakpointContextMenu="OnBreakpointContextMenu" + BreakpointToggle="OnBreakpointToggle"> </OkTsTableColumnBreakpoint> <NimbleTableColumnNumberText FieldName="LineNumber">Line</NimbleTableColumnNumberText> - </NimbleTable> - - @if (_contextMenuOpen) - { - <div style="position: fixed; inset: 0; z-index: 2147483646;" @onclick="CloseContextMenu"></div> - <NimbleMenu style="@ContextMenuStyle"> + + <NimbleMenu slot="breakpoint-menu"> @if (_contextMenuRecordState == OkBlazor.BreakpointState.Off) { <NimbleMenuItem @onchange="@(_ => OnAddBreakpoint())">Add breakpoint</NimbleMenuItem> @@ -37,7 +34,7 @@ } } </NimbleMenu> - } - <p><strong>Last event:</strong> @_lastEvent</p> + + </NimbleTable> </SubContainer> </div> 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 index 855f4f506b..c03ac4594f 100644 --- 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 @@ -6,14 +6,8 @@ namespace Demo.Shared.Pages.Sections; public partial class TsBreakpointTableSection { private NimbleTable<BreakpointTableRecord>? _table; - private bool _contextMenuOpen; - private double _menuX; - private double _menuY; private string? _contextMenuRecordId; private string _contextMenuRecordState = BreakpointState.Off; - private string _lastEvent = "(none)"; - - private string ContextMenuStyle => $"position: fixed; left: {_menuX}px; top: {_menuY}px; z-index: 2147483647;"; private List<BreakpointTableRecord> _tableData = new() { @@ -28,7 +22,11 @@ public partial class TsBreakpointTableSection protected override async Task OnAfterRenderAsync(bool firstRender) { - await _table!.SetDataAsync(_tableData); + if (firstRender) + { + await _table!.SetDataAsync(_tableData); + } + await base.OnAfterRenderAsync(firstRender); } @@ -39,7 +37,6 @@ private void OnBreakpointToggle(BreakpointColumnToggleEventArgs e) { record.BreakpointState = e.NewState; } - _lastEvent = $"Toggle: record={e.RecordId}, {e.OldState} -> {e.NewState}"; StateHasChanged(); } @@ -47,48 +44,33 @@ private void OnBreakpointContextMenu(BreakpointColumnContextMenuEventArgs e) { _contextMenuRecordId = e.RecordId; _contextMenuRecordState = e.CurrentState; - _menuX = e.AnchorX; - _menuY = e.AnchorY; - _contextMenuOpen = true; - _lastEvent = $"ContextMenu: record={e.RecordId}, state={e.CurrentState}, x={e.AnchorX}, y={e.AnchorY}"; + StateHasChanged(); } private void OnAddBreakpoint() { - CloseContextMenuAndSetState(BreakpointState.Enabled); + SetRecordState(BreakpointState.Enabled); } private void OnAddConditionalBreakpoint() { - CloseContextMenuAndSetState(BreakpointState.Conditional); + SetRecordState(BreakpointState.Conditional); } private void OnRemoveBreakpoint() { - CloseContextMenuAndSetState(BreakpointState.Off); + SetRecordState(BreakpointState.Off); } private void OnDisableBreakpoint() { - CloseContextMenuAndSetState(BreakpointState.Disabled); + SetRecordState(BreakpointState.Disabled); } private void OnEnableBreakpoint() { - CloseContextMenuAndSetState(BreakpointState.Enabled); - } - - private void CloseContextMenu() - { - _contextMenuOpen = false; - StateHasChanged(); - } - - private void CloseContextMenuAndSetState(string newState) - { - _contextMenuOpen = false; - SetRecordState(newState); + SetRecordState(BreakpointState.Enabled); } private void SetRecordState(string newState) diff --git a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs index 9d860d2f86..1bcc12bdb3 100644 --- a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/BreakpointEventArgs.cs @@ -30,6 +30,4 @@ public class BreakpointColumnContextMenuEventArgs : EventArgs { public string RecordId { get; set; } = string.Empty; public string CurrentState { get; set; } = string.Empty; - public double AnchorX { get; set; } - public double AnchorY { get; set; } } diff --git a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor index 0031c4385f..ebe94a213c 100644 --- a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor @@ -4,7 +4,8 @@ column-id="@ColumnId" field-name="@FieldName" column-hidden="@ColumnHidden" - @onokbreakpointcolumntoggle="HandleBreakpointToggle" - @onokbreakpointcolumncontextmenu="HandleBreakpointContextMenu" + menu-slot="@MenuSlot" + @onokbreakpointcolumntoggle="BreakpointToggle" + @onokbreakpointcolumncontextmenu="BreakpointContextMenu" @attributes="AdditionalAttributes"> </ok-ts-table-column-breakpoint> diff --git a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs index 98cd975b93..d4bdd39caa 100644 --- a/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs +++ b/packages/blazor-workspace/OkBlazor/Source/Ts/TableColumnBreakpoint/OkTsTableColumnBreakpoint.razor.cs @@ -18,6 +18,12 @@ public partial class OkTsTableColumnBreakpoint : ComponentBase [DisallowNull] public string FieldName { get; set; } = null!; + /// <summary> + /// 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. + /// </summary> + [Parameter] + public string? MenuSlot { get; set; } + /// <summary> /// Whether or not the column should be hidden. /// </summary> @@ -41,14 +47,4 @@ public partial class OkTsTableColumnBreakpoint : ComponentBase /// </summary> [Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; } - - protected async void HandleBreakpointToggle(BreakpointColumnToggleEventArgs eventArgs) - { - await BreakpointToggle.InvokeAsync(eventArgs); - } - - protected async void HandleBreakpointContextMenu(BreakpointColumnContextMenuEventArgs eventArgs) - { - await BreakpointContextMenu.InvokeAsync(eventArgs); - } } diff --git a/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js b/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js index 8a08697be2..010ed5b69e 100644 --- a/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js +++ b/packages/blazor-workspace/OkBlazor/wwwroot/OkBlazor.lib.module.js @@ -39,9 +39,7 @@ function registerEvents(Blazor) { createEventArgs: event => { return { recordId: event.detail.recordId, - currentState: event.detail.currentState, - anchorX: event.detail.anchorX, - anchorY: event.detail.anchorY + currentState: event.detail.currentState }; } }); 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 index 1dd3a06c3f..8b568132a7 100644 --- 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 @@ -1,9 +1,17 @@ import { DesignSystem } from '@ni/fast-foundation'; import { TableCellView } from '@ni/nimble-components/dist/esm/table-column/base/cell-view'; -import { observable } from '@ni/fast-element'; +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, type BreakpointToggleEventDetail, type BreakpointContextMenuEventDetail } from '../types'; +import { + BreakpointState, + breakpointCellViewMenuSlotName, + type BreakpointToggleEventDetail, + type BreakpointContextMenuEventDetail +} from '../types'; import type { TsTableColumnBreakpointCellRecord, TsTableColumnBreakpointColumnConfig } from '..'; declare global { @@ -21,9 +29,27 @@ export class TsTableColumnBreakpointCellView extends TableCellView< > { 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'; @@ -87,6 +113,11 @@ export class TsTableColumnBreakpointCellView extends TableCellView< } } + /** @internal */ + public get menuPosition(): BreakpointMenuPosition { + return this.columnConfig?.position ?? MenuButtonPosition.auto; + } + public override get tabbableChildren(): HTMLElement[] { if (this.button) { return [this.button]; @@ -108,17 +139,17 @@ export class TsTableColumnBreakpointCellView extends TableCellView< public onContextMenu(event: Event): void { event.preventDefault(); event.stopPropagation(); - const mouseEvent = event as MouseEvent; - this.emitContextMenu(mouseEvent.clientX, mouseEvent.clientY); + this.requestContextMenu(); } /** @internal */ public onKeyDown(event: KeyboardEvent): boolean { if ((event.key === 'F10' && event.shiftKey) - || event.key === TsTableColumnBreakpointCellView.menuKeyAlias) { + || event.key === TsTableColumnBreakpointCellView.menuKeyAlias + || (event.key === TsTableColumnBreakpointCellView.contextMenuKeyAlias)) { event.preventDefault(); event.stopPropagation(); - this.emitContextMenuFromButton(); + this.requestContextMenu(); return false; } @@ -129,39 +160,168 @@ export class TsTableColumnBreakpointCellView extends TableCellView< 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 ?? '', + recordId: this.recordId!, newState, oldState }; this.$emit('breakpoint-column-toggle', detail); } - private emitContextMenu(anchorX: number, anchorY: number): void { + private requestContextMenu(): void { + this.openMenuFromColumnSlot(); + const detail: BreakpointContextMenuEventDetail = { - recordId: this.recordId ?? '', - currentState: this.currentState, - anchorX, - anchorY + recordId: this.recordId!, + currentState: this.currentState }; this.$emit('breakpoint-column-context-menu', detail); } - private emitContextMenuFromButton(): void { - const rect = this.button?.getBoundingClientRect(); - if (rect) { - this.emitContextMenu(rect.left, rect.bottom); - } else { - this.emitContextMenu(0, 0); + 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({ 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 index b8c0c03f86..ba16095627 100644 --- 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 @@ -1,31 +1,58 @@ -import { html, ref, when } from '@ni/fast-element'; +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 { BreakpointState } from '../types'; +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<TsTableColumnBreakpointCellView>` - <button - ${ref('button')} - part="button" - class="breakpoint-button state-${x => x.currentState}" - @click="${(x, c) => x.onButtonClick(c.event)}" - @contextmenu="${(x, c) => x.onContextMenu(c.event)}" - @keydown="${(x, c) => x.onKeyDown(c.event as KeyboardEvent)}" - aria-label="${x => x.ariaLabelText}" - title="${x => x.tooltipText}" - ${'' /* tabindex managed dynamically by KeyboardNavigationManager */} - tabindex="-1" + <template + ?open="${x => x.open}" + @focusout="${(x, c) => x.focusoutHandler(c.event as FocusEvent)}" > - ${when(x => x.currentState === BreakpointState.off, html`<${iconBreakpointHoverTag} />`)} - ${when(x => x.currentState === BreakpointState.enabled, html`<${iconBreakpointEnabledTag} />`)} - ${when(x => x.currentState === BreakpointState.disabled, html`<${iconBreakpointDisabledTag} />`)} - ${when(x => x.currentState === BreakpointState.hit, html`<${iconBreakpointHitTag} />`)} - ${when(x => x.currentState === BreakpointState.conditional, html`<${iconBreakpointConditionalTag} />`)} - ${when(x => x.currentState === BreakpointState.hitDisabled, html`<${iconBreakpointHitDisabledTag} />`)} - </button> + <button + ${ref('button')} + part="button" + class="breakpoint-button state-${x => x.currentState}" + @click="${(x, c) => x.onButtonClick(c.event)}" + @contextmenu="${(x, c) => x.onContextMenu(c.event)}" + @keydown="${(x, c) => x.onKeyDown(c.event as KeyboardEvent)}" + aria-label="${x => x.ariaLabelText}" + title="${x => x.tooltipText}" + ${'' /* tabindex managed dynamically by KeyboardNavigationManager */} + tabindex="-1" + > + ${when(x => x.currentState === BreakpointState.off, html`<${iconBreakpointHoverTag} />`)} + ${when(x => x.currentState === BreakpointState.enabled, html`<${iconBreakpointEnabledTag} />`)} + ${when(x => x.currentState === BreakpointState.disabled, html`<${iconBreakpointDisabledTag} />`)} + ${when(x => x.currentState === BreakpointState.hit, html`<${iconBreakpointHitTag} />`)} + ${when(x => x.currentState === BreakpointState.conditional, html`<${iconBreakpointConditionalTag} />`)} + ${when(x => x.currentState === BreakpointState.hitDisabled, html`<${iconBreakpointHitDisabledTag} />`)} + </button> + ${when( + x => x.open, + html<TsTableColumnBreakpointCellView>` + <${anchoredRegionTag} + fixed-placement="true" + auto-update-mode="auto" + horizontal-inset="true" + horizontal-positioning-mode="dynamic" + vertical-positioning-mode="${x => (x.menuPosition === MenuButtonPosition.auto ? 'dynamic' : 'locktodefault')}" + vertical-default-position="${x => (x.menuPosition === MenuButtonPosition.above ? 'top' : 'bottom')}" + @loaded="${x => x.regionLoadedHandler()}" + @keydown="${(x, c) => x.contextMenuKeyDownHandler(c.event as KeyboardEvent)}" + ${ref('region')} + > + <span part="menu"> + <slot name="${breakpointCellViewMenuSlotName}" ${slotted({ property: 'slottedMenus' })}></slot> + </span> + </${anchoredRegionTag}> + ` + )} + </template> `; diff --git a/packages/ok-components/src/ts/table-column/breakpoint/index.ts b/packages/ok-components/src/ts/table-column/breakpoint/index.ts index 494c0acf53..db9565225f 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/index.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/index.ts @@ -7,14 +7,18 @@ import { ColumnValidator } from '@ni/nimble-components/dist/esm/table-column/bas 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'>; -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface TsTableColumnBreakpointColumnConfig {} +export interface TsTableColumnBreakpointColumnConfig { + menuSlot?: string; + position?: BreakpointMenuPosition; +} declare global { interface HTMLElementTagNameMap { @@ -29,6 +33,12 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint @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. @@ -65,6 +75,7 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint cellRecordFieldNames: ['value'], cellViewTag: tsTableColumnBreakpointCellViewTag, delegatedEvents: ['breakpoint-column-toggle', 'breakpoint-column-context-menu'], + slotNames: [breakpointCellViewMenuSlotName], validator: new ColumnValidator<[]>([]) }; } @@ -73,6 +84,21 @@ export class TsTableColumnBreakpoint extends TableColumn<TsTableColumnBreakpoint 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({ 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 index f8b5c95a23..b3bf9ea3f9 100644 --- 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 @@ -1,6 +1,8 @@ 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'; @@ -23,6 +25,9 @@ interface SimpleTableRecord extends TableRecord { class ElementReferences { public table!: Table; public column!: TsTableColumnBreakpoint; + public firstMenuItem!: MenuItem; + public secondMenuItem!: MenuItem; + public lastMenuItem!: MenuItem; } describe('TsTableColumnBreakpoint', () => { @@ -38,8 +43,18 @@ describe('TsTableColumnBreakpoint', () => { ): Promise<Fixture<Table<SimpleTableRecord>>> { return await fixture<Table<SimpleTableRecord>>( html`<${tableTag} ${ref('table')} id-field-name="id" style="width: 700px"> - <${tsTableColumnBreakpointTag} ${ref('column')} field-name="breakpointState"> + <${tsTableColumnBreakpointTag} + ${ref('column')} + field-name="breakpointState" + menu-slot="breakpoint-menu" + > </${tsTableColumnBreakpointTag}> + + <${menuTag} slot="breakpoint-menu"> + <${menuItemTag} ${ref('firstMenuItem')}>Toggle</${menuItemTag}> + <${menuItemTag} ${ref('secondMenuItem')}>Disable</${menuItemTag}> + <${menuItemTag} ${ref('lastMenuItem')}>Edit</${menuItemTag}> + </${menuTag}> </${tableTag}>`, { source } ); diff --git a/packages/ok-components/src/ts/table-column/breakpoint/types.ts b/packages/ok-components/src/ts/table-column/breakpoint/types.ts index f8f86e399c..e3ffea578f 100644 --- a/packages/ok-components/src/ts/table-column/breakpoint/types.ts +++ b/packages/ok-components/src/ts/table-column/breakpoint/types.ts @@ -26,6 +26,7 @@ export interface BreakpointToggleEventDetail { export interface BreakpointContextMenuEventDetail { recordId: string; currentState: BreakpointState; - anchorX: number; - anchorY: number; } + +/** @internal */ +export const breakpointCellViewMenuSlotName = 'menu'; 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 index 4487e8a0e4..576ee713ae 100644 --- 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 @@ -4,6 +4,8 @@ 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 { @@ -106,6 +108,7 @@ export default metadata; interface BreakpointColumnTableArgs extends SharedTableArgs { fieldName: string; + menuSlot: string; toggleEvent: never; contextMenuEvent: never; currentData: CodeRecord[]; @@ -127,6 +130,7 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { > <${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; @@ -143,6 +147,11 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { > Code </${tableColumnTextTag}> + <${menuTag} slot="${x => x.menuSlot}"> + <${menuItemTag}>Enable breakpoint</${menuItemTag}> + <${menuItemTag}>Disable breakpoint</${menuItemTag}> + <${menuItemTag}>Remove breakpoint</${menuItemTag}> + </${menuTag}> </${tableTag}> `), argTypes: { @@ -153,6 +162,13 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { 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: @@ -175,6 +191,7 @@ export const breakpointColumn: StoryObj<BreakpointColumnTableArgs> = { }, args: { fieldName: 'breakpointState', + menuSlot: 'breakpoint-menu', currentData: [...simpleData] } }; From 431a7fa0daa3589c935390d386d3c695179effff Mon Sep 17 00:00:00 2001 From: Valerie Gleason <5265744+hellovolcano@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:09:57 -0500 Subject: [PATCH 23/23] fix blazor example --- .../Pages/Sections/Ts/TsBreakpointTableSection.razor.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index c03ac4594f..d2ee3351d1 100644 --- 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 @@ -22,11 +22,7 @@ public partial class TsBreakpointTableSection protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) - { - await _table!.SetDataAsync(_tableData); - } - + await _table!.SetDataAsync(_tableData); await base.OnAfterRenderAsync(firstRender); }