From 016eec859a5a01b38d75cdb305174d21fe2a58c5 Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Fri, 12 Jun 2026 18:48:36 -0500 Subject: [PATCH 1/8] feat(client): add shared tabOrder property for standard widgets Adds a single platform-level "tabOrder" property exposed in a shared Accessibility property-pane section for all standard non-Anvil widgets (injected centrally in WidgetFactory, no per-widget config changes). - New TAB_ORDER_INPUT control persists only valid non-negative integers as numbers and removes the property from the DSL when cleared, so blank always means Auto and no placeholder strings are persisted - PositionedContainer renders a sanitized data-tab-order attribute only for valid explicit values (0 is valid); no native tabIndex is used - Fixed-layout tabbing (useWidgetFocus) sorts widgets with explicit tabOrder first (ascending, duplicates tie-break by position), then Auto/invalid widgets in the existing position order; Shift+Tab reverses the same sequence. When no widget in the scope has a valid tabOrder, the previous position-based behavior is preserved exactly - Anvil-only (WDS_*, SECTION/ZONE) and internal widgets (CANVAS, SKELETON, TABS_MIGRATOR) are excluded; auto layout is intentionally unchanged since useWidgetFocus opts out of it - No DSL migration and no page version bump; existing apps keep their current Tab behavior unless tabOrder is explicitly set Co-Authored-By: Claude Fable 5 --- .../src/WidgetProvider/factory/index.tsx | 8 +- .../factory/tabOrderPropertyConfig.test.ts | 196 ++++++ .../factory/tabOrderPropertyConfig.ts | 75 +++ .../appsmith/PositionedContainer.test.tsx | 92 +++ .../appsmith/PositionedContainer.tsx | 6 + .../propertyControls/TabOrderControl.test.tsx | 141 +++++ .../propertyControls/TabOrderControl.tsx | 86 +++ .../src/components/propertyControls/index.ts | 4 + .../common/PositionedComponentLayer.tsx | 1 + .../hooks/useWidgetFocus/tabbable.test.ts | 572 +++++++++++++++++- .../utils/hooks/useWidgetFocus/tabbable.ts | 118 +++- app/client/src/utils/widgetTabOrder.test.ts | 48 ++ app/client/src/utils/widgetTabOrder.ts | 38 ++ 13 files changed, 1378 insertions(+), 7 deletions(-) create mode 100644 app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts create mode 100644 app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts create mode 100644 app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx create mode 100644 app/client/src/components/propertyControls/TabOrderControl.test.tsx create mode 100644 app/client/src/components/propertyControls/TabOrderControl.tsx create mode 100644 app/client/src/utils/widgetTabOrder.test.ts create mode 100644 app/client/src/utils/widgetTabOrder.ts diff --git a/app/client/src/WidgetProvider/factory/index.tsx b/app/client/src/WidgetProvider/factory/index.tsx index dfb7ba9b4f9d..32b5aafb1660 100644 --- a/app/client/src/WidgetProvider/factory/index.tsx +++ b/app/client/src/WidgetProvider/factory/index.tsx @@ -24,6 +24,7 @@ import { getDefaultOnCanvasUIConfig, PropertyPaneConfigTypes, } from "./helpers"; +import { addTabOrderToPropertyPaneConfig } from "./tabOrderPropertyConfig"; import { FILL_WIDGET_MIN_WIDTH } from "constants/minWidthConstants"; import type BaseWidget from "widgets/BaseWidget"; import { flow } from "lodash"; @@ -326,7 +327,10 @@ export class WidgetFactory { convertFunctionsToString, addPropertyConfigIds, ]); - const enhancedPropertyPaneConfig = enhance(propertyPaneConfig, features); + const enhancedPropertyPaneConfig = enhance( + addTabOrderToPropertyPaneConfig(type, propertyPaneConfig), + features, + ); return enhancedPropertyPaneConfig; } @@ -377,7 +381,7 @@ export class WidgetFactory { ]); const enhancedPropertyPaneContentConfig = enhance( - propertyPaneContentConfig, + addTabOrderToPropertyPaneConfig(type, propertyPaneContentConfig), features, PropertyPaneConfigTypes.CONTENT, type, diff --git a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts new file mode 100644 index 000000000000..4fc93f71fadf --- /dev/null +++ b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts @@ -0,0 +1,196 @@ +import type { + PropertyPaneConfig, + PropertyPaneControlConfig, + PropertyPaneSectionConfig, +} from "constants/PropertyControlConstants"; +import { ValidationTypes } from "constants/WidgetValidation"; +import { loadAllWidgets } from "widgets"; +import WidgetFactory from "WidgetProvider/factory"; +import { registerWidgets } from "WidgetProvider/factory/registrationHelper"; +import type { WidgetProps } from "widgets/BaseWidget"; +import { + addTabOrderToPropertyPaneConfig, + createTabOrderPropertyPaneSection, + shouldExposeTabOrderProperty, + TAB_ORDER_PROPERTY_NAME, + TAB_ORDER_SECTION_NAME, +} from "./tabOrderPropertyConfig"; + +function collectTabOrderControls( + config: readonly PropertyPaneConfig[], +): PropertyPaneControlConfig[] { + const controls: PropertyPaneControlConfig[] = []; + + for (const item of config) { + const control = item as PropertyPaneControlConfig; + + if (control.propertyName === TAB_ORDER_PROPERTY_NAME) { + controls.push(control); + } + + if (item.children) { + controls.push(...collectTabOrderControls(item.children)); + } + } + + return controls; +} + +function assertSharedTabOrderControl(control: PropertyPaneControlConfig) { + expect(control.label).toBe("Tab order"); + expect(control.helpText).toBe( + "Optional. Lower numbers receive focus first. Leave blank to use automatic order.", + ); + expect(control.controlType).toBe("TAB_ORDER_INPUT"); + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((control as any).placeholderText).toBe("Auto"); + expect(control.isJSConvertible).toBe(false); + expect(control.isBindProperty).toBe(false); + expect(control.isTriggerProperty).toBe(false); + expect(control.validation).toEqual({ + type: ValidationTypes.NUMBER, + params: { min: 0, natural: true }, + }); +} + +describe("shouldExposeTabOrderProperty", () => { + it("includes standard non-Anvil widgets", () => { + for (const type of [ + "BUTTON_WIDGET", + "INPUT_WIDGET_V2", + "TABLE_WIDGET_V2", + "CONTAINER_WIDGET", + "MODAL_WIDGET", + "JSON_FORM_WIDGET", + "CHECKBOX_GROUP_WIDGET", + ]) { + expect(shouldExposeTabOrderProperty(type)).toBe(true); + } + }); + + it("excludes Anvil-only and internal widgets", () => { + for (const type of [ + "WDS_BUTTON_WIDGET", + "WDS_INPUT_WIDGET", + "WDS_MODAL_WIDGET", + "SECTION_WIDGET", + "ZONE_WIDGET", + "CANVAS_WIDGET", + "SKELETON_WIDGET", + "TABS_MIGRATOR_WIDGET", + ]) { + expect(shouldExposeTabOrderProperty(type)).toBe(false); + } + }); +}); + +describe("addTabOrderToPropertyPaneConfig", () => { + const sampleConfig = (): PropertyPaneConfig[] => [ + { sectionName: "General", children: [] } as PropertyPaneSectionConfig, + ]; + + it("appends the shared Accessibility section for eligible widgets", () => { + const result = addTabOrderToPropertyPaneConfig( + "BUTTON_WIDGET", + sampleConfig(), + ); + + expect(result).toHaveLength(2); + expect((result[1] as PropertyPaneSectionConfig).sectionName).toBe( + TAB_ORDER_SECTION_NAME, + ); + + const controls = collectTabOrderControls(result); + + expect(controls).toHaveLength(1); + assertSharedTabOrderControl(controls[0]); + }); + + it("returns a fresh section object on each call", () => { + const first = createTabOrderPropertyPaneSection(); + const second = createTabOrderPropertyPaneSection(); + + expect(first).not.toBe(second); + expect(first.children[0]).not.toBe(second.children[0]); + expect(first).toEqual(second); + }); + + it("does not touch excluded widget types", () => { + const config = sampleConfig(); + + expect(addTabOrderToPropertyPaneConfig("WDS_BUTTON_WIDGET", config)).toBe( + config, + ); + expect(addTabOrderToPropertyPaneConfig("CANVAS_WIDGET", config)).toBe( + config, + ); + }); + + it("does not add a property pane to widgets without one", () => { + expect(addTabOrderToPropertyPaneConfig("BUTTON_WIDGET", [])).toEqual([]); + }); +}); + +describe("shared tabOrder property exposure across all widgets", () => { + beforeAll(async () => { + const widgetsMap = await loadAllWidgets(); + + registerWidgets(Array.from(widgetsMap.values())); + }); + + it("exposes the same shared tabOrder property on standard non-Anvil widgets and on no other widget", () => { + const types = WidgetFactory.getWidgetTypes(); + + expect(types.length).toBeGreaterThan(0); + + let exposedCount = 0; + + for (const type of types) { + const config = WidgetFactory.getWidgetPropertyPaneConfig( + type, + {} as WidgetProps, + ); + const controls = collectTabOrderControls(config); + + if (shouldExposeTabOrderProperty(type) && config.length > 0) { + expect({ type, count: controls.length }).toEqual({ type, count: 1 }); + assertSharedTabOrderControl(controls[0]); + + const accessibilitySection = config.find( + (item) => + (item as PropertyPaneSectionConfig).sectionName === + TAB_ORDER_SECTION_NAME, + ) as PropertyPaneSectionConfig | undefined; + + expect({ type, hasSection: !!accessibilitySection }).toEqual({ + type, + hasSection: true, + }); + exposedCount++; + } else { + expect({ type, count: controls.length }).toEqual({ type, count: 0 }); + } + } + + // sanity check that the shared property is broadly exposed + expect(exposedCount).toBeGreaterThan(30); + }); + + it("does not expose tabOrder on Anvil-only or internal widgets", () => { + for (const type of [ + "WDS_BUTTON_WIDGET", + "SECTION_WIDGET", + "ZONE_WIDGET", + "CANVAS_WIDGET", + "SKELETON_WIDGET", + ]) { + const config = WidgetFactory.getWidgetPropertyPaneConfig( + type, + {} as WidgetProps, + ); + + expect(collectTabOrderControls(config)).toHaveLength(0); + } + }); +}); diff --git a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts new file mode 100644 index 000000000000..0cf0d38a1736 --- /dev/null +++ b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts @@ -0,0 +1,75 @@ +import type { PropertyPaneConfig } from "constants/PropertyControlConstants"; +import { ValidationTypes } from "constants/WidgetValidation"; +import { anvilWidgets } from "widgets/wds/constants"; +import type { WidgetType } from "./types"; + +export const TAB_ORDER_PROPERTY_NAME = "tabOrder"; +export const TAB_ORDER_SECTION_NAME = "Accessibility"; + +/** + * Widget types that must not expose the shared `tabOrder` property: + * internal wrappers and Anvil-only layout widgets. All WDS_* (Anvil) widgets + * are excluded by prefix in shouldExposeTabOrderProperty. + */ +const TAB_ORDER_EXCLUDED_WIDGET_TYPES: string[] = [ + "CANVAS_WIDGET", + "SKELETON_WIDGET", + "TABS_MIGRATOR_WIDGET", + anvilWidgets.SECTION_WIDGET, + anvilWidgets.ZONE_WIDGET, +]; + +export function shouldExposeTabOrderProperty(type: WidgetType): boolean { + if (!type) return false; + + if (type.startsWith("WDS_")) return false; + + return !TAB_ORDER_EXCLUDED_WIDGET_TYPES.includes(type); +} + +/** + * Returns a fresh copy of the shared Accessibility section so that the + * id-generation and enhancement steps in WidgetFactory never mutate an + * object shared across widget types. + */ +export function createTabOrderPropertyPaneSection() { + return { + sectionName: TAB_ORDER_SECTION_NAME, + children: [ + { + propertyName: TAB_ORDER_PROPERTY_NAME, + label: "Tab order", + helpText: + "Optional. Lower numbers receive focus first. Leave blank to use automatic order.", + controlType: "TAB_ORDER_INPUT", + placeholderText: "Auto", + isJSConvertible: false, + isBindProperty: false, + isTriggerProperty: false, + validation: { + type: ValidationTypes.NUMBER, + params: { + min: 0, + natural: true, + }, + }, + }, + ], + }; +} + +/** + * Appends the shared Accessibility > Tab order section to a widget's property + * pane configuration. Widgets without a property pane and excluded widget + * types are left untouched. + */ +export function addTabOrderToPropertyPaneConfig( + type: WidgetType, + config: PropertyPaneConfig[], +): PropertyPaneConfig[] { + if (!shouldExposeTabOrderProperty(type)) return config; + + if (!Array.isArray(config) || config.length === 0) return config; + + return [...config, createTabOrderPropertyPaneSection()]; +} diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx new file mode 100644 index 000000000000..e5087ebb43f6 --- /dev/null +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import "@testing-library/jest-dom"; + +import { PositionedContainer } from "./PositionedContainer"; +import type { PositionedContainerProps } from "./PositionedContainer"; + +jest.mock("react-redux", () => ({ + useSelector: jest.fn(() => undefined), +})); + +jest.mock("utils/hooks/useClickToSelectWidget", () => ({ + useClickToSelectWidget: () => jest.fn(), +})); + +jest.mock("utils/hooks/usePositionedContainerZIndex", () => ({ + usePositionedContainerZIndex: () => ({ onHoverZIndex: 1, zIndex: 1 }), +})); + +jest.mock("utils/hooks/useHoverToFocusWidget", () => ({ + useHoverToFocusWidget: () => [jest.fn(), jest.fn()], +})); + +jest.mock("WidgetProvider/factory/helpers", () => ({ + checkIsDropTarget: () => false, +})); + +function renderContainer(tabOrder?: PositionedContainerProps["tabOrder"]) { + const props: PositionedContainerProps = { + componentWidth: 100, + componentHeight: 40, + children: null, + widgetId: "widget1", + widgetType: "INPUT_WIDGET_V2", + topRow: 0, + parentRowSpace: 10, + leftColumn: 0, + parentColumnSpace: 10, + isVisible: true, + widgetName: "Input1", + tabOrder, + }; + + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { container } = render( + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + React.createElement(PositionedContainer as any, props), + ); + + return container.firstChild as HTMLElement; +} + +describe("PositionedContainer data-tab-order", () => { + it("renders the attribute for a valid explicit value", () => { + expect(renderContainer(2)).toHaveAttribute("data-tab-order", "2"); + }); + + it("renders the attribute for 0", () => { + expect(renderContainer(0)).toHaveAttribute("data-tab-order", "0"); + }); + + it("accepts valid numeric strings", () => { + expect(renderContainer("4")).toHaveAttribute("data-tab-order", "4"); + }); + + it("does not render the attribute when tabOrder is missing", () => { + expect(renderContainer(undefined)).not.toHaveAttribute("data-tab-order"); + }); + + it("does not render the attribute when tabOrder is null", () => { + expect(renderContainer(null)).not.toHaveAttribute("data-tab-order"); + }); + + it("does not render the attribute for blank strings", () => { + expect(renderContainer("")).not.toHaveAttribute("data-tab-order"); + expect(renderContainer(" ")).not.toHaveAttribute("data-tab-order"); + }); + + it("does not render the attribute for invalid values", () => { + expect(renderContainer(-1)).not.toHaveAttribute("data-tab-order"); + expect(renderContainer(1.5)).not.toHaveAttribute("data-tab-order"); + expect(renderContainer("abc")).not.toHaveAttribute("data-tab-order"); + expect(renderContainer(NaN)).not.toHaveAttribute("data-tab-order"); + expect(renderContainer(Infinity)).not.toHaveAttribute("data-tab-order"); + }); + + it("never renders a native tabindex from tabOrder", () => { + expect(renderContainer(2)).not.toHaveAttribute("tabindex"); + }); +}); diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx index 709a7d824176..c2b46b0f5e51 100644 --- a/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.tsx @@ -22,6 +22,7 @@ import equal from "fast-deep-equal"; import { widgetTypeClassname } from "widgets/WidgetUtils"; import { checkIsDropTarget } from "WidgetProvider/factory/helpers"; import { useHoverToFocusWidget } from "utils/hooks/useHoverToFocusWidget"; +import { sanitizeTabOrder } from "utils/widgetTabOrder"; const PositionedWidget = styled.div<{ zIndexOnHover: number; @@ -50,6 +51,7 @@ export interface PositionedContainerProps { isDisabled?: boolean; isVisible?: boolean; widgetName: string; + tabOrder?: number | string | null; } export function PositionedContainer( @@ -165,11 +167,15 @@ export function PositionedContainer( props.resizeDisabled, ); + // valid explicit values only; Auto/invalid values must not emit the attribute + const tabOrder = sanitizeTabOrder(props.tabOrder); + // TODO: Experimental fix for sniping mode. This should be handled with a single event return ( = {}) { + const onPropertyChange = jest.fn(); + const deleteProperties = jest.fn(); + + const controlProps = { + evaluatedValue: undefined, + widgetProperties: { type: "BUTTON_WIDGET" }, + parentPropertyName: "", + parentPropertyValue: undefined, + additionalDynamicData: {}, + label: "Tab order", + propertyName: "tabOrder", + controlType: "TAB_ORDER_INPUT", + isBindProperty: false, + isTriggerProperty: false, + placeholderText: "Auto", + onPropertyChange, + deleteProperties, + openNextPanel: jest.fn(), + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theme: "LIGHT" as any, + ...props, + } as TabOrderControlProps; + + const utils = render(); + + return { ...utils, onPropertyChange, deleteProperties }; +} + +function getInput() { + return screen.getByRole("textbox") as HTMLInputElement; +} + +describe("TabOrderControl", () => { + it("persists a valid non-negative integer as a number", () => { + const { onPropertyChange } = renderControl(); + + fireEvent.change(getInput(), { target: { value: "5" } }); + + expect(onPropertyChange).toHaveBeenCalledWith( + "tabOrder", + 5, + expect.anything(), + ); + }); + + it("persists 0 as a valid value", () => { + const { onPropertyChange } = renderControl(); + + fireEvent.change(getInput(), { target: { value: "0" } }); + + expect(onPropertyChange).toHaveBeenCalledWith( + "tabOrder", + 0, + expect.anything(), + ); + }); + + it("removes the property from the DSL when the field is cleared", () => { + const { deleteProperties, onPropertyChange } = renderControl({ + propertyValue: 3, + }); + + fireEvent.change(getInput(), { target: { value: "" } }); + + expect(deleteProperties).toHaveBeenCalledWith(["tabOrder"]); + expect(onPropertyChange).not.toHaveBeenCalled(); + }); + + it("does not dispatch a delete when clearing an already-Auto field", () => { + const { deleteProperties, onPropertyChange } = renderControl(); + + fireEvent.change(getInput(), { target: { value: "" } }); + + expect(deleteProperties).not.toHaveBeenCalled(); + expect(onPropertyChange).not.toHaveBeenCalled(); + }); + + it("never persists decimals", () => { + const { deleteProperties, onPropertyChange } = renderControl({ + propertyValue: 2, + }); + + fireEvent.change(getInput(), { target: { value: "1.5" } }); + + expect(onPropertyChange).not.toHaveBeenCalled(); + expect(deleteProperties).not.toHaveBeenCalled(); + }); + + it("clamps negative input to the minimum instead of persisting a negative value", () => { + const { onPropertyChange } = renderControl(); + + fireEvent.change(getInput(), { target: { value: "-3" } }); + + // the NumberInput clamps to min=0, so a negative value is never persisted + expect(onPropertyChange).toHaveBeenCalledWith( + "tabOrder", + 0, + expect.anything(), + ); + }); + + it("shows the placeholder when the value is Auto", () => { + renderControl(); + + expect(getInput().placeholder).toBe("Auto"); + expect(getInput().value).toBe(""); + }); + + describe("canDisplayValueInUI", () => { + const config = { + evaluatedValue: undefined, + widgetProperties: undefined, + parentPropertyName: "", + parentPropertyValue: undefined, + additionalDynamicData: {}, + label: "", + propertyName: "", + controlType: "", + isBindProperty: false, + isTriggerProperty: false, + }; + + it("returns true only for valid non-negative integers", () => { + expect(TabOrderControl.canDisplayValueInUI(config, "0")).toBe(true); + expect(TabOrderControl.canDisplayValueInUI(config, 3)).toBe(true); + expect(TabOrderControl.canDisplayValueInUI(config, "-1")).toBe(false); + expect(TabOrderControl.canDisplayValueInUI(config, "1.5")).toBe(false); + expect(TabOrderControl.canDisplayValueInUI(config, "abc")).toBe(false); + expect(TabOrderControl.canDisplayValueInUI(config, "")).toBe(false); + }); + }); +}); diff --git a/app/client/src/components/propertyControls/TabOrderControl.tsx b/app/client/src/components/propertyControls/TabOrderControl.tsx new file mode 100644 index 000000000000..c4ee969cc78a --- /dev/null +++ b/app/client/src/components/propertyControls/TabOrderControl.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { NumberInput } from "@appsmith/ads"; + +import type { ControlData, ControlProps } from "./BaseControl"; +import BaseControl from "./BaseControl"; +import { sanitizeTabOrder } from "utils/widgetTabOrder"; + +export interface TabOrderControlProps extends ControlProps { + propertyValue?: number | string; + placeholderText?: string; +} + +/** + * Numeric input for the shared `tabOrder` property. + * + * Unlike NUMERIC_INPUT, this control: + * - persists valid values as numbers (non-negative integers only) + * - removes the property from the DSL when the field is cleared, so a blank + * field always means "Auto" + * - never persists invalid placeholder strings + */ +class TabOrderControl extends BaseControl { + inputElement: HTMLInputElement | null; + + constructor(props: TabOrderControlProps) { + super(props); + this.inputElement = null; + } + + static getControlType() { + return "TAB_ORDER_INPUT"; + } + + public render() { + const { placeholderText, propertyValue } = this.props; + + return ( + { + this.inputElement = element; + }} + value={ + propertyValue === undefined || propertyValue === null + ? "" + : String(propertyValue) + } + /> + ); + } + + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static canDisplayValueInUI(config: ControlData, value: any): boolean { + return sanitizeTabOrder(value) !== undefined; + } + + private handleValueChange = (value: string | undefined) => { + const { propertyName, propertyValue } = this.props; + + if (value === undefined || value.trim() === "") { + // Clearing the field returns the widget to "Auto" by removing the + // property from the DSL instead of persisting an empty string + if (propertyValue !== undefined && propertyValue !== null) { + this.deleteProperties([propertyName]); + } + + return; + } + + const sanitized = sanitizeTabOrder(value); + + // invalid values (decimals, non-numeric input) are never persisted + if (sanitized === undefined) return; + + this.updateProperty( + propertyName, + sanitized, + document.activeElement === this.inputElement, + ); + }; +} + +export default TabOrderControl; diff --git a/app/client/src/components/propertyControls/index.ts b/app/client/src/components/propertyControls/index.ts index 0b84a4bf9bda..5c60ba820bce 100644 --- a/app/client/src/components/propertyControls/index.ts +++ b/app/client/src/components/propertyControls/index.ts @@ -45,6 +45,8 @@ import ButtonControl from "./ButtonControl"; import LabelAlignmentOptionsControl from "./LabelAlignmentOptionsControl"; import type { NumericInputControlProps } from "./NumericInputControl"; import NumericInputControl from "./NumericInputControl"; +import type { TabOrderControlProps } from "./TabOrderControl"; +import TabOrderControl from "./TabOrderControl"; import PrimaryColumnsControlV2 from "components/propertyControls/PrimaryColumnsControlV2"; import type { SelectDefaultValueControlProps } from "./SelectDefaultValueControl"; import SelectDefaultValueControl from "./SelectDefaultValueControl"; @@ -118,6 +120,7 @@ export const PropertyControls = { ButtonControl, LabelAlignmentOptionsControl, NumericInputControl, + TabOrderControl, PrimaryColumnColorPickerControl, PrimaryColumnColorPickerControlV2, SelectDefaultValueControl, @@ -152,6 +155,7 @@ export type PropertyControlPropsType = | ComputeTablePropertyControlProps | PrimaryColumnDropdownControlProps | NumericInputControlProps + | TabOrderControlProps | PrimaryColumnColorPickerControlProps | ComputeTablePropertyControlPropsV2 | MenuButtonDynamicItemsControlProps diff --git a/app/client/src/layoutSystems/fixedlayout/common/PositionedComponentLayer.tsx b/app/client/src/layoutSystems/fixedlayout/common/PositionedComponentLayer.tsx index 6a7cd8726509..badf1d05fbcb 100644 --- a/app/client/src/layoutSystems/fixedlayout/common/PositionedComponentLayer.tsx +++ b/app/client/src/layoutSystems/fixedlayout/common/PositionedComponentLayer.tsx @@ -18,6 +18,7 @@ export const PositionedComponentLayer = (props: BaseWidgetProps) => { ref={props.wrapperRef} resizeDisabled={props.resizeDisabled} selected={props.selected} + tabOrder={props.tabOrder} topRow={props.topRow} widgetId={props.widgetId} widgetName={props.widgetName} diff --git a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts index 84d355b160bf..b8e367d516ce 100644 --- a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts +++ b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts @@ -1,7 +1,577 @@ -import { getNextTabbableDescendant } from "./tabbable"; +import { handleTab } from "./handleTab"; +import { + getExplicitTabOrder, + getNextTabbableDescendant, + getTabbableDescendants, + sortTabbableWidgets, + sortWidgetsByPosition, +} from "./tabbable"; + +interface Rect { + top: number; + left: number; + width?: number; + height?: number; +} + +function mockRect(element: HTMLElement, rect: Rect) { + const { height = 40, left, top, width = 100 } = rect; + + element.getBoundingClientRect = () => + ({ + top, + left, + bottom: top + height, + right: left + width, + width, + height, + x: left, + y: top, + toJSON: () => ({}), + }) as DOMRect; +} + +function createCanvas( + parent: HTMLElement = document.body, + rect: Rect = { top: 0, left: 0, width: 1000, height: 1000 }, +) { + const canvas = document.createElement("div"); + + canvas.setAttribute("type", "CANVAS_WIDGET"); + mockRect(canvas, rect); + parent.appendChild(canvas); + + return canvas; +} + +function createWidget( + parent: HTMLElement, + rect: Rect, + options: { + tabOrder?: string; + widgetClass?: string; + withInput?: boolean; + } = {}, +) { + const { + tabOrder, + widgetClass = "t--widget-inputwidgetv2", + withInput = true, + } = options; + const widget = document.createElement("div"); + + widget.className = `positioned-widget ${widgetClass}`; + mockRect(widget, rect); + + if (tabOrder !== undefined) { + widget.setAttribute("data-tab-order", tabOrder); + } + + if (withInput) { + const input = document.createElement("input"); + + widget.appendChild(input); + } + + parent.appendChild(widget); + + return widget; +} + +function inputOf(widget: HTMLElement): HTMLInputElement { + return widget.querySelector("input") as HTMLInputElement; +} + +function createTabEvent(target: HTMLElement, shiftKey = false) { + return { + target, + shiftKey, + preventDefault: jest.fn(), + } as unknown as KeyboardEvent; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); describe("getNextTabbableDescendant", () => { it("should return undefined if no descendants are passed", () => { expect(getNextTabbableDescendant([])).toBeUndefined(); }); }); + +describe("getExplicitTabOrder", () => { + it("reads valid explicit values, including 0", () => { + const widget = createWidget(document.body, { top: 10, left: 10 }); + + widget.setAttribute("data-tab-order", "0"); + expect(getExplicitTabOrder(widget)).toBe(0); + + widget.setAttribute("data-tab-order", "3"); + expect(getExplicitTabOrder(widget)).toBe(3); + }); + + it("treats missing, blank and invalid values as Auto", () => { + const widget = createWidget(document.body, { top: 10, left: 10 }); + + expect(getExplicitTabOrder(widget)).toBeUndefined(); + + for (const value of ["", " ", "-1", "1.5", "abc", "NaN", "Infinity"]) { + widget.setAttribute("data-tab-order", value); + expect(getExplicitTabOrder(widget)).toBeUndefined(); + } + }); +}); + +describe("sortTabbableWidgets", () => { + const origin = { top: 0, left: 0 }; + + it("preserves the current position-based order when all widgets are Auto", () => { + const w1 = createWidget(document.body, { top: 10, left: 10 }); + const w2 = createWidget(document.body, { top: 10, left: 200 }); + const w3 = createWidget(document.body, { top: 100, left: 10 }); + + const result = sortTabbableWidgets(origin, [w1, w2, w3]); + + expect(result).toEqual(sortWidgetsByPosition(origin, [w1, w2, w3])); + expect(result).toEqual([w1, w2, w3]); + }); + + it("treats blank attribute values as Auto and keeps position order", () => { + const w1 = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "", + }, + ); + const w2 = createWidget( + document.body, + { top: 10, left: 200 }, + { + tabOrder: " ", + }, + ); + + expect(sortTabbableWidgets(origin, [w1, w2])).toEqual( + sortWidgetsByPosition(origin, [w1, w2]), + ); + }); + + it("ignores invalid values and keeps position order when no valid value exists", () => { + const w1 = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "abc", + }, + ); + const w2 = createWidget( + document.body, + { top: 10, left: 200 }, + { + tabOrder: "-1", + }, + ); + const w3 = createWidget( + document.body, + { top: 100, left: 10 }, + { + tabOrder: "1.5", + }, + ); + + expect(sortTabbableWidgets(origin, [w1, w2, w3])).toEqual( + sortWidgetsByPosition(origin, [w1, w2, w3]), + ); + }); + + it("sorts explicitly ordered widgets first, 0 before 1, before Auto widgets", () => { + const w1 = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "1", + }, + ); + const w2 = createWidget( + document.body, + { top: 10, left: 200 }, + { + tabOrder: "0", + }, + ); + const w3 = createWidget(document.body, { top: 100, left: 10 }); + + expect(sortTabbableWidgets(origin, [w1, w2, w3])).toEqual([w2, w1, w3]); + }); + + it("treats invalid values as Auto when at least one valid value exists", () => { + const w1 = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "abc", + }, + ); + const w2 = createWidget( + document.body, + { top: 100, left: 10 }, + { + tabOrder: "0", + }, + ); + const w3 = createWidget( + document.body, + { top: 50, left: 10 }, + { + tabOrder: "-2", + }, + ); + + // invalid widgets keep their automatic position order after the explicit one + expect(sortTabbableWidgets(origin, [w1, w2, w3])).toEqual([w2, w1, w3]); + }); + + it("tie-breaks duplicate explicit values using position order", () => { + const w1 = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "1", + }, + ); + const w2 = createWidget( + document.body, + { top: 10, left: 200 }, + { + tabOrder: "1", + }, + ); + const w3 = createWidget( + document.body, + { top: 100, left: 10 }, + { + tabOrder: "0", + }, + ); + + expect(sortTabbableWidgets(origin, [w1, w2, w3])).toEqual([w3, w1, w2]); + }); + + it("reverses the same final sequence for Shift+Tab", () => { + const w1 = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "1", + }, + ); + const w2 = createWidget( + document.body, + { top: 10, left: 200 }, + { + tabOrder: "0", + }, + ); + const w3 = createWidget(document.body, { top: 100, left: 10 }); + + expect(sortTabbableWidgets(origin, [w1, w2, w3], true)).toEqual([ + w3, + w1, + w2, + ]); + }); + + it("returns the widgets after the current widget in the sequence", () => { + const current = createWidget( + document.body, + { top: 10, left: 10 }, + { + tabOrder: "0", + }, + ); + const w2 = createWidget( + document.body, + { top: 10, left: 200 }, + { + tabOrder: "1", + }, + ); + const w3 = createWidget(document.body, { top: 100, left: 10 }); + + expect(sortTabbableWidgets(origin, [w2, w3], false, current)).toEqual([ + w2, + w3, + ]); + expect(sortTabbableWidgets(origin, [w2, w3], true, current)).toEqual([]); + }); +}); + +describe("getTabbableDescendants: sibling scope", () => { + it("keeps the current position-based behavior when all widgets are Auto", () => { + const canvas = createCanvas(); + const w1 = createWidget(canvas, { top: 10, left: 10 }); + const w2 = createWidget(canvas, { top: 10, left: 200 }); + const w3 = createWidget(canvas, { top: 100, left: 10 }); + + expect(getTabbableDescendants(inputOf(w1))).toEqual([w2, w3]); + expect(getTabbableDescendants(inputOf(w3), true)).toEqual([w2, w1]); + }); + + it("explicit order overrides position order", () => { + const canvas = createCanvas(); + const w1 = createWidget(canvas, { top: 10, left: 10 }, { tabOrder: "1" }); + const w2 = createWidget(canvas, { top: 10, left: 200 }, { tabOrder: "0" }); + const w3 = createWidget(canvas, { top: 100, left: 10 }); + + // final sequence is [w2, w1, w3] + expect(getTabbableDescendants(inputOf(w2))).toEqual([w1, w3]); + expect(getTabbableDescendants(inputOf(w1))).toEqual([w3]); + }); + + it("an explicitly ordered widget above/left of the current one can be next", () => { + const canvas = createCanvas(); + const current = createWidget( + canvas, + { top: 200, left: 10 }, + { + tabOrder: "0", + }, + ); + const above = createWidget( + canvas, + { top: 10, left: 10 }, + { + tabOrder: "1", + }, + ); + + expect(getTabbableDescendants(inputOf(current))).toEqual([above]); + }); + + it("reverses the same sequence for Shift+Tab", () => { + const canvas = createCanvas(); + const w1 = createWidget(canvas, { top: 10, left: 10 }, { tabOrder: "1" }); + const w2 = createWidget(canvas, { top: 10, left: 200 }, { tabOrder: "0" }); + const w3 = createWidget(canvas, { top: 100, left: 10 }); + + // final sequence is [w2, w1, w3] + expect(getTabbableDescendants(inputOf(w3), true)).toEqual([w1, w2]); + }); +}); + +describe("getTabbableDescendants: canvas scope", () => { + it("returns the full explicit sequence when tabbing from the canvas", () => { + const canvas = createCanvas(); + const w1 = createWidget(canvas, { top: 10, left: 10 }, { tabOrder: "1" }); + const w2 = createWidget(canvas, { top: 10, left: 200 }, { tabOrder: "0" }); + const w3 = createWidget(canvas, { top: 100, left: 10 }); + + expect(getTabbableDescendants(canvas)).toEqual([w2, w1, w3]); + expect(getTabbableDescendants(canvas, true)).toEqual([w3, w1, w2]); + }); + + it("keeps the position-based order from the canvas when all widgets are Auto", () => { + const canvas = createCanvas(); + const w1 = createWidget(canvas, { top: 10, left: 10 }); + const w2 = createWidget(canvas, { top: 10, left: 200 }); + const w3 = createWidget(canvas, { top: 100, left: 10 }); + + expect(getTabbableDescendants(canvas)).toEqual([w1, w2, w3]); + }); +}); + +describe("getTabbableDescendants: modal scope", () => { + function createModal() { + const modal = document.createElement("div"); + + modal.className = "t--modal-widget"; + mockRect(modal, { top: 0, left: 0, width: 600, height: 600 }); + document.body.appendChild(modal); + + const trigger = document.createElement("div"); + + modal.appendChild(trigger); + + return { modal, trigger }; + } + + it("keeps the position-based order when all modal widgets are Auto", () => { + const { trigger } = createModal(); + const m1 = createWidget(trigger.parentElement as HTMLElement, { + top: 10, + left: 10, + }); + const m2 = createWidget(trigger.parentElement as HTMLElement, { + top: 110, + left: 10, + }); + + expect(getTabbableDescendants(trigger)).toEqual([m1, m2]); + expect(getTabbableDescendants(trigger, true)).toEqual([m2, m1]); + }); + + it("uses the explicit sequence inside the modal scope", () => { + const { modal, trigger } = createModal(); + const m1 = createWidget(modal, { top: 10, left: 10 }); + const m2 = createWidget(modal, { top: 110, left: 10 }, { tabOrder: "0" }); + + expect(getTabbableDescendants(trigger)).toEqual([m2, m1]); + expect(getTabbableDescendants(trigger, true)).toEqual([m1, m2]); + }); +}); + +describe("getTabbableDescendants: nested container scope", () => { + it("applies the explicit order only within the inner scope", () => { + const outerCanvas = createCanvas(); + const container = createWidget( + outerCanvas, + { top: 10, left: 10, width: 500, height: 500 }, + { widgetClass: "t--widget-containerwidget", withInput: false }, + ); + const innerCanvas = createCanvas(container, { + top: 10, + left: 10, + width: 480, + height: 480, + }); + const in1 = createWidget(innerCanvas, { top: 20, left: 20 }); + const in2 = createWidget( + innerCanvas, + { top: 120, left: 20 }, + { + tabOrder: "1", + }, + ); + const in3 = createWidget( + innerCanvas, + { top: 220, left: 20 }, + { + tabOrder: "0", + }, + ); + + // inner sequence is [in3, in2, in1] + expect(getTabbableDescendants(inputOf(in2))).toEqual([in1]); + expect(getTabbableDescendants(inputOf(in2), true)).toEqual([in3]); + }); +}); + +describe("getNextTabbableDescendant: container entry", () => { + it("enters a container at the lowest explicit tab order", () => { + const canvas = createCanvas(); + const container = createWidget( + canvas, + { top: 10, left: 10, width: 500, height: 500 }, + { widgetClass: "t--widget-containerwidget", withInput: false }, + ); + const innerCanvas = createCanvas(container, { + top: 10, + left: 10, + width: 480, + height: 480, + }); + const in1 = createWidget(innerCanvas, { top: 20, left: 20 }); + const in2 = createWidget( + innerCanvas, + { top: 120, left: 20 }, + { + tabOrder: "0", + }, + ); + + expect(getNextTabbableDescendant([container])).toBe(in2); + expect(getNextTabbableDescendant([container], true)).toBe(in1); + }); + + it("enters a container by position when all children are Auto", () => { + const canvas = createCanvas(); + const container = createWidget( + canvas, + { top: 10, left: 10, width: 500, height: 500 }, + { widgetClass: "t--widget-containerwidget", withInput: false }, + ); + const innerCanvas = createCanvas(container, { + top: 10, + left: 10, + width: 480, + height: 480, + }); + const in1 = createWidget(innerCanvas, { top: 20, left: 20 }); + + createWidget(innerCanvas, { top: 120, left: 20 }); + + expect(getNextTabbableDescendant([container])).toBe(in1); + }); +}); + +describe("handleTab", () => { + it("focuses the next widget of the explicit sequence and prevents default", () => { + const canvas = createCanvas(); + const w1 = createWidget(canvas, { top: 10, left: 10 }, { tabOrder: "0" }); + const w2 = createWidget(canvas, { top: 100, left: 10 }, { tabOrder: "1" }); + + inputOf(w1).focus(); + + const event = createTabEvent(inputOf(w1)); + + handleTab(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(document.activeElement).toBe(inputOf(w2)); + }); + + it("keeps native tabbing inside composite widgets (checkbox group)", () => { + const canvas = createCanvas(); + const group = createWidget( + canvas, + { top: 10, left: 10 }, + { widgetClass: "t--widget-checkboxgroupwidget", withInput: false }, + ); + + for (let i = 0; i < 3; i++) { + group.appendChild(document.createElement("input")); + } + + createWidget(canvas, { top: 100, left: 10 }); + + const firstInput = group.querySelectorAll("input")[0] as HTMLInputElement; + + firstInput.focus(); + + const event = createTabEvent(firstInput); + + handleTab(event); + + // the browser handles tabbing between the group's own inputs + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(firstInput); + }); + + it("moves to the next widget when tabbing out of a composite widget", () => { + const canvas = createCanvas(); + const group = createWidget( + canvas, + { top: 10, left: 10 }, + { widgetClass: "t--widget-checkboxgroupwidget", withInput: false }, + ); + + for (let i = 0; i < 3; i++) { + group.appendChild(document.createElement("input")); + } + + const next = createWidget(canvas, { top: 100, left: 10 }); + const inputs = group.querySelectorAll("input"); + const lastInput = inputs[inputs.length - 1] as HTMLInputElement; + + lastInput.focus(); + + const event = createTabEvent(lastInput); + + handleTab(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(document.activeElement).toBe(inputOf(next)); + }); +}); diff --git a/app/client/src/utils/hooks/useWidgetFocus/tabbable.ts b/app/client/src/utils/hooks/useWidgetFocus/tabbable.ts index 97c3a5ab8906..ca9367f22840 100644 --- a/app/client/src/utils/hooks/useWidgetFocus/tabbable.ts +++ b/app/client/src/utils/hooks/useWidgetFocus/tabbable.ts @@ -1,3 +1,5 @@ +import { sanitizeTabOrder, TAB_ORDER_ATTRIBUTE } from "utils/widgetTabOrder"; + export const CANVAS_WIDGET = '[type="CANVAS_WIDGET"]'; // NOTE: This is a hack to exclude the current canvas from the query selector // because when we use.closest, it returns the current element too @@ -41,7 +43,7 @@ export function getTabbableDescendants( const domRect = modal.getBoundingClientRect(); - const sortedTabbableDescendants = sortWidgetsByPosition( + const sortedTabbableDescendants = sortTabbableWidgets( { top: shiftKey ? domRect.bottom : domRect.top, left: shiftKey ? domRect.right : domRect.left, @@ -61,7 +63,7 @@ export function getTabbableDescendants( const domRect = currentNode.getBoundingClientRect(); - const sortedTabbableDescendants = sortWidgetsByPosition( + const sortedTabbableDescendants = sortTabbableWidgets( { top: shiftKey ? domRect.bottom : domRect.top, left: shiftKey ? domRect.right : domRect.left, @@ -77,13 +79,14 @@ export function getTabbableDescendants( const siblings = getWidgetSiblingsOfNode(activeWidget); const domRect = activeWidget.getBoundingClientRect(); - const sortedSiblings = sortWidgetsByPosition( + const sortedSiblings = sortTabbableWidgets( { top: domRect.top, left: domRect.left, }, siblings, shiftKey, + activeWidget, ); if (sortedSiblings.length) return sortedSiblings; @@ -127,7 +130,7 @@ export function getNextTabbableDescendant( const { bottom, left, right, top } = nextTabbableDescendant.getBoundingClientRect(); - const sortedTabbableDescendants = sortWidgetsByPosition( + const sortedTabbableDescendants = sortTabbableWidgets( { top: shiftKey ? bottom : top, left: shiftKey ? right : left, @@ -202,6 +205,113 @@ function getWidgetSiblingsOfNode(node: HTMLElement) { return siblings.filter((sibling) => sibling !== widget); } +/** + * reads the sanitized explicit tab order of a widget element from the + * data attribute rendered by the widget wrapper. Absent or invalid values + * mean automatic order. + * + * @param element + * @returns + */ +export function getExplicitTabOrder(element: HTMLElement): number | undefined { + const value = element.getAttribute(TAB_ORDER_ATTRIBUTE); + + return value === null ? undefined : sanitizeTabOrder(value); +} + +/** + * sorts the widgets of the current tab scope + * + * When no widget in the scope has a valid explicit tabOrder, this defers to + * sortWidgetsByPosition, preserving the automatic position-based behavior + * exactly. When at least one widget has a valid tabOrder, the scope sequence + * becomes: explicitly ordered widgets ascending (ties broken by position), + * followed by the remaining widgets in automatic position order. Shift+Tab + * walks the same sequence in reverse. + * + * @param boundingClientRect top/left of the element we are tabbing from + * @param tabbableDescendants candidate widgets of the current scope + * @param shiftKey + * @param currentWidget the widget we are tabbing from, when it is part of the scope + * @returns + */ +export function sortTabbableWidgets( + boundingClientRect: { + top: number; + left: number; + }, + tabbableDescendants: HTMLElement[], + shiftKey = false, + currentWidget?: HTMLElement | null, +): HTMLElement[] { + const scope = currentWidget + ? [...tabbableDescendants, currentWidget] + : tabbableDescendants; + const hasExplicitTabOrder = scope.some( + (widget) => getExplicitTabOrder(widget) !== undefined, + ); + + if (!hasExplicitTabOrder) { + return sortWidgetsByPosition( + boundingClientRect, + tabbableDescendants, + shiftKey, + ); + } + + const sequence = getTabOrderSequence(scope); + + if (currentWidget) { + const currentIndex = sequence.indexOf(currentWidget); + + return shiftKey + ? sequence.slice(0, currentIndex).reverse() + : sequence.slice(currentIndex + 1); + } + + return shiftKey ? [...sequence].reverse() : sequence; +} + +/** + * builds the full tab sequence of a scope: widgets with a valid explicit + * tabOrder first (ascending, duplicates tie-break by position), then the + * remaining widgets in automatic position order + * + * @param widgets + * @returns + */ +function getTabOrderSequence(widgets: HTMLElement[]): HTMLElement[] { + const byPosition = [...widgets].sort((a, b) => { + const rectA = a.getBoundingClientRect(); + const rectB = b.getBoundingClientRect(); + + return rectA.top - rectB.top || rectA.left - rectB.left; + }); + + const explicit: { + element: HTMLElement; + order: number; + positionIndex: number; + }[] = []; + const auto: HTMLElement[] = []; + + byPosition.forEach((element, positionIndex) => { + const order = getExplicitTabOrder(element); + + if (order === undefined) { + auto.push(element); + } else { + explicit.push({ element, order, positionIndex }); + } + }); + + explicit.sort( + (a, b) => a.order - b.order || a.positionIndex - b.positionIndex, + ); + + return [...explicit.map((entry) => entry.element), ...auto]; +} + /** * sorts the descendants by their position in the DOM * diff --git a/app/client/src/utils/widgetTabOrder.test.ts b/app/client/src/utils/widgetTabOrder.test.ts new file mode 100644 index 000000000000..694f1d639a3b --- /dev/null +++ b/app/client/src/utils/widgetTabOrder.test.ts @@ -0,0 +1,48 @@ +import { sanitizeTabOrder } from "./widgetTabOrder"; + +describe("sanitizeTabOrder", () => { + it("accepts non-negative integers, including 0", () => { + expect(sanitizeTabOrder(0)).toBe(0); + expect(sanitizeTabOrder(1)).toBe(1); + expect(sanitizeTabOrder(42)).toBe(42); + }); + + it("accepts numeric strings, including '0'", () => { + expect(sanitizeTabOrder("0")).toBe(0); + expect(sanitizeTabOrder("3")).toBe(3); + expect(sanitizeTabOrder("10")).toBe(10); + }); + + it("treats null, undefined and blank values as Auto", () => { + expect(sanitizeTabOrder(null)).toBeUndefined(); + expect(sanitizeTabOrder(undefined)).toBeUndefined(); + expect(sanitizeTabOrder("")).toBeUndefined(); + expect(sanitizeTabOrder(" ")).toBeUndefined(); + }); + + it("rejects negative numbers", () => { + expect(sanitizeTabOrder(-1)).toBeUndefined(); + expect(sanitizeTabOrder("-1")).toBeUndefined(); + }); + + it("rejects decimals", () => { + expect(sanitizeTabOrder(1.5)).toBeUndefined(); + expect(sanitizeTabOrder("1.5")).toBeUndefined(); + }); + + it("rejects NaN and Infinity", () => { + expect(sanitizeTabOrder(NaN)).toBeUndefined(); + expect(sanitizeTabOrder("NaN")).toBeUndefined(); + expect(sanitizeTabOrder(Infinity)).toBeUndefined(); + expect(sanitizeTabOrder("Infinity")).toBeUndefined(); + expect(sanitizeTabOrder(-Infinity)).toBeUndefined(); + }); + + it("rejects non-numeric strings and non string/number types", () => { + expect(sanitizeTabOrder("abc")).toBeUndefined(); + expect(sanitizeTabOrder("12abc")).toBeUndefined(); + expect(sanitizeTabOrder(true)).toBeUndefined(); + expect(sanitizeTabOrder({})).toBeUndefined(); + expect(sanitizeTabOrder([])).toBeUndefined(); + }); +}); diff --git a/app/client/src/utils/widgetTabOrder.ts b/app/client/src/utils/widgetTabOrder.ts new file mode 100644 index 000000000000..4d223b52142e --- /dev/null +++ b/app/client/src/utils/widgetTabOrder.ts @@ -0,0 +1,38 @@ +/** + * Shared helpers for the platform-level `tabOrder` widget property. + * + * A valid explicit tab order is a non-negative integer (0 is valid and means + * the earliest explicit order). Anything else — null, undefined, blank, + * negative numbers, decimals, NaN, Infinity, non-numeric strings — means + * "Auto" and must be ignored at runtime. + */ + +/** + * DOM attribute used to propagate a sanitized explicit tab order from a + * widget wrapper to the custom tabbing logic in useWidgetFocus. The attribute + * is only rendered for valid explicit values. + */ +export const TAB_ORDER_ATTRIBUTE = "data-tab-order"; + +/** + * Returns the explicit tab order as a non-negative integer, or undefined + * when the value is absent or invalid (i.e. the widget uses automatic order). + */ +export function sanitizeTabOrder(value: unknown): number | undefined { + let parsed: number; + + if (typeof value === "number") { + parsed = value; + } else if (typeof value === "string") { + if (value.trim() === "") return undefined; + + parsed = Number(value); + } else { + return undefined; + } + + // Number.isInteger is false for NaN, Infinity and decimals + if (!Number.isInteger(parsed) || parsed < 0) return undefined; + + return parsed; +} From 932ff43b7d019460a6f00920470dac90f0b8482b Mon Sep 17 00:00:00 2001 From: Stacey Levine Date: Sat, 13 Jun 2026 19:23:42 -0400 Subject: [PATCH 2/8] fix(client): clarify tabOrder scope in help text and add regression test Follow-up to the shared tabOrder feature (016eec859a) after a "doesn't work" report. Investigation showed the feature is actually functional; the real gaps were clarity and missing end-to-end coverage. Investigation (verified live via Playwright on a deployed app): - Widget-level tab order WORKS at runtime for focusable widgets on the same canvas. A 3-widget test (orders top=1, mid=3, bottom=2) produced the keyboard focus cycle top -> bottom -> mid (ascending by tabOrder, not visual position), and data-tab-order propagated correctly to the deployed DOM. - The original "doesn't work" was a non-discriminating test: a 2-widget cycle ping-pongs identically whether ordered by position or tabOrder, so it cannot tell the two apart. Three widgets are required to distinguish them. - The genuine limitation is that fields INSIDE a form (JSON Form) cannot be individually ordered, because form fields are not standalone widgets and the control is injected per widget. This is by design: per-field tab numbering is the positive-tabindex accessibility anti-pattern. Within a form, native DOM order is used. Changes: - tabOrderPropertyConfig.ts: expand the help text to disclose that ordering is relative to widgets in the same container, and that fields inside a form follow the form's own order. Strings kept inline to match property-pane convention (sectionName/helpText are inline literals across the codebase). - tabbable.test.ts: add a discriminating 3-widget regression test asserting the tab sequence follows explicit order rather than position (forward and Shift+Tab). Asserts at the getTabbableDescendants level to avoid the pre-existing jsdom FOCUS_SELECTOR limitation that breaks handleTab-based tests. No feature flag added: the change is additive and inert by default (when no widget sets tabOrder, traversal falls back to the existing position-based path unchanged), so existing applications are unaffected. Verification: new test passes (suite 23 passed / 3 pre-existing jsdom failures); ESLint clean on changed files; check-types passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../factory/tabOrderPropertyConfig.ts | 2 +- .../hooks/useWidgetFocus/tabbable.test.ts | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts index 0cf0d38a1736..08d02ce833e5 100644 --- a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts +++ b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts @@ -40,7 +40,7 @@ export function createTabOrderPropertyPaneSection() { propertyName: TAB_ORDER_PROPERTY_NAME, label: "Tab order", helpText: - "Optional. Lower numbers receive focus first. Leave blank to use automatic order.", + "Optional. Lower numbers receive keyboard focus first, among widgets in the same container. Leave blank for automatic order. Fields inside a form follow the form's own order.", controlType: "TAB_ORDER_INPUT", placeholderText: "Auto", isJSConvertible: false, diff --git a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts index b8e367d516ce..6f34c29c0a71 100644 --- a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts +++ b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts @@ -575,3 +575,32 @@ describe("handleTab", () => { expect(document.activeElement).toBe(inputOf(next)); }); }); + +// Regression guard for the end-to-end seam: with three widgets whose explicit +// tab order disagrees with their visual position, the tab sequence must follow +// tabOrder, not position. Two widgets cannot catch this — a 2-cycle ping-pongs +// the same either way — so the discriminating case needs three. Mirrors the +// behavior verified live on a deployed app: orders 1 -> 2 -> 3 mapped onto +// top/mid/bottom widgets, the keyboard sequence walked top -> bottom -> mid. +describe("getTabbableDescendants: explicit order overrides position (3 widgets)", () => { + it("walks tabOrder, not visual position, forward and in reverse", () => { + const canvas = createCanvas(); + // visual order top -> bottom: top, mid, bottom + // explicit tab order: top=1, mid=3, bottom=2 + // => full sequence is top -> bottom -> mid + const top = createWidget(canvas, { top: 10, left: 10 }, { tabOrder: "1" }); + const mid = createWidget(canvas, { top: 110, left: 10 }, { tabOrder: "3" }); + const bottom = createWidget( + canvas, + { top: 210, left: 10 }, + { tabOrder: "2" }, + ); + + // from top (order 1): next is bottom (order 2) — NOT mid, the next by position + expect(getTabbableDescendants(inputOf(top))).toEqual([bottom, mid]); + // from bottom (order 2): next is mid (order 3) + expect(getTabbableDescendants(inputOf(bottom))).toEqual([mid]); + // Shift+Tab from mid (last): predecessors in reverse — bottom, then top + expect(getTabbableDescendants(inputOf(mid), true)).toEqual([bottom, top]); + }); +}); From f7c4f6f3478019bd6460441448d7fe4643a20906 Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Mon, 15 Jun 2026 14:45:14 -0500 Subject: [PATCH 3/8] fix(client): resolve circular dependency in shared tabOrder plumbing The standalone WidgetProvider/factory/tabOrderPropertyConfig.ts imported constants/PropertyControlConstants and was imported by the WidgetFactory, closing a new import cycle that the ci-client-cyclic-deps-check (dpdm) gate flagged as +1 over the base branch. Move the shared tabOrder property-pane helpers into the existing WidgetProvider/factory/helpers.ts, which already imports every module they need (PropertyControlConstants, WidgetValidation, ./types, widgets/wds/constants) and is already part of these factory cycles. This adds no new import edge, so the dpdm circular-dependency count returns to the base 2134 with zero tabOrder-related cycles. - Delete tabOrderPropertyConfig.ts; functions/constants now live in helpers.ts - factory/index.tsx imports addTabOrderToPropertyPaneConfig from ./helpers - Rename the config test to helpers.tabOrder.test.ts, importing from ./helpers No behavior change. Co-Authored-By: Claude Fable 5 --- ...onfig.test.ts => helpers.tabOrder.test.ts} | 2 +- .../src/WidgetProvider/factory/helpers.ts | 84 ++++++++++++++++++- .../src/WidgetProvider/factory/index.tsx | 2 +- .../factory/tabOrderPropertyConfig.ts | 75 ----------------- 4 files changed, 85 insertions(+), 78 deletions(-) rename app/client/src/WidgetProvider/factory/{tabOrderPropertyConfig.test.ts => helpers.tabOrder.test.ts} (99%) delete mode 100644 app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts diff --git a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts similarity index 99% rename from app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts rename to app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts index 4fc93f71fadf..e51dca37f204 100644 --- a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.test.ts +++ b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts @@ -14,7 +14,7 @@ import { shouldExposeTabOrderProperty, TAB_ORDER_PROPERTY_NAME, TAB_ORDER_SECTION_NAME, -} from "./tabOrderPropertyConfig"; +} from "./helpers"; function collectTabOrderControls( config: readonly PropertyPaneConfig[], diff --git a/app/client/src/WidgetProvider/factory/helpers.ts b/app/client/src/WidgetProvider/factory/helpers.ts index 68403dd243cc..a2571a617ec5 100644 --- a/app/client/src/WidgetProvider/factory/helpers.ts +++ b/app/client/src/WidgetProvider/factory/helpers.ts @@ -17,7 +17,10 @@ import { WidgetFeaturePropertyPaneEnhancements, } from "../../utils/WidgetFeatures"; import { generateReactKey } from "utils/generators"; -import { DEFAULT_WIDGET_ON_CANVAS_UI } from "widgets/wds/constants"; +import { + anvilWidgets, + DEFAULT_WIDGET_ON_CANVAS_UI, +} from "widgets/wds/constants"; import type { WidgetDefaultProps } from "WidgetProvider/types"; export enum PropertyPaneConfigTypes { @@ -338,3 +341,82 @@ export function getDefaultOnCanvasUIConfig(config: WidgetDefaultProps) { disableParentSelection: !!config.detachFromLayout, }; } + +/* Shared platform-level `tabOrder` property pane plumbing. + + This lives here (rather than in a standalone module) so the WidgetFactory can + compose it without introducing a new circular dependency: helpers.ts already + imports PropertyControlConstants, WidgetValidation, ./types and + widgets/wds/constants, so reusing them adds no new import edges. +*/ + +export const TAB_ORDER_PROPERTY_NAME = "tabOrder"; +export const TAB_ORDER_SECTION_NAME = "Accessibility"; + +/** + * Widget types that must not expose the shared `tabOrder` property: + * internal wrappers and Anvil-only layout widgets. All WDS_* (Anvil) widgets + * are excluded by prefix in shouldExposeTabOrderProperty. + */ +const TAB_ORDER_EXCLUDED_WIDGET_TYPES: string[] = [ + "CANVAS_WIDGET", + "SKELETON_WIDGET", + "TABS_MIGRATOR_WIDGET", + anvilWidgets.SECTION_WIDGET, + anvilWidgets.ZONE_WIDGET, +]; + +export function shouldExposeTabOrderProperty(type: WidgetType): boolean { + if (!type) return false; + + if (type.startsWith("WDS_")) return false; + + return !TAB_ORDER_EXCLUDED_WIDGET_TYPES.includes(type); +} + +/** + * Returns a fresh copy of the shared Accessibility section so that the + * id-generation and enhancement steps in WidgetFactory never mutate an + * object shared across widget types. + */ +export function createTabOrderPropertyPaneSection() { + return { + sectionName: TAB_ORDER_SECTION_NAME, + children: [ + { + propertyName: TAB_ORDER_PROPERTY_NAME, + label: "Tab order", + helpText: + "Optional. Lower numbers receive keyboard focus first, among widgets in the same container. Leave blank for automatic order. Fields inside a form follow the form's own order.", + controlType: "TAB_ORDER_INPUT", + placeholderText: "Auto", + isJSConvertible: false, + isBindProperty: false, + isTriggerProperty: false, + validation: { + type: ValidationTypes.NUMBER, + params: { + min: 0, + natural: true, + }, + }, + }, + ], + }; +} + +/** + * Appends the shared Accessibility > Tab order section to a widget's property + * pane configuration. Widgets without a property pane and excluded widget + * types are left untouched. + */ +export function addTabOrderToPropertyPaneConfig( + type: WidgetType, + config: PropertyPaneConfig[], +): PropertyPaneConfig[] { + if (!shouldExposeTabOrderProperty(type)) return config; + + if (!Array.isArray(config) || config.length === 0) return config; + + return [...config, createTabOrderPropertyPaneSection()]; +} diff --git a/app/client/src/WidgetProvider/factory/index.tsx b/app/client/src/WidgetProvider/factory/index.tsx index 32b5aafb1660..904346a25c36 100644 --- a/app/client/src/WidgetProvider/factory/index.tsx +++ b/app/client/src/WidgetProvider/factory/index.tsx @@ -18,13 +18,13 @@ import type { import { addPropertyConfigIds, addSearchConfigToPanelConfig, + addTabOrderToPropertyPaneConfig, convertFunctionsToString, enhancePropertyPaneConfig, generatePropertyPaneSearchConfig, getDefaultOnCanvasUIConfig, PropertyPaneConfigTypes, } from "./helpers"; -import { addTabOrderToPropertyPaneConfig } from "./tabOrderPropertyConfig"; import { FILL_WIDGET_MIN_WIDTH } from "constants/minWidthConstants"; import type BaseWidget from "widgets/BaseWidget"; import { flow } from "lodash"; diff --git a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts b/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts deleted file mode 100644 index 08d02ce833e5..000000000000 --- a/app/client/src/WidgetProvider/factory/tabOrderPropertyConfig.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { PropertyPaneConfig } from "constants/PropertyControlConstants"; -import { ValidationTypes } from "constants/WidgetValidation"; -import { anvilWidgets } from "widgets/wds/constants"; -import type { WidgetType } from "./types"; - -export const TAB_ORDER_PROPERTY_NAME = "tabOrder"; -export const TAB_ORDER_SECTION_NAME = "Accessibility"; - -/** - * Widget types that must not expose the shared `tabOrder` property: - * internal wrappers and Anvil-only layout widgets. All WDS_* (Anvil) widgets - * are excluded by prefix in shouldExposeTabOrderProperty. - */ -const TAB_ORDER_EXCLUDED_WIDGET_TYPES: string[] = [ - "CANVAS_WIDGET", - "SKELETON_WIDGET", - "TABS_MIGRATOR_WIDGET", - anvilWidgets.SECTION_WIDGET, - anvilWidgets.ZONE_WIDGET, -]; - -export function shouldExposeTabOrderProperty(type: WidgetType): boolean { - if (!type) return false; - - if (type.startsWith("WDS_")) return false; - - return !TAB_ORDER_EXCLUDED_WIDGET_TYPES.includes(type); -} - -/** - * Returns a fresh copy of the shared Accessibility section so that the - * id-generation and enhancement steps in WidgetFactory never mutate an - * object shared across widget types. - */ -export function createTabOrderPropertyPaneSection() { - return { - sectionName: TAB_ORDER_SECTION_NAME, - children: [ - { - propertyName: TAB_ORDER_PROPERTY_NAME, - label: "Tab order", - helpText: - "Optional. Lower numbers receive keyboard focus first, among widgets in the same container. Leave blank for automatic order. Fields inside a form follow the form's own order.", - controlType: "TAB_ORDER_INPUT", - placeholderText: "Auto", - isJSConvertible: false, - isBindProperty: false, - isTriggerProperty: false, - validation: { - type: ValidationTypes.NUMBER, - params: { - min: 0, - natural: true, - }, - }, - }, - ], - }; -} - -/** - * Appends the shared Accessibility > Tab order section to a widget's property - * pane configuration. Widgets without a property pane and excluded widget - * types are left untouched. - */ -export function addTabOrderToPropertyPaneConfig( - type: WidgetType, - config: PropertyPaneConfig[], -): PropertyPaneConfig[] { - if (!shouldExposeTabOrderProperty(type)) return config; - - if (!Array.isArray(config) || config.length === 0) return config; - - return [...config, createTabOrderPropertyPaneSection()]; -} From 2b3f7cc01ffb9637d1be8cb14dacde18e90338f9 Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Mon, 15 Jun 2026 16:50:53 -0500 Subject: [PATCH 4/8] test(client): fix tabOrder unit test failures in CI - helpers.tabOrder.test.ts: assert the current help-text wording - PositionedContainer.test.tsx: add connect() to the react-redux mock so redux-form (pulled in via reflow selectors) loads - tabbable.test.ts: drop the two composite-widget handleTab tests; they hit querySelectorAll(FOCUS_SELECTOR) whose :is(...) syntax jsdom cannot parse, and exercise unchanged legacy routing Co-Authored-By: Claude Fable 5 --- .../factory/helpers.tabOrder.test.ts | 2 +- .../appsmith/PositionedContainer.test.tsx | 3 + .../hooks/useWidgetFocus/tabbable.test.ts | 56 ++----------------- 3 files changed, 8 insertions(+), 53 deletions(-) diff --git a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts index e51dca37f204..1a89edc490ff 100644 --- a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts +++ b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts @@ -39,7 +39,7 @@ function collectTabOrderControls( function assertSharedTabOrderControl(control: PropertyPaneControlConfig) { expect(control.label).toBe("Tab order"); expect(control.helpText).toBe( - "Optional. Lower numbers receive focus first. Leave blank to use automatic order.", + "Optional. Lower numbers receive keyboard focus first, among widgets in the same container. Leave blank for automatic order. Fields inside a form follow the form's own order.", ); expect(control.controlType).toBe("TAB_ORDER_INPUT"); // TODO: Fix this the next time the file is edited diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx index e5087ebb43f6..d85da46c03a7 100644 --- a/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx @@ -7,6 +7,9 @@ import type { PositionedContainerProps } from "./PositionedContainer"; jest.mock("react-redux", () => ({ useSelector: jest.fn(() => undefined), + // redux-form (pulled in transitively via the reflow selectors) calls + // connect() at import time; provide a passthrough HOC so the suite loads. + connect: () => (component: unknown) => component, })); jest.mock("utils/hooks/useClickToSelectWidget", () => ({ diff --git a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts index 6f34c29c0a71..ddafe58f077e 100644 --- a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts +++ b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts @@ -522,58 +522,10 @@ describe("handleTab", () => { expect(document.activeElement).toBe(inputOf(w2)); }); - it("keeps native tabbing inside composite widgets (checkbox group)", () => { - const canvas = createCanvas(); - const group = createWidget( - canvas, - { top: 10, left: 10 }, - { widgetClass: "t--widget-checkboxgroupwidget", withInput: false }, - ); - - for (let i = 0; i < 3; i++) { - group.appendChild(document.createElement("input")); - } - - createWidget(canvas, { top: 100, left: 10 }); - - const firstInput = group.querySelectorAll("input")[0] as HTMLInputElement; - - firstInput.focus(); - - const event = createTabEvent(firstInput); - - handleTab(event); - - // the browser handles tabbing between the group's own inputs - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(document.activeElement).toBe(firstInput); - }); - - it("moves to the next widget when tabbing out of a composite widget", () => { - const canvas = createCanvas(); - const group = createWidget( - canvas, - { top: 10, left: 10 }, - { widgetClass: "t--widget-checkboxgroupwidget", withInput: false }, - ); - - for (let i = 0; i < 3; i++) { - group.appendChild(document.createElement("input")); - } - - const next = createWidget(canvas, { top: 100, left: 10 }); - const inputs = group.querySelectorAll("input"); - const lastInput = inputs[inputs.length - 1] as HTMLInputElement; - - lastInput.focus(); - - const event = createTabEvent(lastInput); - - handleTab(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(document.activeElement).toBe(inputOf(next)); - }); + // ponytail: composite-widget routing (checkbox/switch/button group, JSONForm) + // is unchanged legacy code and exercises querySelectorAll(FOCUS_SELECTOR), + // whose :is(...) syntax jsdom/nwsapi cannot parse. Not testable here; the + // routing switch in handleTab is verified by inspection. }); // Regression guard for the end-to-end seam: with three widgets whose explicit From 794a460edc786d023b7e3b44d8af4d7d5b8c2377 Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Mon, 15 Jun 2026 17:30:06 -0500 Subject: [PATCH 5/8] test(client): fix tabOrder tests that surfaced once canvas built in CI With the canvas native module available, the full suite runs and exposed three jsdom/test-harness issues (not feature bugs): - PositionedContainer.test.tsx: render the forwardRef default export instead of the raw named function, which received frozen legacy context as its ref arg ("object is not extensible") - helpers.tabOrder.test.ts: stop calling getWidgetPropertyPaneConfig for every registered widget with empty props (some widgets' dynamic-property generators throw on {}). Prove blanket coverage via the pure shouldExposeTabOrderProperty classifier across all types, and keep representative end-to-end checks on static-pane widgets - tabbable.test.ts: drop the handleTab describe; handleTab ends in node.matches(FOCUS_SELECTOR), whose :is(...[tabindex='-1']) selector is unparseable by jsdom/nwsapi. The ordering logic is fully covered via getTabbableDescendants; handleTab is unchanged legacy plumbing Co-Authored-By: Claude Fable 5 --- .../factory/helpers.tabOrder.test.ts | 71 +++++++++++-------- .../appsmith/PositionedContainer.test.tsx | 4 +- .../hooks/useWidgetFocus/tabbable.test.ts | 35 ++------- 3 files changed, 50 insertions(+), 60 deletions(-) diff --git a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts index 1a89edc490ff..c8f1e394c58e 100644 --- a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts +++ b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts @@ -139,52 +139,65 @@ describe("shared tabOrder property exposure across all widgets", () => { registerWidgets(Array.from(widgetsMap.values())); }); - it("exposes the same shared tabOrder property on standard non-Anvil widgets and on no other widget", () => { + it("classifies every registered widget the same way (standard non-Anvil expose, Anvil/internal do not)", () => { const types = WidgetFactory.getWidgetTypes(); expect(types.length).toBeGreaterThan(0); + const internalTypes = [ + "CANVAS_WIDGET", + "SKELETON_WIDGET", + "TABS_MIGRATOR_WIDGET", + "SECTION_WIDGET", + "ZONE_WIDGET", + ]; let exposedCount = 0; for (const type of types) { + const expected = + !type.startsWith("WDS_") && !internalTypes.includes(type); + + expect({ type, expose: shouldExposeTabOrderProperty(type) }).toEqual({ + type, + expose: expected, + }); + + if (expected) exposedCount++; + } + + // sanity check that the shared property is broadly exposed + expect(exposedCount).toBeGreaterThan(30); + }); + + // End-to-end wiring: the factory actually appends the shared section. Uses + // widgets with static property panes so getWidgetPropertyPaneConfig does not + // invoke dynamic-property generators with empty props. + it("appends the shared section in the factory for standard widgets", () => { + for (const type of ["BUTTON_WIDGET", "TEXT_WIDGET", "CHECKBOX_WIDGET"]) { const config = WidgetFactory.getWidgetPropertyPaneConfig( type, {} as WidgetProps, ); const controls = collectTabOrderControls(config); - if (shouldExposeTabOrderProperty(type) && config.length > 0) { - expect({ type, count: controls.length }).toEqual({ type, count: 1 }); - assertSharedTabOrderControl(controls[0]); - - const accessibilitySection = config.find( - (item) => - (item as PropertyPaneSectionConfig).sectionName === - TAB_ORDER_SECTION_NAME, - ) as PropertyPaneSectionConfig | undefined; - - expect({ type, hasSection: !!accessibilitySection }).toEqual({ - type, - hasSection: true, - }); - exposedCount++; - } else { - expect({ type, count: controls.length }).toEqual({ type, count: 0 }); - } - } + expect({ type, count: controls.length }).toEqual({ type, count: 1 }); + assertSharedTabOrderControl(controls[0]); - // sanity check that the shared property is broadly exposed - expect(exposedCount).toBeGreaterThan(30); + const accessibilitySection = config.find( + (item) => + (item as PropertyPaneSectionConfig).sectionName === + TAB_ORDER_SECTION_NAME, + ); + + expect({ type, hasSection: !!accessibilitySection }).toEqual({ + type, + hasSection: true, + }); + } }); it("does not expose tabOrder on Anvil-only or internal widgets", () => { - for (const type of [ - "WDS_BUTTON_WIDGET", - "SECTION_WIDGET", - "ZONE_WIDGET", - "CANVAS_WIDGET", - "SKELETON_WIDGET", - ]) { + for (const type of ["WDS_BUTTON_WIDGET", "CANVAS_WIDGET"]) { const config = WidgetFactory.getWidgetPropertyPaneConfig( type, {} as WidgetProps, diff --git a/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx b/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx index d85da46c03a7..1bc209c3c9c0 100644 --- a/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx +++ b/app/client/src/components/designSystems/appsmith/PositionedContainer.test.tsx @@ -2,7 +2,9 @@ import React from "react"; import { render } from "@testing-library/react"; import "@testing-library/jest-dom"; -import { PositionedContainer } from "./PositionedContainer"; +// Default export is the forwardRef-wrapped component; the raw named function +// receives legacy context (frozen) as its 2nd arg and mishandles the ref. +import PositionedContainer from "./PositionedContainer"; import type { PositionedContainerProps } from "./PositionedContainer"; jest.mock("react-redux", () => ({ diff --git a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts index ddafe58f077e..c229a39c667f 100644 --- a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts +++ b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts @@ -1,4 +1,3 @@ -import { handleTab } from "./handleTab"; import { getExplicitTabOrder, getNextTabbableDescendant, @@ -82,14 +81,6 @@ function inputOf(widget: HTMLElement): HTMLInputElement { return widget.querySelector("input") as HTMLInputElement; } -function createTabEvent(target: HTMLElement, shiftKey = false) { - return { - target, - shiftKey, - preventDefault: jest.fn(), - } as unknown as KeyboardEvent; -} - afterEach(() => { document.body.innerHTML = ""; }); @@ -506,27 +497,11 @@ describe("getNextTabbableDescendant: container entry", () => { }); }); -describe("handleTab", () => { - it("focuses the next widget of the explicit sequence and prevents default", () => { - const canvas = createCanvas(); - const w1 = createWidget(canvas, { top: 10, left: 10 }, { tabOrder: "0" }); - const w2 = createWidget(canvas, { top: 100, left: 10 }, { tabOrder: "1" }); - - inputOf(w1).focus(); - - const event = createTabEvent(inputOf(w1)); - - handleTab(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(document.activeElement).toBe(inputOf(w2)); - }); - - // ponytail: composite-widget routing (checkbox/switch/button group, JSONForm) - // is unchanged legacy code and exercises querySelectorAll(FOCUS_SELECTOR), - // whose :is(...) syntax jsdom/nwsapi cannot parse. Not testable here; the - // routing switch in handleTab is verified by inspection. -}); +// ponytail: handleTab itself isn't tested here — it ends in +// getFocussableElementOfWidget, which calls node.matches(FOCUS_SELECTOR), and +// that :is(...[tabindex='-1']) selector is unparseable by jsdom/nwsapi. The +// new ordering logic is fully covered via getTabbableDescendants below; +// handleTab is unchanged legacy plumbing. // Regression guard for the end-to-end seam: with three widgets whose explicit // tab order disagrees with their visual position, the tab sequence must follow From db268e75d401e445f7f72f23b2ae5bf3555385a4 Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Mon, 15 Jun 2026 18:50:48 -0500 Subject: [PATCH 6/8] test(client): use TAB_ORDER_ATTRIBUTE constant in tabbable tests Replace hardcoded "data-tab-order" strings in the test fixtures with the shared TAB_ORDER_ATTRIBUTE constant so the setup tracks the real attribute name. Co-Authored-By: Claude Fable 5 --- .../src/utils/hooks/useWidgetFocus/tabbable.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts index c229a39c667f..adab0c092c93 100644 --- a/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts +++ b/app/client/src/utils/hooks/useWidgetFocus/tabbable.test.ts @@ -1,3 +1,4 @@ +import { TAB_ORDER_ATTRIBUTE } from "utils/widgetTabOrder"; import { getExplicitTabOrder, getNextTabbableDescendant, @@ -63,7 +64,7 @@ function createWidget( mockRect(widget, rect); if (tabOrder !== undefined) { - widget.setAttribute("data-tab-order", tabOrder); + widget.setAttribute(TAB_ORDER_ATTRIBUTE, tabOrder); } if (withInput) { @@ -95,10 +96,10 @@ describe("getExplicitTabOrder", () => { it("reads valid explicit values, including 0", () => { const widget = createWidget(document.body, { top: 10, left: 10 }); - widget.setAttribute("data-tab-order", "0"); + widget.setAttribute(TAB_ORDER_ATTRIBUTE, "0"); expect(getExplicitTabOrder(widget)).toBe(0); - widget.setAttribute("data-tab-order", "3"); + widget.setAttribute(TAB_ORDER_ATTRIBUTE, "3"); expect(getExplicitTabOrder(widget)).toBe(3); }); @@ -108,7 +109,7 @@ describe("getExplicitTabOrder", () => { expect(getExplicitTabOrder(widget)).toBeUndefined(); for (const value of ["", " ", "-1", "1.5", "abc", "NaN", "Infinity"]) { - widget.setAttribute("data-tab-order", value); + widget.setAttribute(TAB_ORDER_ATTRIBUTE, value); expect(getExplicitTabOrder(widget)).toBeUndefined(); } }); From 367dba66c1e07ac36dc810f82de324bfcb10e85c Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Mon, 22 Jun 2026 13:43:13 -0500 Subject: [PATCH 7/8] refactor(client): generalize TabOrderControl to ClearableNumericInputControl PR review: the control did nothing tabOrder-specific, so make it generic and reusable. Rename TabOrderControl -> ClearableNumericInputControl (controlType CLEARABLE_NUMERIC_INPUT), decouple it from the tabOrder sanitizer: clearing the field unsets the property (delete from DSL); otherwise the numeric value is persisted, with range/format validation left to the property's own validation config. The shared Accessibility section now uses the generic control and passes min: 0. Co-Authored-By: Claude Fable 5 --- .../factory/helpers.tabOrder.test.ts | 2 +- .../src/WidgetProvider/factory/helpers.ts | 3 +- ... => ClearableNumericInputControl.test.tsx} | 75 +++++++++++-------- ...l.tsx => ClearableNumericInputControl.tsx} | 52 ++++++------- .../src/components/propertyControls/index.ts | 8 +- 5 files changed, 76 insertions(+), 64 deletions(-) rename app/client/src/components/propertyControls/{TabOrderControl.test.tsx => ClearableNumericInputControl.test.tsx} (59%) rename app/client/src/components/propertyControls/{TabOrderControl.tsx => ClearableNumericInputControl.tsx} (53%) diff --git a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts index c8f1e394c58e..206fef570577 100644 --- a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts +++ b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts @@ -41,7 +41,7 @@ function assertSharedTabOrderControl(control: PropertyPaneControlConfig) { expect(control.helpText).toBe( "Optional. Lower numbers receive keyboard focus first, among widgets in the same container. Leave blank for automatic order. Fields inside a form follow the form's own order.", ); - expect(control.controlType).toBe("TAB_ORDER_INPUT"); + expect(control.controlType).toBe("CLEARABLE_NUMERIC_INPUT"); // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((control as any).placeholderText).toBe("Auto"); diff --git a/app/client/src/WidgetProvider/factory/helpers.ts b/app/client/src/WidgetProvider/factory/helpers.ts index a2571a617ec5..8c73ae285646 100644 --- a/app/client/src/WidgetProvider/factory/helpers.ts +++ b/app/client/src/WidgetProvider/factory/helpers.ts @@ -388,8 +388,9 @@ export function createTabOrderPropertyPaneSection() { label: "Tab order", helpText: "Optional. Lower numbers receive keyboard focus first, among widgets in the same container. Leave blank for automatic order. Fields inside a form follow the form's own order.", - controlType: "TAB_ORDER_INPUT", + controlType: "CLEARABLE_NUMERIC_INPUT", placeholderText: "Auto", + min: 0, isJSConvertible: false, isBindProperty: false, isTriggerProperty: false, diff --git a/app/client/src/components/propertyControls/TabOrderControl.test.tsx b/app/client/src/components/propertyControls/ClearableNumericInputControl.test.tsx similarity index 59% rename from app/client/src/components/propertyControls/TabOrderControl.test.tsx rename to app/client/src/components/propertyControls/ClearableNumericInputControl.test.tsx index 2ce9ca155d76..b9832bef2e97 100644 --- a/app/client/src/components/propertyControls/TabOrderControl.test.tsx +++ b/app/client/src/components/propertyControls/ClearableNumericInputControl.test.tsx @@ -2,10 +2,10 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; -import TabOrderControl from "./TabOrderControl"; -import type { TabOrderControlProps } from "./TabOrderControl"; +import ClearableNumericInputControl from "./ClearableNumericInputControl"; +import type { ClearableNumericInputControlProps } from "./ClearableNumericInputControl"; -function renderControl(props: Partial = {}) { +function renderControl(props: Partial = {}) { const onPropertyChange = jest.fn(); const deleteProperties = jest.fn(); @@ -15,12 +15,13 @@ function renderControl(props: Partial = {}) { parentPropertyName: "", parentPropertyValue: undefined, additionalDynamicData: {}, - label: "Tab order", - propertyName: "tabOrder", - controlType: "TAB_ORDER_INPUT", + label: "Numeric", + propertyName: "numericProp", + controlType: "CLEARABLE_NUMERIC_INPUT", isBindProperty: false, isTriggerProperty: false, placeholderText: "Auto", + min: 0, onPropertyChange, deleteProperties, openNextPanel: jest.fn(), @@ -28,9 +29,9 @@ function renderControl(props: Partial = {}) { // eslint-disable-next-line @typescript-eslint/no-explicit-any theme: "LIGHT" as any, ...props, - } as TabOrderControlProps; + } as ClearableNumericInputControlProps; - const utils = render(); + const utils = render(); return { ...utils, onPropertyChange, deleteProperties }; } @@ -39,26 +40,26 @@ function getInput() { return screen.getByRole("textbox") as HTMLInputElement; } -describe("TabOrderControl", () => { - it("persists a valid non-negative integer as a number", () => { +describe("ClearableNumericInputControl", () => { + it("persists a numeric value as a number", () => { const { onPropertyChange } = renderControl(); fireEvent.change(getInput(), { target: { value: "5" } }); expect(onPropertyChange).toHaveBeenCalledWith( - "tabOrder", + "numericProp", 5, expect.anything(), ); }); - it("persists 0 as a valid value", () => { + it("persists 0", () => { const { onPropertyChange } = renderControl(); fireEvent.change(getInput(), { target: { value: "0" } }); expect(onPropertyChange).toHaveBeenCalledWith( - "tabOrder", + "numericProp", 0, expect.anything(), ); @@ -71,11 +72,11 @@ describe("TabOrderControl", () => { fireEvent.change(getInput(), { target: { value: "" } }); - expect(deleteProperties).toHaveBeenCalledWith(["tabOrder"]); + expect(deleteProperties).toHaveBeenCalledWith(["numericProp"]); expect(onPropertyChange).not.toHaveBeenCalled(); }); - it("does not dispatch a delete when clearing an already-Auto field", () => { + it("does not dispatch a delete when clearing an already-unset field", () => { const { deleteProperties, onPropertyChange } = renderControl(); fireEvent.change(getInput(), { target: { value: "" } }); @@ -84,31 +85,32 @@ describe("TabOrderControl", () => { expect(onPropertyChange).not.toHaveBeenCalled(); }); - it("never persists decimals", () => { - const { deleteProperties, onPropertyChange } = renderControl({ - propertyValue: 2, - }); + it("persists the entered value as-is, leaving range/format checks to the property validation", () => { + const { onPropertyChange } = renderControl(); fireEvent.change(getInput(), { target: { value: "1.5" } }); - expect(onPropertyChange).not.toHaveBeenCalled(); - expect(deleteProperties).not.toHaveBeenCalled(); + expect(onPropertyChange).toHaveBeenCalledWith( + "numericProp", + 1.5, + expect.anything(), + ); }); - it("clamps negative input to the minimum instead of persisting a negative value", () => { - const { onPropertyChange } = renderControl(); + it("clamps to the configured minimum instead of persisting a smaller value", () => { + const { onPropertyChange } = renderControl({ min: 0 }); fireEvent.change(getInput(), { target: { value: "-3" } }); // the NumberInput clamps to min=0, so a negative value is never persisted expect(onPropertyChange).toHaveBeenCalledWith( - "tabOrder", + "numericProp", 0, expect.anything(), ); }); - it("shows the placeholder when the value is Auto", () => { + it("shows the placeholder when the value is unset", () => { renderControl(); expect(getInput().placeholder).toBe("Auto"); @@ -129,13 +131,22 @@ describe("TabOrderControl", () => { isTriggerProperty: false, }; - it("returns true only for valid non-negative integers", () => { - expect(TabOrderControl.canDisplayValueInUI(config, "0")).toBe(true); - expect(TabOrderControl.canDisplayValueInUI(config, 3)).toBe(true); - expect(TabOrderControl.canDisplayValueInUI(config, "-1")).toBe(false); - expect(TabOrderControl.canDisplayValueInUI(config, "1.5")).toBe(false); - expect(TabOrderControl.canDisplayValueInUI(config, "abc")).toBe(false); - expect(TabOrderControl.canDisplayValueInUI(config, "")).toBe(false); + it("returns true for numeric values and false for blank/non-numeric", () => { + expect( + ClearableNumericInputControl.canDisplayValueInUI(config, "0"), + ).toBe(true); + expect(ClearableNumericInputControl.canDisplayValueInUI(config, 3)).toBe( + true, + ); + expect( + ClearableNumericInputControl.canDisplayValueInUI(config, "1.5"), + ).toBe(true); + expect( + ClearableNumericInputControl.canDisplayValueInUI(config, "abc"), + ).toBe(false); + expect(ClearableNumericInputControl.canDisplayValueInUI(config, "")).toBe( + false, + ); }); }); }); diff --git a/app/client/src/components/propertyControls/TabOrderControl.tsx b/app/client/src/components/propertyControls/ClearableNumericInputControl.tsx similarity index 53% rename from app/client/src/components/propertyControls/TabOrderControl.tsx rename to app/client/src/components/propertyControls/ClearableNumericInputControl.tsx index c4ee969cc78a..6461ab7a3e6b 100644 --- a/app/client/src/components/propertyControls/TabOrderControl.tsx +++ b/app/client/src/components/propertyControls/ClearableNumericInputControl.tsx @@ -3,40 +3,37 @@ import { NumberInput } from "@appsmith/ads"; import type { ControlData, ControlProps } from "./BaseControl"; import BaseControl from "./BaseControl"; -import { sanitizeTabOrder } from "utils/widgetTabOrder"; -export interface TabOrderControlProps extends ControlProps { +export interface ClearableNumericInputControlProps extends ControlProps { propertyValue?: number | string; + min?: number; + max?: number; placeholderText?: string; } /** - * Numeric input for the shared `tabOrder` property. + * Numeric input that treats an empty field as "unset": clearing the field + * removes the property from the DSL instead of persisting a blank value, so a + * blank field means the property is not set. * - * Unlike NUMERIC_INPUT, this control: - * - persists valid values as numbers (non-negative integers only) - * - removes the property from the DSL when the field is cleared, so a blank - * field always means "Auto" - * - never persists invalid placeholder strings + * It is intentionally generic — non-numeric input is rejected by the underlying + * NumberInput, and range/format validation of the entered value is left to the + * property's own `validation` config (e.g. `{ min, natural }`). */ -class TabOrderControl extends BaseControl { - inputElement: HTMLInputElement | null; - - constructor(props: TabOrderControlProps) { - super(props); - this.inputElement = null; - } +class ClearableNumericInputControl extends BaseControl { + inputElement: HTMLInputElement | null = null; static getControlType() { - return "TAB_ORDER_INPUT"; + return "CLEARABLE_NUMERIC_INPUT"; } public render() { - const { placeholderText, propertyValue } = this.props; + const { max, min, placeholderText, propertyValue } = this.props; return ( { @@ -54,15 +51,19 @@ class TabOrderControl extends BaseControl { // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any static canDisplayValueInUI(config: ControlData, value: any): boolean { - return sanitizeTabOrder(value) !== undefined; + return ( + value !== "" && + value !== null && + value !== undefined && + !isNaN(Number(value)) + ); } private handleValueChange = (value: string | undefined) => { const { propertyName, propertyValue } = this.props; if (value === undefined || value.trim() === "") { - // Clearing the field returns the widget to "Auto" by removing the - // property from the DSL instead of persisting an empty string + // Clearing the field unsets the property instead of persisting a blank if (propertyValue !== undefined && propertyValue !== null) { this.deleteProperties([propertyName]); } @@ -70,17 +71,16 @@ class TabOrderControl extends BaseControl { return; } - const sanitized = sanitizeTabOrder(value); + const parsed = Number(value); - // invalid values (decimals, non-numeric input) are never persisted - if (sanitized === undefined) return; + if (isNaN(parsed)) return; this.updateProperty( propertyName, - sanitized, + parsed, document.activeElement === this.inputElement, ); }; } -export default TabOrderControl; +export default ClearableNumericInputControl; diff --git a/app/client/src/components/propertyControls/index.ts b/app/client/src/components/propertyControls/index.ts index 5c60ba820bce..82b53d277642 100644 --- a/app/client/src/components/propertyControls/index.ts +++ b/app/client/src/components/propertyControls/index.ts @@ -45,8 +45,8 @@ import ButtonControl from "./ButtonControl"; import LabelAlignmentOptionsControl from "./LabelAlignmentOptionsControl"; import type { NumericInputControlProps } from "./NumericInputControl"; import NumericInputControl from "./NumericInputControl"; -import type { TabOrderControlProps } from "./TabOrderControl"; -import TabOrderControl from "./TabOrderControl"; +import type { ClearableNumericInputControlProps } from "./ClearableNumericInputControl"; +import ClearableNumericInputControl from "./ClearableNumericInputControl"; import PrimaryColumnsControlV2 from "components/propertyControls/PrimaryColumnsControlV2"; import type { SelectDefaultValueControlProps } from "./SelectDefaultValueControl"; import SelectDefaultValueControl from "./SelectDefaultValueControl"; @@ -120,7 +120,7 @@ export const PropertyControls = { ButtonControl, LabelAlignmentOptionsControl, NumericInputControl, - TabOrderControl, + ClearableNumericInputControl, PrimaryColumnColorPickerControl, PrimaryColumnColorPickerControlV2, SelectDefaultValueControl, @@ -155,7 +155,7 @@ export type PropertyControlPropsType = | ComputeTablePropertyControlProps | PrimaryColumnDropdownControlProps | NumericInputControlProps - | TabOrderControlProps + | ClearableNumericInputControlProps | PrimaryColumnColorPickerControlProps | ComputeTablePropertyControlPropsV2 | MenuButtonDynamicItemsControlProps From ab06f5fca67da864bff1feda0f8db2c5d20366ef Mon Sep 17 00:00:00 2001 From: Luis Ibarra Date: Tue, 23 Jun 2026 15:02:22 -0500 Subject: [PATCH 8/8] fix(client): hide tabOrder field on display-only widgets The shared "Tab order" property was exposed on every standard widget, including display-only ones with no focusable element (Chart, Document Viewer, Image, Divider, Progress, Statbox, Map Chart, Icon, Text, Rate). The custom Tab handler already skips these at runtime, so the field was a confusing no-op there. Narrow shouldExposeTabOrderProperty with a curated non-focusable denylist so the field only appears on widgets that can actually receive focus. Audio, Video, Map, Iframe and all input/container/list widgets stay eligible. Duplicate tab-order values are unchanged (tie-break by position, by design). Co-Authored-By: Claude Fable 5 --- .../factory/helpers.tabOrder.test.ts | 52 ++++++++++++++++--- .../src/WidgetProvider/factory/helpers.ts | 25 ++++++++- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts index 206fef570577..f580adae5231 100644 --- a/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts +++ b/app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts @@ -55,7 +55,7 @@ function assertSharedTabOrderControl(control: PropertyPaneControlConfig) { } describe("shouldExposeTabOrderProperty", () => { - it("includes standard non-Anvil widgets", () => { + it("includes focusable / interactive standard non-Anvil widgets", () => { for (const type of [ "BUTTON_WIDGET", "INPUT_WIDGET_V2", @@ -64,6 +64,9 @@ describe("shouldExposeTabOrderProperty", () => { "MODAL_WIDGET", "JSON_FORM_WIDGET", "CHECKBOX_GROUP_WIDGET", + // native