From a22e3b03e4dc47d7038706d8e9cbca73f1e69e6b Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Wed, 12 Aug 2026 12:42:56 +0530 Subject: [PATCH 1/6] feat(Table) : added Table Component --- .../__tests__/components/Table.test.tsx | 204 ++++++++++++++++++ .../src/components/Table/Table.tsx | 69 ++++++ .../src/components/Table/TableHeader.tsx | 73 +++++++ .../src/components/Table/TableRow.tsx | 76 +++++++ .../src/components/Table/index.ts | 3 + .../react-vanilla-components/src/index.ts | 8 +- .../src/utils/mappings.ts | 8 +- 7 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 packages/react-vanilla-components/__tests__/components/Table.test.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/Table.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/TableHeader.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/TableRow.tsx create mode 100644 packages/react-vanilla-components/src/components/Table/index.ts diff --git a/packages/react-vanilla-components/__tests__/components/Table.test.tsx b/packages/react-vanilla-components/__tests__/components/Table.test.tsx new file mode 100644 index 0000000..f64944a --- /dev/null +++ b/packages/react-vanilla-components/__tests__/components/Table.test.tsx @@ -0,0 +1,204 @@ +/* + * Copyright 2026 Adobe, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import Table from '../../src/components/Table/Table'; +import { createForm, Provider, renderComponent } from '../utils'; +import '@testing-library/jest-dom/extend-expect'; + +const headerItem = { + id: 'header-test', + fieldType: 'panel', + ':type': 'table-header', + name: 'header', + label: { value: 'Header Row', visible: true }, + visible: true, + enabled: true, + index: 0, + items: [ + { id: 'col1', fieldType: 'plain-text', ':type': 'plain-text', name: 'col1', value: 'Column 1', index: 0 }, + { id: 'col2', fieldType: 'plain-text', ':type': 'plain-text', name: 'col2', value: 'Column 2', index: 1 }, + ], +}; + +const rowItem = { + id: 'row1-test', + fieldType: 'panel', + ':type': 'table-row', + name: 'row1', + label: { value: 'Row 1', visible: true }, + visible: true, + enabled: true, + index: 1, + items: [ + { id: 'cell1', fieldType: 'text-input', ':type': 'text-input', name: 'cell1', type: 'string', label: { value: '' }, index: 0 }, + { id: 'cell2', fieldType: 'text-input', ':type': 'text-input', name: 'cell2', type: 'string', label: { value: '' }, index: 1 }, + ], +}; + +const tableField = { + id: 'table-test', + fieldType: 'panel', + ':type': 'table', + name: 'table1', + label: { value: 'Test Table', visible: true }, + visible: true, + enabled: true, + index: 0, + enableSorting: false, + items: [headerItem, rowItem], +}; + +const fullField = { + id: 'table-52dfd36109', + fieldType: 'panel', + name: 'table1786517710602', + ':type': 'table', + label: { value: 'Table' }, + visible: true, + enabled: true, + index: 0, + enableSorting: true, + items: [ + { + id: 'tableheader-5ae4718dd5', + fieldType: 'panel', + ':type': 'table-header', + name: 'header1786517710631', + label: { value: 'Header Row' }, + visible: true, + enabled: true, + index: 0, + items: [ + { id: 'text-b805a4780f', fieldType: 'plain-text', name: 'column1', value: 'Column 1', index: 0 }, + { id: 'text-9575c7c94f', fieldType: 'plain-text', name: 'column2', value: 'Column 2', index: 1 }, + ], + }, + { + id: 'tablerow-6c377ff30e', + fieldType: 'panel', + ':type': 'table-row', + name: 'row11786517710665', + label: { value: 'Row 1' }, + visible: true, + enabled: true, + index: 1, + items: [ + { id: 'textinput-c849d0b688', fieldType: 'text-input', name: 'cell1', type: 'string', label: { value: '' }, index: 0 }, + { id: 'textinput-4e1d3fc2df', fieldType: 'text-input', name: 'cell2', type: 'string', label: { value: '' }, index: 1 }, + ], + }, + ], +}; + +const helper = renderComponent(Table); + +describe('Table', () => { + test('renders element', () => { + const { renderResponse } = helper(tableField); + expect(renderResponse.container.querySelector('table')).not.toBeNull(); + }); + + test('renders label', () => { + const { renderResponse } = helper(tableField); + expect(renderResponse.queryByText(tableField.label.value)).not.toBeNull(); + }); + + test('renders and ', () => { + const { renderResponse } = helper(tableField); + expect(renderResponse.container.querySelector('thead')).not.toBeNull(); + expect(renderResponse.container.querySelector('tbody')).not.toBeNull(); + }); + + test('header items render as ', () => { + const { renderResponse } = helper(tableField); + const thead = renderResponse.container.querySelector('thead'); + const headers = thead?.querySelectorAll('th'); + expect(headers?.length).toBe(2); + }); + + test('row items render as in ', () => { + const { renderResponse } = helper(tableField); + const tbody = renderResponse.container.querySelector('tbody'); + const rows = tbody?.querySelectorAll('tr'); + expect(rows?.length).toBe(1); + }); + + test('row cells render as has adaptiveFormTableHeader data-cmp-is', () => { + const { renderResponse } = helper(tableField); + const headerRow = renderResponse.container.querySelector('.cmp-adaptiveform-tableheader'); + expect(headerRow?.getAttribute('data-cmp-is')).toBe('adaptiveFormTableHeader'); + }); + + test('body has adaptiveFormTableRow data-cmp-is', () => { + const { renderResponse } = helper(tableField); + const dataRow = renderResponse.container.querySelector('.cmp-adaptiveform-tablerow'); + expect(dataRow?.getAttribute('data-cmp-is')).toBe('adaptiveFormTableRow'); + }); + + test('tooltip and description toggle correctly', async () => { + const f = { + ...tableField, + tooltip: 'Short Description', + description: 'Long Description', + properties: { 'afs:layout': { tooltipVisible: true } }, + }; + const { renderResponse } = await helper(f); + expect(renderResponse.getByText('Short Description')).not.toBeNull(); + const button = renderResponse.container.getElementsByClassName('cmp-adaptiveform-table__questionmark'); + if (button.length > 0) { + (button[0] as HTMLButtonElement).click(); + expect(renderResponse.getByText('Long Description')).not.toBeNull(); + } + }); + + test('full model.json field renders without errors', async () => { + const form = await createForm(fullField); + const state = form.items[0].getState(); + const tableEl =
in
', () => { + const { renderResponse } = helper(tableField); + const tbody = renderResponse.container.querySelector('tbody'); + const cells = tbody?.querySelectorAll('td'); + expect(cells?.length).toBe(2); + }); + + test('table is hidden when visible is false', () => { + const { renderResponse } = helper({ ...tableField, visible: false }); + expect(renderResponse.queryByText(tableField.label.value)).toBeNull(); + }); + + test('sort buttons absent when enableSorting is false', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: false }); + const sortButtons = renderResponse.container.getElementsByClassName('cmp-adaptiveform-table__sort-button'); + expect(sortButtons.length).toBe(0); + }); + + test('sort buttons appear when enableSorting is true', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortButtons = renderResponse.container.getElementsByClassName('cmp-adaptiveform-table__sort-button'); + expect(sortButtons.length).toBe(2); + }); + + test('cmp-adaptiveform-table BEM block on root div', () => { + const { renderResponse } = helper(tableField); + const root = renderResponse.container.querySelector('.cmp-adaptiveform-table'); + expect(root).not.toBeNull(); + expect(root?.getAttribute('data-cmp-is')).toBe('adaptiveFormTable'); + }); + + test('header
; + const wrapper = Provider(form); + const { container } = render(tableEl, { wrapper }); + expect(container.querySelector('table')).not.toBeNull(); + expect(container.querySelector('thead')).not.toBeNull(); + expect(container.querySelector('tbody')).not.toBeNull(); + }); +}); diff --git a/packages/react-vanilla-components/src/components/Table/Table.tsx b/packages/react-vanilla-components/src/components/Table/Table.tsx new file mode 100644 index 0000000..ed03480 --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -0,0 +1,69 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React from 'react'; +import { withRuleEnginePanel } from '../../utils/withRuleEngine'; +import { PROPS_PANEL } from '../../utils/type'; +import LabelWithDescription from '../common/LabelWithDescription'; +import TableHeader from './TableHeader'; +import TableRow from './TableRow'; + +const Table = (props: PROPS_PANEL) => { + const { items, id, visible, enabled, label, appliedCssClassNames } = props; + const enableSorting = (props as any).enableSorting as boolean; + + const headerItems = items.filter((item: any) => item[':type'] === 'table-header'); + const rowItems = items.filter((item: any) => item[':type'] === 'table-row'); + + return ( +
+ +
+ + {headerItems.map((item: any) => ( + + ))} + + + {rowItems.map((item: any) => ( + + ))} + +
+ + ); +}; + +export default withRuleEnginePanel(Table); diff --git a/packages/react-vanilla-components/src/components/Table/TableHeader.tsx b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx new file mode 100644 index 0000000..d671d3e --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx @@ -0,0 +1,73 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React, { useContext } from 'react'; +import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; + +type TableHeaderProps = { + id: string; + visible?: boolean; + enabled?: boolean; + items?: any[]; + enableSorting?: boolean; +}; + +const TableHeader = (props: TableHeaderProps) => { + const { id, visible, enabled, items = [], enableSorting } = props; + // @ts-ignore + const { mappings } = useContext(FormContext); + + return ( + + {items.map((cell: any) => { + const Comp = getRenderer(cell, mappings); + const content = Comp ? : null; + return ( + + {enableSorting ? ( +
+
{content}
+
+ ) : ( + content + )} + + ); + })} + + ); +}; + +export default TableHeader; diff --git a/packages/react-vanilla-components/src/components/Table/TableRow.tsx b/packages/react-vanilla-components/src/components/Table/TableRow.tsx new file mode 100644 index 0000000..e64245e --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -0,0 +1,76 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React, { useContext } from 'react'; +import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; + +type TableRowProps = { + id: string; + visible?: boolean; + enabled?: boolean; + readOnly?: boolean; + repeatable?: boolean; + items?: any[]; +}; + +const TableRow = (props: TableRowProps) => { + const { id, visible, enabled, readOnly, repeatable, items = [] } = props; + // @ts-ignore + const { mappings } = useContext(FormContext); + + return ( + + {items.map((cell: any, i: number) => { + const Comp = getRenderer(cell, mappings); + const isLast = i === items.length - 1; + const tdClass = `cmp-adaptiveform-tablecell${repeatable && isLast ? ' cmp-adaptiveform-tablecell--with-row-controls' : ''}`; + return ( + + {Comp ? : null} + {repeatable && isLast && ( +
+
+ )} + + ); + })} + + ); +}; + +export default TableRow; diff --git a/packages/react-vanilla-components/src/components/Table/index.ts b/packages/react-vanilla-components/src/components/Table/index.ts new file mode 100644 index 0000000..b959092 --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/index.ts @@ -0,0 +1,3 @@ +export { default as Table } from './Table'; +export { default as TableHeader } from './TableHeader'; +export { default as TableRow } from './TableRow'; diff --git a/packages/react-vanilla-components/src/index.ts b/packages/react-vanilla-components/src/index.ts index dbbaa2a..0b8d22e 100644 --- a/packages/react-vanilla-components/src/index.ts +++ b/packages/react-vanilla-components/src/index.ts @@ -31,6 +31,9 @@ import mappings from './utils/mappings'; import ReCaptcha from './components/ReCaptcha'; import Image from './components/Image'; import Scribble from './components/Scribble'; +import Table from './components/Table/Table'; +import TableHeader from './components/Table/TableHeader'; +import TableRow from './components/Table/TableRow'; export * from './utils/withRuleEngine'; export * from './utils/type'; @@ -60,5 +63,8 @@ export { ReCaptcha, Image, Scribble, - Form + Form, + Table, + TableHeader, + TableRow }; diff --git a/packages/react-vanilla-components/src/utils/mappings.ts b/packages/react-vanilla-components/src/utils/mappings.ts index 90b887a..d08b8c1 100644 --- a/packages/react-vanilla-components/src/utils/mappings.ts +++ b/packages/react-vanilla-components/src/utils/mappings.ts @@ -39,6 +39,9 @@ import ReCaptcha from '../components/ReCaptcha'; import HCaptcha from '../components/HCaptcha'; import Image from '../components/Image'; import Scribble from '../components/Scribble'; +import Table from '../components/Table/Table'; +import TableHeader from '../components/Table/TableHeader'; +import TableRow from '../components/Table/TableRow'; const mappings = { 'text-input': TextField, @@ -67,7 +70,10 @@ const mappings = { captcha: ReCaptcha, image: Image, signature: Scribble, - form: Form + form: Form, + table: Table, + 'table-header': TableHeader, + 'table-row': TableRow }; export default mappings; \ No newline at end of file From b8e6060650d44f7666a05e0202067e17eb5eee85 Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Sat, 15 Aug 2026 23:10:24 +0530 Subject: [PATCH 2/6] handled complex properties like colspan, colwidth and sorting behaviour --- .../src/components/Table/Table.tsx | 103 ++++++++++++++++-- .../src/components/Table/TableHeader.tsx | 28 ++++- .../src/components/Table/TableRow.tsx | 55 +++++++--- 3 files changed, 156 insertions(+), 30 deletions(-) diff --git a/packages/react-vanilla-components/src/components/Table/Table.tsx b/packages/react-vanilla-components/src/components/Table/Table.tsx index ed03480..f10c322 100644 --- a/packages/react-vanilla-components/src/components/Table/Table.tsx +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -18,18 +18,70 @@ // ****************************************************************************** import React from 'react'; +import { FormContext } from '@aemforms/af-react-renderer'; import { withRuleEnginePanel } from '../../utils/withRuleEngine'; import { PROPS_PANEL } from '../../utils/type'; -import LabelWithDescription from '../common/LabelWithDescription'; import TableHeader from './TableHeader'; import TableRow from './TableRow'; +type SortDirection = 'asc' | 'desc' | null; + +type SortState = { + colIndex: number; + direction: SortDirection; +}; + const Table = (props: PROPS_PANEL) => { const { items, id, visible, enabled, label, appliedCssClassNames } = props; const enableSorting = (props as any).enableSorting as boolean; + const columnWidth = ((props as any).columnWidth + || (props as any).properties?.['fd:dor']?.columnWidth) as string | undefined; + + // @ts-ignore + const { form } = React.useContext(FormContext); const headerItems = items.filter((item: any) => item[':type'] === 'table-header'); - const rowItems = items.filter((item: any) => item[':type'] === 'table-row'); + const rawRowItems = items.filter((item: any) => item[':type'] === 'table-row'); + + const [sortState, setSortState] = React.useState({ colIndex: -1, direction: null }); + + const handleSort = React.useCallback((colIndex: number) => { + setSortState(prev => { + if (prev.colIndex !== colIndex) return { colIndex, direction: 'asc' }; + return { colIndex, direction: prev.direction === 'asc' ? 'desc' : 'asc' }; + }); + }, []); + + const getLiveCellValue = React.useCallback((rowId: string, colIndex: number): string => { + const rowEl = form?.getElement(rowId); + if (!rowEl) return ''; + const rowState = rowEl.getState(); + const cell = rowState.items?.[colIndex]; + if (!cell) return ''; + const val = cell.value ?? cell.default ?? ''; + return String(val).toLowerCase(); + }, [form]); + + const rowItems = React.useMemo(() => { + if (!sortState.direction || sortState.colIndex < 0) return rawRowItems; + return [...rawRowItems].sort((a: any, b: any) => { + const av = getLiveCellValue(a.id, sortState.colIndex); + const bv = getLiveCellValue(b.id, sortState.colIndex); + const cmp = av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }); + return sortState.direction === 'asc' ? cmp : -cmp; + }); + }, [rawRowItems, sortState, getLiveCellValue]); + + // Parse "1,2,1" into percentage widths: total=4, cols=[25%, 50%, 25%] + const colWidths: string[] = React.useMemo(() => { + if (!columnWidth) return []; + const parts = columnWidth.split(',').map((s: string) => Number(s.trim())).filter(Boolean); + if (!parts.length) return []; + const total = parts.reduce((a: number, b: number) => a + b, 0); + return parts.map((w: number) => `${((w / total) * 100).toFixed(2)}%`); + }, [columnWidth]); + + const tableStyle = columnWidth ? { tableLayout: 'fixed' as const, width: '100%' } : undefined; return (
{ data-cmp-enabled={enabled} data-cmp-sorting-enabled={enableSorting ? 'true' : 'false'} > - + {label?.visible !== false && ( +
+ {label?.value} +
+ )} +
+ {props.tooltip && ( +
+ {props.tooltip} +
+ )} +
+ {props.description && ( +
+ {props.description} +
+ )} + {colWidths.length > 0 && ( + + {colWidths.map((w, i) => ( + + ))} + + )} {headerItems.map((item: any) => ( - + ))} diff --git a/packages/react-vanilla-components/src/components/Table/TableHeader.tsx b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx index d671d3e..fb7755e 100644 --- a/packages/react-vanilla-components/src/components/Table/TableHeader.tsx +++ b/packages/react-vanilla-components/src/components/Table/TableHeader.tsx @@ -20,16 +20,21 @@ import React, { useContext } from 'react'; import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; +type SortDirection = 'asc' | 'desc' | null; + type TableHeaderProps = { id: string; visible?: boolean; enabled?: boolean; items?: any[]; enableSorting?: boolean; + sortColIndex?: number; + sortDirection?: SortDirection; + onSort?: (colIndex: number) => void; }; const TableHeader = (props: TableHeaderProps) => { - const { id, visible, enabled, items = [], enableSorting } = props; + const { id, visible, enabled, items = [], enableSorting, sortColIndex = -1, sortDirection = null, onSort } = props; // @ts-ignore const { mappings } = useContext(FormContext); @@ -41,23 +46,36 @@ const TableHeader = (props: TableHeaderProps) => { data-cmp-visible={visible} data-cmp-enabled={enabled} > - {items.map((cell: any) => { + {items.map((cell: any, index: number) => { const Comp = getRenderer(cell, mappings); const content = Comp ? : null; + const colspan = cell.properties?.colspan || cell.colspan; + const disableSorting = cell.properties?.disableSorting; + const showSort = enableSorting && !disableSorting; + const isActive = sortColIndex === index; + const sortDir: SortDirection = isActive ? sortDirection : null; + return ( { {items.map((cell: any, i: number) => { const Comp = getRenderer(cell, mappings); const isLast = i === items.length - 1; + const colspan = cell.properties?.colspan || cell.colspan; const tdClass = `cmp-adaptiveform-tablecell${repeatable && isLast ? ' cmp-adaptiveform-tablecell--with-row-controls' : ''}`; + return ( - From 613b4c91f2e1a7085bef5658371960dfb8f05d34 Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Mon, 24 Aug 2026 14:31:36 +0530 Subject: [PATCH 3/6] fix(Table): implement repeatable table row via RepeatableTableRow component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit af-core wraps repeatable panels in a generated array-type container with :type 'panel' (not 'table-row'), causing the row filter to silently drop all repeatable rows. Fix the filter to also match item.type === 'array' and route those to a new RepeatableTableRow component that mirrors the Accordion/RepeatableItem pattern — gets live instances via form.getElement, renders one TableRow per instance, and dispatches AddItem/RemoveItem on add/remove button clicks. TableRow is simplified to accept onAdd/onRemove/ showAdd/showRemove props instead of managing form state internally. Co-Authored-By: Claude Sonnet 4.6 --- .../components/Table/RepeatableTableRow.tsx | 51 +++++++++++++++++++ .../src/components/Table/Table.tsx | 15 ++++-- .../src/components/Table/TableRow.tsx | 28 ++++------ .../src/components/Table/index.ts | 1 + 4 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx diff --git a/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx b/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx new file mode 100644 index 0000000..2fe212f --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx @@ -0,0 +1,51 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React, { useContext } from 'react'; +import { AddItem, RemoveItem } from '@aemforms/af-core'; +import { FormContext } from '@aemforms/af-react-renderer'; +import { withRuleEnginePanel } from '../../utils/withRuleEngine'; +import { PROPS_PANEL } from '../../utils/type'; +import TableRow from './TableRow'; + +const RepeatableTableRow = (props: PROPS_PANEL) => { + // @ts-ignore + const { form } = useContext(FormContext); + const element = form.getElement(props.id); + const instances = element.getState().items; + + const showAdd = instances.length !== props.maxItems; + const showRemove = instances.length !== props.minItems; + + const handleAdd = () => element.dispatch(new AddItem()); + const handleRemove = () => element.dispatch(new RemoveItem()); + + return instances.map((instance: any) => ( + + )); +}; + +export default withRuleEnginePanel(RepeatableTableRow); diff --git a/packages/react-vanilla-components/src/components/Table/Table.tsx b/packages/react-vanilla-components/src/components/Table/Table.tsx index f10c322..a938200 100644 --- a/packages/react-vanilla-components/src/components/Table/Table.tsx +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -23,6 +23,7 @@ import { withRuleEnginePanel } from '../../utils/withRuleEngine'; import { PROPS_PANEL } from '../../utils/type'; import TableHeader from './TableHeader'; import TableRow from './TableRow'; +import RepeatableTableRow from './RepeatableTableRow'; type SortDirection = 'asc' | 'desc' | null; @@ -41,7 +42,11 @@ const Table = (props: PROPS_PANEL) => { const { form } = React.useContext(FormContext); const headerItems = items.filter((item: any) => item[':type'] === 'table-header'); - const rawRowItems = items.filter((item: any) => item[':type'] === 'table-row'); + // Static rows have :type 'table-row'; repeatable rows are wrapped by af-core into + // a generated array-type panel with :type 'panel' and type 'array'. + const rawRowItems = items.filter((item: any) => + item[':type'] === 'table-row' || item.type === 'array' + ); const [sortState, setSortState] = React.useState({ colIndex: -1, direction: null }); @@ -140,9 +145,11 @@ const Table = (props: PROPS_PANEL) => { ))} - {rowItems.map((item: any) => ( - - ))} + {rowItems.map((item: any) => + item.type === 'array' + ? + : + )}
- {enableSorting ? ( + {showSort ? (
-
{content}
+ {content}
) : ( diff --git a/packages/react-vanilla-components/src/components/Table/TableRow.tsx b/packages/react-vanilla-components/src/components/Table/TableRow.tsx index e64245e..17aedef 100644 --- a/packages/react-vanilla-components/src/components/Table/TableRow.tsx +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -18,6 +18,7 @@ // ****************************************************************************** import React, { useContext } from 'react'; +import { AddItem, RemoveItem } from '@aemforms/af-core'; import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; type TableRowProps = { @@ -26,13 +27,23 @@ type TableRowProps = { enabled?: boolean; readOnly?: boolean; repeatable?: boolean; + minItems?: number; + maxItems?: number; items?: any[]; }; const TableRow = (props: TableRowProps) => { - const { id, visible, enabled, readOnly, repeatable, items = [] } = props; + const { id, visible, enabled, readOnly, repeatable, minItems, maxItems, items = [] } = props; // @ts-ignore - const { mappings } = useContext(FormContext); + const { mappings, form } = useContext(FormContext); + + const element = repeatable ? form?.getElement(id) : null; + const instanceCount = element ? element.getState().items?.length ?? 1 : 1; + const showAdd = repeatable && (maxItems === undefined || instanceCount < maxItems); + const showRemove = repeatable && (minItems === undefined || instanceCount > minItems); + + const handleAdd = () => element?.dispatch(new AddItem()); + const handleRemove = () => element?.dispatch(new RemoveItem()); return (
+ {Comp ? : null} {repeatable && isLast && (
-
)}
diff --git a/packages/react-vanilla-components/src/components/Table/TableRow.tsx b/packages/react-vanilla-components/src/components/Table/TableRow.tsx index 17aedef..325e4f4 100644 --- a/packages/react-vanilla-components/src/components/Table/TableRow.tsx +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -18,7 +18,6 @@ // ****************************************************************************** import React, { useContext } from 'react'; -import { AddItem, RemoveItem } from '@aemforms/af-core'; import { FormContext, getRenderer } from '@aemforms/af-react-renderer'; type TableRowProps = { @@ -26,24 +25,19 @@ type TableRowProps = { visible?: boolean; enabled?: boolean; readOnly?: boolean; - repeatable?: boolean; - minItems?: number; - maxItems?: number; items?: any[]; + onAdd?: () => void; + onRemove?: () => void; + showAdd?: boolean; + showRemove?: boolean; }; const TableRow = (props: TableRowProps) => { - const { id, visible, enabled, readOnly, repeatable, minItems, maxItems, items = [] } = props; + const { id, visible, enabled, readOnly, items = [], onAdd, onRemove, showAdd, showRemove } = props; // @ts-ignore - const { mappings, form } = useContext(FormContext); + const { mappings } = useContext(FormContext); - const element = repeatable ? form?.getElement(id) : null; - const instanceCount = element ? element.getState().items?.length ?? 1 : 1; - const showAdd = repeatable && (maxItems === undefined || instanceCount < maxItems); - const showRemove = repeatable && (minItems === undefined || instanceCount > minItems); - - const handleAdd = () => element?.dispatch(new AddItem()); - const handleRemove = () => element?.dispatch(new RemoveItem()); + const isRepeatable = !!(onAdd || onRemove); return ( { const Comp = getRenderer(cell, mappings); const isLast = i === items.length - 1; const colspan = cell.properties?.colspan || cell.colspan; - const tdClass = `cmp-adaptiveform-tablecell${repeatable && isLast ? ' cmp-adaptiveform-tablecell--with-row-controls' : ''}`; + const tdClass = `cmp-adaptiveform-tablecell${isRepeatable && isLast ? ' cmp-adaptiveform-tablecell--with-row-controls' : ''}`; return ( { colSpan={colspan ? Number(colspan) : undefined} > {Comp ? : null} - {repeatable && isLast && ( + {isRepeatable && isLast && (
{showAdd && (
diff --git a/packages/react-vanilla-components/src/components/Table/index.ts b/packages/react-vanilla-components/src/components/Table/index.ts index b959092..4e40eee 100644 --- a/packages/react-vanilla-components/src/components/Table/index.ts +++ b/packages/react-vanilla-components/src/components/Table/index.ts @@ -1,3 +1,4 @@ export { default as Table } from './Table'; export { default as TableHeader } from './TableHeader'; export { default as TableRow } from './TableRow'; +export { default as RepeatableTableRow } from './RepeatableTableRow'; From 27b68c69038948a30f523b383af3f0891d1125fe Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Mon, 24 Aug 2026 16:19:42 +0530 Subject: [PATCH 4/6] fix(Table): use index-based AddItem/RemoveItem for repeatable rows Each row now dispatches AddItem(index + 1) to insert after itself and RemoveItem(index) to remove itself, instead of always appending/removing from the end. Co-Authored-By: Claude Sonnet 4.6 --- .../components/Table/RepeatableTableRow.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx b/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx index 2fe212f..5f40f2e 100644 --- a/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx +++ b/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx @@ -33,19 +33,21 @@ const RepeatableTableRow = (props: PROPS_PANEL) => { const showAdd = instances.length !== props.maxItems; const showRemove = instances.length !== props.minItems; - const handleAdd = () => element.dispatch(new AddItem()); - const handleRemove = () => element.dispatch(new RemoveItem()); + return instances.map((instance: any, index: number) => { + const handleAdd = () => element.dispatch(new AddItem(index + 1)); + const handleRemove = () => element.dispatch(new RemoveItem(index)); - return instances.map((instance: any) => ( - - )); + return ( + + ); + }); }; export default withRuleEnginePanel(RepeatableTableRow); From eb379deff1393eb3a85ac132cab80614f735ce87 Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Wed, 2 Sep 2026 14:51:36 +0530 Subject: [PATCH 5/6] added the mobileLayoutChanges --- .../__tests__/components/Table.test.tsx | 74 +++++- .../components/Table/RepeatableTableRow.tsx | 3 +- .../src/components/Table/Table.tsx | 21 +- .../src/components/Table/TableMobileBar.tsx | 229 ++++++++++++++++++ .../src/components/Table/TableRow.tsx | 4 +- 5 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 packages/react-vanilla-components/src/components/Table/TableMobileBar.tsx diff --git a/packages/react-vanilla-components/__tests__/components/Table.test.tsx b/packages/react-vanilla-components/__tests__/components/Table.test.tsx index f64944a..fd5a859 100644 --- a/packages/react-vanilla-components/__tests__/components/Table.test.tsx +++ b/packages/react-vanilla-components/__tests__/components/Table.test.tsx @@ -9,7 +9,7 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import Table from '../../src/components/Table/Table'; import { createForm, Provider, renderComponent } from '../utils'; import '@testing-library/jest-dom/extend-expect'; @@ -201,4 +201,76 @@ describe('Table', () => { expect(container.querySelector('thead')).not.toBeNull(); expect(container.querySelector('tbody')).not.toBeNull(); }); + + test('data-label on matches its column header text', () => { + const { renderResponse } = helper(tableField); + const cells = renderResponse.container.querySelectorAll('tbody td'); + expect(cells[0].getAttribute('data-label')).toBe('Column 1'); + expect(cells[1].getAttribute('data-label')).toBe('Column 2'); + }); + + test('mobile bar renders with Sort and Filter buttons', () => { + const { renderResponse } = helper(tableField); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort'); + const filterBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--filter'); + expect(sortBtn).not.toBeNull(); + expect(filterBtn).not.toBeNull(); + }); + + test('mobile Sort button disabled when enableSorting is false', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: false }); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort'); + expect(sortBtn).toBeDisabled(); + }); + + test('mobile Sort button enabled when enableSorting is true', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort'); + expect(sortBtn).not.toBeDisabled(); + }); + + test('opening sort sheet lists one option per column', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort') as HTMLButtonElement; + fireEvent.click(sortBtn); + const options = renderResponse.container.querySelectorAll('.cmp-adaptiveform-table__sort-option'); + expect(options.length).toBe(2); + }); + + test('selecting a sort option closes the sheet and marks it selected', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort') as HTMLButtonElement; + fireEvent.click(sortBtn); + const firstOption = renderResponse.container.querySelector('[data-col-index="0"]') as HTMLElement; + fireEvent.click(firstOption); + expect(renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim')).toBeNull(); + }); + + test('opening filter sheet and selecting Ascending closes the sheet', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const filterBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--filter') as HTMLButtonElement; + fireEvent.click(filterBtn); + const ascOption = renderResponse.container.querySelector('[data-dir="asc"]') as HTMLElement; + expect(ascOption).not.toBeNull(); + fireEvent.click(ascOption); + expect(renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim')).toBeNull(); + }); + + test('Escape key closes an open sheet', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort') as HTMLButtonElement; + fireEvent.click(sortBtn); + expect(renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim')).not.toBeNull(); + fireEvent.keyDown(renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim') as HTMLElement, { key: 'Escape' }); + expect(renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim')).toBeNull(); + }); + + test('clicking scrim background closes the sheet', () => { + const { renderResponse } = helper({ ...tableField, enableSorting: true }); + const sortBtn = renderResponse.container.querySelector('.cmp-adaptiveform-table__mobile-bar-btn--sort') as HTMLButtonElement; + fireEvent.click(sortBtn); + const scrim = renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim') as HTMLElement; + fireEvent.click(scrim); + expect(renderResponse.container.querySelector('.cmp-adaptiveform-table__sort-scrim')).toBeNull(); + }); }); diff --git a/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx b/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx index 5f40f2e..7fe1b00 100644 --- a/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx +++ b/packages/react-vanilla-components/src/components/Table/RepeatableTableRow.tsx @@ -24,7 +24,7 @@ import { withRuleEnginePanel } from '../../utils/withRuleEngine'; import { PROPS_PANEL } from '../../utils/type'; import TableRow from './TableRow'; -const RepeatableTableRow = (props: PROPS_PANEL) => { +const RepeatableTableRow = (props: PROPS_PANEL & { headerLabels?: string[] }) => { // @ts-ignore const { form } = useContext(FormContext); const element = form.getElement(props.id); @@ -45,6 +45,7 @@ const RepeatableTableRow = (props: PROPS_PANEL) => { onRemove={handleRemove} showAdd={showAdd} showRemove={showRemove} + headerLabels={props.headerLabels} /> ); }); diff --git a/packages/react-vanilla-components/src/components/Table/Table.tsx b/packages/react-vanilla-components/src/components/Table/Table.tsx index a938200..7934476 100644 --- a/packages/react-vanilla-components/src/components/Table/Table.tsx +++ b/packages/react-vanilla-components/src/components/Table/Table.tsx @@ -24,6 +24,7 @@ import { PROPS_PANEL } from '../../utils/type'; import TableHeader from './TableHeader'; import TableRow from './TableRow'; import RepeatableTableRow from './RepeatableTableRow'; +import TableMobileBar from './TableMobileBar'; type SortDirection = 'asc' | 'desc' | null; @@ -50,13 +51,21 @@ const Table = (props: PROPS_PANEL) => { const [sortState, setSortState] = React.useState({ colIndex: -1, direction: null }); - const handleSort = React.useCallback((colIndex: number) => { + const handleSort = React.useCallback((colIndex: number, forceDir?: SortDirection) => { + if (forceDir) { + setSortState({ colIndex, direction: forceDir }); + return; + } setSortState(prev => { if (prev.colIndex !== colIndex) return { colIndex, direction: 'asc' }; return { colIndex, direction: prev.direction === 'asc' ? 'desc' : 'asc' }; }); }, []); + const columnLabels: string[] = React.useMemo(() => ( + ((headerItems[0] as any)?.items || []).map((cell: any) => cell.label?.value ?? cell.value ?? '') + ), [headerItems]); + const getLiveCellValue = React.useCallback((rowId: string, colIndex: number): string => { const rowEl = form?.getElement(rowId); if (!rowEl) return ''; @@ -120,6 +129,12 @@ const Table = (props: PROPS_PANEL) => { {props.description} )} + { {rowItems.map((item: any) => item.type === 'array' - ? - : + ? + : )}
diff --git a/packages/react-vanilla-components/src/components/Table/TableMobileBar.tsx b/packages/react-vanilla-components/src/components/Table/TableMobileBar.tsx new file mode 100644 index 0000000..269198a --- /dev/null +++ b/packages/react-vanilla-components/src/components/Table/TableMobileBar.tsx @@ -0,0 +1,229 @@ +// ******************************************************************************* +// * Copyright 2026 Adobe +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// * +// * BEM markup follows AEM core form components guidelines. +// * LINK- https://github.com/adobe/aem-core-forms-components +// ****************************************************************************** + +import React from 'react'; +import { useFormIntl } from '@aemforms/af-react-renderer'; + +type SortDirection = 'asc' | 'desc' | null; + +type SortState = { + colIndex: number; + direction: SortDirection; +}; + +type TableMobileBarProps = { + columnLabels: string[]; + enableSorting?: boolean; + sortState: SortState; + onSort: (colIndex: number, forceDir?: SortDirection) => void; +}; + +type OpenSheet = 'sort' | 'filter' | null; + +const TableMobileBar = (props: TableMobileBarProps) => { + const { columnLabels, enableSorting, sortState, onSort } = props; + const i18n = useFormIntl(); + + const [openSheet, setOpenSheet] = React.useState(null); + const sheetRef = React.useRef(null); + const sortButtonRef = React.useRef(null); + const filterButtonRef = React.useRef(null); + const triggerRef = React.useRef(null); + + const t = React.useCallback((key: string, defaultMessage: string) => ( + i18n.formatMessage({ id: `table.mobile.${key}`, defaultMessage }) + ), [i18n]); + + const closeSheet = React.useCallback(() => { + setOpenSheet(null); + document.body.style.overflow = ''; + triggerRef.current?.focus(); + triggerRef.current = null; + }, []); + + const openSort = () => { + triggerRef.current = sortButtonRef.current; + setOpenSheet('sort'); + document.body.style.overflow = 'hidden'; + }; + + const openFilter = () => { + triggerRef.current = filterButtonRef.current; + setOpenSheet('filter'); + document.body.style.overflow = 'hidden'; + }; + + React.useEffect(() => { + if (!openSheet) return; + const firstOption = sheetRef.current?.querySelector('[tabindex="0"]'); + firstOption?.focus(); + }, [openSheet]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeSheet(); + return; + } + if (e.key === 'Tab') { + const focusable = sheetRef.current?.querySelectorAll('[tabindex="0"]'); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }; + + const handleSortOptionActivate = (colIndex: number) => { + onSort(colIndex); + closeSheet(); + }; + + const handleFilterOptionActivate = (dir: SortDirection) => { + const colIndex = sortState.colIndex >= 0 ? sortState.colIndex : 0; + onSort(colIndex, dir); + closeSheet(); + }; + + const activateOnKey = (e: React.KeyboardEvent, action: () => void) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + action(); + } + }; + + return ( + <> +
+ +
+ +
+ + {openSheet === 'sort' && ( +
{ if (e.target === e.currentTarget) closeSheet(); }} + onKeyDown={handleKeyDown} + > +
+ +
+ )} + + {openSheet === 'filter' && ( +
{ if (e.target === e.currentTarget) closeSheet(); }} + onKeyDown={handleKeyDown} + > +
+ +
+ )} + + ); +}; + +export default TableMobileBar; diff --git a/packages/react-vanilla-components/src/components/Table/TableRow.tsx b/packages/react-vanilla-components/src/components/Table/TableRow.tsx index 325e4f4..e861a2f 100644 --- a/packages/react-vanilla-components/src/components/Table/TableRow.tsx +++ b/packages/react-vanilla-components/src/components/Table/TableRow.tsx @@ -30,10 +30,11 @@ type TableRowProps = { onRemove?: () => void; showAdd?: boolean; showRemove?: boolean; + headerLabels?: string[]; }; const TableRow = (props: TableRowProps) => { - const { id, visible, enabled, readOnly, items = [], onAdd, onRemove, showAdd, showRemove } = props; + const { id, visible, enabled, readOnly, items = [], onAdd, onRemove, showAdd, showRemove, headerLabels } = props; // @ts-ignore const { mappings } = useContext(FormContext); @@ -59,6 +60,7 @@ const TableRow = (props: TableRowProps) => { key={cell.id} className={tdClass} colSpan={colspan ? Number(colspan) : undefined} + data-label={headerLabels?.[i]} > {Comp ? : null} {isRepeatable && isLast && ( From c9c475e0b9d21297c34ff605f5455b8982090e85 Mon Sep 17 00:00:00 2001 From: Armaan Gupta Date: Wed, 2 Sep 2026 17:09:33 +0530 Subject: [PATCH 6/6] updated README.md to add the table component --- packages/react-vanilla-components/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-vanilla-components/README.md b/packages/react-vanilla-components/README.md index 6a832f1..41fb10b 100644 --- a/packages/react-vanilla-components/README.md +++ b/packages/react-vanilla-components/README.md @@ -28,6 +28,7 @@ The document provides detailed information on React Vanilla Components(HTML5 bas - ReCAPTCHA - Scribble (Signature) - Switch + - Table - Telephone Input - Text Area Field - Text Field