Skip to content
Open
249 changes: 249 additions & 0 deletions app/client/src/WidgetProvider/factory/helpers.tabOrder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
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 "./helpers";

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 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("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");
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 focusable / interactive 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",
// native <audio>/<video> controls are focusable
"AUDIO_WIDGET",
"VIDEO_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);
}
});

it("excludes display-only widgets that have nothing focusable", () => {
for (const type of [
"TEXT_WIDGET",
"RATE_WIDGET",
"IMAGE_WIDGET",
"CHART_WIDGET",
"MAP_CHART_WIDGET",
"DIVIDER_WIDGET",
"STATBOX_WIDGET",
"DOCUMENT_VIEWER_WIDGET",
"ICON_WIDGET",
"PROGRESSBAR_WIDGET",
"PROGRESS_WIDGET",
"CIRCULAR_PROGRESS_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("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 excludedTypes = [
// Anvil-only / internal
"CANVAS_WIDGET",
"SKELETON_WIDGET",
"TABS_MIGRATOR_WIDGET",
"SECTION_WIDGET",
"ZONE_WIDGET",
// display-only / non-focusable
"TEXT_WIDGET",
"RATE_WIDGET",
"IMAGE_WIDGET",
"CHART_WIDGET",
"MAP_CHART_WIDGET",
"DIVIDER_WIDGET",
"STATBOX_WIDGET",
"DOCUMENT_VIEWER_WIDGET",
"ICON_WIDGET",
"PROGRESSBAR_WIDGET",
"PROGRESS_WIDGET",
"CIRCULAR_PROGRESS_WIDGET",
];
let exposedCount = 0;

for (const type of types) {
const expected =
!type.startsWith("WDS_") && !excludedTypes.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", "SWITCH_WIDGET", "CHECKBOX_WIDGET"]) {
const config = WidgetFactory.getWidgetPropertyPaneConfig(
type,
{} as WidgetProps,
);
const controls = collectTabOrderControls(config);

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,
);

expect({ type, hasSection: !!accessibilitySection }).toEqual({
type,
hasSection: true,
});
}
});

it("does not expose tabOrder on Anvil-only, internal, or display-only widgets", () => {
for (const type of [
"WDS_BUTTON_WIDGET",
"CANVAS_WIDGET",
"DIVIDER_WIDGET",
]) {
const config = WidgetFactory.getWidgetPropertyPaneConfig(
type,
{} as WidgetProps,
);

expect(collectTabOrderControls(config)).toHaveLength(0);
}
});
});
108 changes: 107 additions & 1 deletion app/client/src/WidgetProvider/factory/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -338,3 +341,106 @@ 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,
];

/**
* Display-only widgets that have no focusable element. The platform's custom
* Tab handler skips them at runtime, so the `tabOrder` property would be a
* no-op — hide it to avoid confusing the builder.
*/
const TAB_ORDER_NON_FOCUSABLE_WIDGET_TYPES: string[] = [
// both are already excluded from tabbing at runtime via NON_FOCUSABLE_WIDGET_CLASS
"TEXT_WIDGET",
"RATE_WIDGET",
"IMAGE_WIDGET",
"CHART_WIDGET",
"MAP_CHART_WIDGET",
"DIVIDER_WIDGET",
"STATBOX_WIDGET",
"DOCUMENT_VIEWER_WIDGET",
"ICON_WIDGET",
"PROGRESSBAR_WIDGET",
"PROGRESS_WIDGET",
"CIRCULAR_PROGRESS_WIDGET",
];

export function shouldExposeTabOrderProperty(type: WidgetType): boolean {
if (!type) return false;

if (type.startsWith("WDS_")) return false;

if (TAB_ORDER_EXCLUDED_WIDGET_TYPES.includes(type)) return false;

return !TAB_ORDER_NON_FOCUSABLE_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: "CLEARABLE_NUMERIC_INPUT",
placeholderText: "Auto",
min: 0,
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()];
}
8 changes: 6 additions & 2 deletions app/client/src/WidgetProvider/factory/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
import {
addPropertyConfigIds,
addSearchConfigToPanelConfig,
addTabOrderToPropertyPaneConfig,
convertFunctionsToString,
enhancePropertyPaneConfig,
generatePropertyPaneSearchConfig,
Expand Down Expand Up @@ -326,7 +327,10 @@ export class WidgetFactory {
convertFunctionsToString,
addPropertyConfigIds,
]);
const enhancedPropertyPaneConfig = enhance(propertyPaneConfig, features);
const enhancedPropertyPaneConfig = enhance(
addTabOrderToPropertyPaneConfig(type, propertyPaneConfig),
features,
);

return enhancedPropertyPaneConfig;
}
Expand Down Expand Up @@ -377,7 +381,7 @@ export class WidgetFactory {
]);

const enhancedPropertyPaneContentConfig = enhance(
propertyPaneContentConfig,
addTabOrderToPropertyPaneConfig(type, propertyPaneContentConfig),
features,
PropertyPaneConfigTypes.CONTENT,
type,
Expand Down
Loading
Loading