diff --git a/.changeset/sweet-islands-dance.md b/.changeset/sweet-islands-dance.md new file mode 100644 index 00000000000..52e2ed7da1e --- /dev/null +++ b/.changeset/sweet-islands-dance.md @@ -0,0 +1,5 @@ +--- +"@itwin/components-react": minor +--- + +Provide option to define data-testids for PropertyEditor components diff --git a/.vscode/cSpell.json b/.vscode/cSpell.json index 110bea8d6cc..a73ee5f42a8 100644 --- a/.vscode/cSpell.json +++ b/.vscode/cSpell.json @@ -29,6 +29,7 @@ "backstageevent", "badeditor", "badtype", + "beforeunload", "betools", "borderless", "boxshadow", diff --git a/.vscode/launch.json b/.vscode/launch.json index 892321a6437..4278bde17ab 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -80,7 +80,7 @@ "type": "node", "request": "launch", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "start"], + "runtimeArgs": ["run", "dev"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "serverReadyAction": { diff --git a/common/api/components-react.api.md b/common/api/components-react.api.md index 737071fa751..8e1b0035609 100644 --- a/common/api/components-react.api.md +++ b/common/api/components-react.api.md @@ -584,6 +584,7 @@ export interface EditorProps { commit?: () => void; // (undocumented) disabled?: boolean; + id?: string; // (undocumented) metadata: TMetadata; // (undocumented) diff --git a/common/api/core-react.api.md b/common/api/core-react.api.md index c6d1b1c3a09..db5c7d5f857 100644 --- a/common/api/core-react.api.md +++ b/common/api/core-react.api.md @@ -649,6 +649,7 @@ export interface ImageCheckBoxProps extends CommonProps { border?: boolean; checked?: boolean; disabled?: boolean; + id?: string; imageOff: string | React_2.ReactNode; imageOn: string | React_2.ReactNode; inputClassName?: string; diff --git a/ui/components-react/src/components-react/editors/BooleanEditor.tsx b/ui/components-react/src/components-react/editors/BooleanEditor.tsx index 4466c740d03..af70562abb3 100644 --- a/ui/components-react/src/components-react/editors/BooleanEditor.tsx +++ b/ui/components-react/src/components-react/editors/BooleanEditor.tsx @@ -140,6 +140,7 @@ export class BooleanEditor this.props.propertyRecord?.isReadonly } data-testid="components-checkbox-editor" + id={this.props.propertyRecord?.property.name} > ); } diff --git a/ui/components-react/src/components-react/editors/DateTimeEditor.tsx b/ui/components-react/src/components-react/editors/DateTimeEditor.tsx index 23f54f7614a..e4776bccb8d 100644 --- a/ui/components-react/src/components-react/editors/DateTimeEditor.tsx +++ b/ui/components-react/src/components-react/editors/DateTimeEditor.tsx @@ -291,7 +291,11 @@ export class DateTimeEditor ); return ( -
+
{this.state.choices && this.state.enumIcons.length && diff --git a/ui/components-react/src/components-react/editors/EnumEditor.tsx b/ui/components-react/src/components-react/editors/EnumEditor.tsx index 9f3dbdb4a33..5db0a404d24 100644 --- a/ui/components-react/src/components-react/editors/EnumEditor.tsx +++ b/ui/components-react/src/components-react/editors/EnumEditor.tsx @@ -181,6 +181,7 @@ export class EnumEditor value={selectValue} onChange={this._updateSelectValue} data-testid="components-select-editor" + id={this.props.propertyRecord?.property.name} options={this.state.options} triggerProps={{ ref: (el) => { diff --git a/ui/components-react/src/components-react/editors/IconEditor.tsx b/ui/components-react/src/components-react/editors/IconEditor.tsx index 99493386308..7d9e8db4cb2 100644 --- a/ui/components-react/src/components-react/editors/IconEditor.tsx +++ b/ui/components-react/src/components-react/editors/IconEditor.tsx @@ -171,6 +171,7 @@ export class IconEditor readonly={this.props.propertyRecord?.isReadonly} onIconChange={this._onIconChange} data-testid="components-icon-editor" + id={this.props.propertyRecord?.property.name} />
); diff --git a/ui/components-react/src/components-react/editors/ImageCheckBoxEditor.tsx b/ui/components-react/src/components-react/editors/ImageCheckBoxEditor.tsx index 53a357d1942..224e0e52ac8 100644 --- a/ui/components-react/src/components-react/editors/ImageCheckBoxEditor.tsx +++ b/ui/components-react/src/components-react/editors/ImageCheckBoxEditor.tsx @@ -162,6 +162,7 @@ export class ImageCheckBoxEditor disabled={isDisabled} onClick={this._handleClick} data-testid="components-imagecheckbox-editor" + id={this.props.propertyRecord?.property.name} /> ); } diff --git a/ui/components-react/src/components-react/editors/NumericInputEditor.tsx b/ui/components-react/src/components-react/editors/NumericInputEditor.tsx index 9c5bcd1935e..d182c9af58c 100644 --- a/ui/components-react/src/components-react/editors/NumericInputEditor.tsx +++ b/ui/components-react/src/components-react/editors/NumericInputEditor.tsx @@ -197,6 +197,7 @@ export class NumericInputEditor return ( +
+
); diff --git a/ui/components-react/src/components-react/new-editors/Types.ts b/ui/components-react/src/components-react/new-editors/Types.ts index 70deef4df00..5d583491cb8 100644 --- a/ui/components-react/src/components-react/new-editors/Types.ts +++ b/ui/components-react/src/components-react/new-editors/Types.ts @@ -40,6 +40,12 @@ export interface EditorProps { cancel?: () => void; disabled?: boolean; size?: "small" | "large"; + /** + * HTML `id` attribute forwarded to the interactive element. Used to associate + * the editor with a `
); diff --git a/ui/components-react/src/components-react/new-editors/interop/old-editors/Color.tsx b/ui/components-react/src/components-react/new-editors/interop/old-editors/Color.tsx index 95f1f405527..3c42238f639 100644 --- a/ui/components-react/src/components-react/new-editors/interop/old-editors/Color.tsx +++ b/ui/components-react/src/components-react/new-editors/interop/old-editors/Color.tsx @@ -48,6 +48,7 @@ function ColorEditor({ onChange, commit, size, + id, }: EditorProps) { const colorParams = useColorEditorParams(metadata); const colors = colorParams?.colorValues ?? []; @@ -81,7 +82,7 @@ function ColorEditor({ } }} > - + diff --git a/ui/components-react/src/components-react/new-editors/interop/old-editors/CustomNumber.tsx b/ui/components-react/src/components-react/new-editors/interop/old-editors/CustomNumber.tsx index b49b144b237..4a4bb820480 100644 --- a/ui/components-react/src/components-react/new-editors/interop/old-editors/CustomNumber.tsx +++ b/ui/components-react/src/components-react/new-editors/interop/old-editors/CustomNumber.tsx @@ -51,6 +51,7 @@ export function CustomNumberEditor({ size, disabled, decoration, + id, }: CustomNumberEditorProps) { const formatParams = useCustomFormattedNumberParams(metadata); const sizeParams = useInputEditorSizeParams(metadata); @@ -101,6 +102,7 @@ export function CustomNumberEditor({ {icon} ) : null} ) { const enumMetadata = useEnumMetadata(metadata); const buttonGroupParams = useButtonGroupEditorParams(metadata); @@ -53,7 +54,7 @@ function EnumButtonGroupEditor({ const currentValue = value ? value : { choice: firstChoice?.value ?? "" }; return ( - + {enumMetadata.choices.map((choice) => { const icon = findIcon(enumIcons?.get(choice.value)); return ( diff --git a/ui/components-react/src/components-react/new-editors/interop/old-editors/NumericInput.tsx b/ui/components-react/src/components-react/new-editors/interop/old-editors/NumericInput.tsx index ed99364776b..d81fd2692b5 100644 --- a/ui/components-react/src/components-react/new-editors/interop/old-editors/NumericInput.tsx +++ b/ui/components-react/src/components-react/new-editors/interop/old-editors/NumericInput.tsx @@ -35,6 +35,7 @@ function NumericInputEditor({ onChange, size, disabled, + id, }: EditorProps) { const sizeParams = useInputEditorSizeParams(metadata); const rangeParams = useRangeEditorParams(metadata); @@ -67,7 +68,13 @@ function NumericInputEditor({ }); return ( - + ); } diff --git a/ui/components-react/src/components-react/new-editors/interop/old-editors/Slider.tsx b/ui/components-react/src/components-react/new-editors/interop/old-editors/Slider.tsx index 16c6ab391bb..1a79498b7f0 100644 --- a/ui/components-react/src/components-react/new-editors/interop/old-editors/Slider.tsx +++ b/ui/components-react/src/components-react/new-editors/interop/old-editors/Slider.tsx @@ -41,6 +41,7 @@ function SliderEditor({ onChange, commit, size, + id, }: EditorProps) { const sliderParams = useSliderEditorParams(metadata); if (!sliderParams) { @@ -98,7 +99,7 @@ function SliderEditor({ }} applyBackground={true} > - diff --git a/ui/components-react/src/components-react/new-editors/interop/old-editors/TextArea.tsx b/ui/components-react/src/components-react/new-editors/interop/old-editors/TextArea.tsx index ab22033d761..9de3b636de7 100644 --- a/ui/components-react/src/components-react/new-editors/interop/old-editors/TextArea.tsx +++ b/ui/components-react/src/components-react/new-editors/interop/old-editors/TextArea.tsx @@ -29,6 +29,7 @@ function TextAreaEditor({ commit, size, disabled, + id, }: EditorProps) { const currentValue = value ?? { value: "" }; @@ -50,7 +51,7 @@ function TextAreaEditor({ } }} > - diff --git a/ui/components-react/src/test/new-editors/EditorRenderer.test.tsx b/ui/components-react/src/test/new-editors/EditorRenderer.test.tsx index f7f4d8c049d..b27c8c23f4a 100644 --- a/ui/components-react/src/test/new-editors/EditorRenderer.test.tsx +++ b/ui/components-react/src/test/new-editors/EditorRenderer.test.tsx @@ -41,4 +41,17 @@ describe("EditorRenderer", () => { expect(queryByText("Multiline")).not.toBeNull(); expect(queryByText("Test message")).not.toBeNull(); }); + + it("forwards id prop to the rendered editor element", () => { + const { container } = render( + {}} + id="my-editor-id" + /> + ); + + expect(container.querySelector('[id="my-editor-id"]')).not.toBeNull(); + }); }); diff --git a/ui/components-react/src/test/new-editors/interop/PropertyRecordEditor.test.tsx b/ui/components-react/src/test/new-editors/interop/PropertyRecordEditor.test.tsx index 0632c997c46..e5e67eb8aeb 100644 --- a/ui/components-react/src/test/new-editors/interop/PropertyRecordEditor.test.tsx +++ b/ui/components-react/src/test/new-editors/interop/PropertyRecordEditor.test.tsx @@ -7,7 +7,7 @@ import * as React from "react"; import { describe, it } from "vitest"; import { render, waitFor } from "@testing-library/react"; import { PropertyRecordEditor } from "../../../components-react.js"; -import { PropertyRecord } from "@itwin/appui-abstract"; +import { PropertyRecord, PropertyValueFormat } from "@itwin/appui-abstract"; describe("PropertyRecordEditor", () => { const propertyRecord = PropertyRecord.fromString("test"); @@ -42,4 +42,89 @@ describe("PropertyRecordEditor", () => { rendered.container.querySelector(".components-editor-container") ).toBeNull(); }); + + describe("id / label association (a11y)", () => { + it("sets id={property.name} on the input when using new editor system", async () => { + const record = PropertyRecord.fromString("hello", "arcLength"); + const { container } = render( + {}} + onCancel={() => {}} + editorSystem="new" + /> + ); + + await waitFor(() => + expect(container.querySelector('[id="arcLength"]')).not.toBeNull() + ); + }); + + it("allows getByLabelText queries for text properties when using new editor system", async () => { + const record = PropertyRecord.fromString("hello", "arcLength"); + const { getByLabelText } = render( + <> + + {}} + onCancel={() => {}} + editorSystem="new" + /> + + ); + + await waitFor(() => expect(getByLabelText("Arc Length")).toBeDefined()); + }); + + it("sets id={property.name} on the select when using new editor system with enum property", async () => { + const record = new PropertyRecord( + { valueFormat: PropertyValueFormat.Primitive, value: 0 }, + { + name: "arcType", + typename: "enum", + displayLabel: "Arc Type", + enum: { + choices: [ + { label: "Clockwise", value: 0 }, + { label: "Counterclockwise", value: 1 }, + ], + }, + } + ); + + const { container } = render( + {}} + onCancel={() => {}} + editorSystem="new" + /> + ); + + await waitFor(() => + expect(container.querySelector('[id="arcType"]')).not.toBeNull() + ); + }); + + it("does not set id when using legacy editor system", async () => { + const record = PropertyRecord.fromString("hello", "arcLength"); + const { container } = render( + {}} + onCancel={() => {}} + /> + ); + + await waitFor(() => + expect(container.querySelector("input")).not.toBeNull() + ); + // Legacy EditorContainer uses property.name as id too, but through a different path. + // This test just guards that the new-editor id is not present on the container element. + expect( + container.querySelector(".components-editor-container") + ).not.toBeNull(); + }); + }); }); diff --git a/ui/core-react/src/core-react/imagecheckbox/ImageCheckBox.tsx b/ui/core-react/src/core-react/imagecheckbox/ImageCheckBox.tsx index cc8eb258433..78fa3773ff4 100644 --- a/ui/core-react/src/core-react/imagecheckbox/ImageCheckBox.tsx +++ b/ui/core-react/src/core-react/imagecheckbox/ImageCheckBox.tsx @@ -39,6 +39,8 @@ export interface ImageCheckBoxProps extends CommonProps { border?: boolean; /** Provides ability to return reference to HTMLInputElement */ inputRef?: React.Ref; + /** HTML id attribute for the checkbox input element */ + id?: string; } /** ImageCheckBox React component shows a checked or unchecked image @@ -85,6 +87,7 @@ export class ImageCheckBox extends React.PureComponent { >