Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/language/texts/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export function en() {
'One of the rows is incorrectly filled out. This has to bee fixed before the schema can be submitted.',
'group.row_popover_delete_message': 'Are you sure you want to delete this row?',
'group.row_popover_delete_button_confirm': 'Yes, delete the row',
'group.row_deleted_sr': 'Row deleted, {0} remaining',
'iframe_component.unsupported_browser_title': 'Your browser is unsupported',
'iframe_component.unsupported_browser':
'Your browser does not support iframes that use srcdoc. This may result in not being able to see all the content intended to be displayed here. We recommend trying a different browser.',
Expand Down
1 change: 1 addition & 0 deletions src/language/texts/nb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export function nb() {
'group.row_error': 'En av radene er ikke fylt ut riktig, dette må fikses før skjema kan sendes inn',
'group.row_popover_delete_message': 'Er du sikker på at du vil slette denne raden?',
'group.row_popover_delete_button_confirm': 'Ja, slett raden',
'group.row_deleted_sr': 'Rad slettet, {0} gjenstår',
'iframe_component.unsupported_browser_title': 'Nettleseren din støttes ikke',
'iframe_component.unsupported_browser':
'Nettleseren du bruker støtter ikke iframes som benytter seg av srcdoc. Dette kan føre til at du ikke ser all innholdet som er ment å vises her. Vi anbefaler deg å prøve en annen nettleser.',
Expand Down
1 change: 1 addition & 0 deletions src/language/texts/nn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export function nn() {
'group.row_error': 'Ei av radene er ikkje fylt ut riktig. Dette må bli retta før skjema kan sendast inn.',
'group.row_popover_delete_message': 'Er du sikker på at du vil sletta denne rada?',
'group.row_popover_delete_button_confirm': 'Ja, slett rada',
'group.row_deleted_sr': 'Rad sletta, {0} står att',
'iframe_component.unsupported_browser_title': 'Nettlesaren din støttas ikkje',
'iframe_component.unsupported_browser':
'Nettlesaren di støttar ikkje iframes som brukar srcdoc. Dette kan føre til at du ikkje ser all innhaldet som er meint å visast her. Vi anbefalar deg å prøve ein annan nettlesar.',
Expand Down
122 changes: 120 additions & 2 deletions src/layout/RepeatingGroup/Container/RepeatingGroupContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
useRepeatingGroupRowState,
useRepeatingGroupSelector,
} from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext';
import { RepeatingGroupsFocusProvider } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext';
import { mockMediaQuery } from 'src/test/mockMediaQuery';
import { renderWithInstanceAndLayout } from 'src/test/renderWithProviders';
import type { ILayout } from 'src/layout/layout';
Expand Down Expand Up @@ -100,8 +101,10 @@ async function render({ container, numRows = 3, validationIssues = [] }: IRender
return await renderWithInstanceAndLayout({
renderer: (
<RepeatingGroupProvider baseComponentId={group.id}>
<LeakEditIndex />
<RepeatingGroupContainer />
<RepeatingGroupsFocusProvider>
<LeakEditIndex />
<RepeatingGroupContainer />
</RepeatingGroupsFocusProvider>
</RepeatingGroupProvider>
),
queries: {
Expand Down Expand Up @@ -209,6 +212,91 @@ describe('RepeatingGroupContainer', () => {
expect(within(editContainer).getByText('Title4')).toBeInTheDocument();
});

it('moves focus to the top of the edit container when navigating between multiPage pages', async () => {
await render({
container: {
edit: {
...mockContainer.edit,
multiPage: true,
},
children: ['0:field1', '0:field2', '1:field3', '1:field4'],
},
});

// Open the first row for editing (shows page 0 with field1/field2)
await userEvent.click(screen.getAllByRole('button', { name: /Rediger/i })[0]);
const editContainer = screen.getByTestId('group-edit-container');

// Navigate forward: focus should move to the first field on the new page, not stay on the button.
await userEvent.click(within(editContainer).getByRole('button', { name: /Neste/i }));
await waitFor(() => expect(document.activeElement).toHaveAccessibleName(/Title3/));

// Navigate back: focus should move to the first field on the previous page.
await userEvent.click(within(editContainer).getByRole('button', { name: /Tilbake/i }));
await waitFor(() => expect(document.activeElement).toHaveAccessibleName(/Title1/));
});

it("moves focus to the same row's edit button after saving and closing", async () => {
await render({ numRows: 3 });

// Open the second row (index 1) for editing
await userEvent.click(screen.getAllByRole('button', { name: /Rediger/i })[1]);

const editContainer = screen.getByTestId('group-edit-container');
await userEvent.click(within(editContainer).getByRole('button', { name: /Lagre og lukk/i }));

// Focus returns to the edit button of the same row (index 1), not the next row.
await waitFor(() => {
expect(document.activeElement).toHaveAccessibleName(/Rediger/);
expect(document.activeElement?.closest('[data-row-num]')).toHaveAttribute('data-row-num', '1');
});
});

it('moves focus to the first field of the next edit container after save and open next', async () => {
await render({
container: {
edit: {
...mockContainer.edit,
saveAndNextButton: true,
},
},
numRows: 3,
});

// Open the first row, then save and open next
await userEvent.click(screen.getAllByRole('button', { name: /Rediger/i })[0]);
const editContainer = screen.getByTestId('group-edit-container');
await userEvent.click(within(editContainer).getByRole('button', { name: /Lagre og åpne neste/i }));

// Focus moves to the first field in the next row's edit container, not its edit button.
await waitFor(() => expect(document.activeElement).toHaveAccessibleName(/Title1/));
});

it('does not move focus to the edit button when validation blocks saving', async () => {
await render({
container: {
validateOnSaveRow: ['All'],
},
validationIssues: [
{
customTextKey: 'Feltet er feil',
field: 'Group[0].prop1',
dataElementId: defaultMockDataElementId,
severity: BackendValidationSeverity.Error,
source: 'custom',
} as BackendValidationIssue,
],
});

await userEvent.click(screen.getAllByRole('button', { name: /Rediger/i })[0]);
await userEvent.click(screen.getAllByRole('button', { name: /Lagre og lukk/i })[1]);

await waitFor(() => expect(screen.getByText(/feltet er feil/i)).toBeInTheDocument());
// The row stays open and focus must remain inside the edit container, not jump to the edit button.
const editContainer = screen.getByTestId('group-edit-container');
expect(editContainer.contains(document.activeElement)).toBe(true);
});

it('should trigger validate when saving if validateOnSaveRow is set', async () => {
await render({
container: {
Expand Down Expand Up @@ -332,6 +420,36 @@ describe('RepeatingGroupContainer', () => {
expect(closeButtons).toHaveLength(1);
});

it('should notify screen readers via a live region when a row is deleted', async () => {
await render({ numRows: 3 });

const status = screen.getByRole('status');
expect(status).toBeEmptyDOMElement();

await userEvent.click(screen.getAllByRole('button', { name: /Slett/i })[0]);
await waitFor(() => expect(status).toHaveTextContent('Rad slettet, 2 gjenstår'));

// The message must change on consecutive deletions; screen readers do not reliably re-announce
// identical live-region text, so a repeated identical message would go unread.
await userEvent.click(screen.getAllByRole('button', { name: /Slett/i })[0]);
await waitFor(() => expect(status).toHaveTextContent('Rad slettet, 1 gjenstår'));
});

it('should move focus to the previous row after deletion instead of letting it fall back to the page', async () => {
await render({ numRows: 3 });

// Delete the second row; focus should move to the first (previous) row.
await userEvent.click(screen.getAllByRole('button', { name: /Slett/i })[1]);

// Focus must land on an actionable element in the remaining previous row, not on document.body
// (which would make the screen reader announce the page title and skip the deletion message).
await waitFor(() => {
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.tagName).toBe('BUTTON');
expect(document.activeElement?.closest('[data-row-num]')).toHaveAttribute('data-row-num', '0');
});
});

it('should display textResourceBindings.save_button as save button if present', async () => {
await render({
container: {
Expand Down
44 changes: 36 additions & 8 deletions src/layout/RepeatingGroup/Container/RepeatingGroupContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { useRepeatingGroupsFocusContext } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext';
import { RepeatingGroupTable } from 'src/layout/RepeatingGroup/Table/RepeatingGroupTable';
import { RepGroupHooks } from 'src/layout/RepeatingGroup/utils';
import utilClasses from 'src/styles/utils.module.css';
import { DataModelLocationProvider, useIndexedId } from 'src/utils/layout/DataModelLocation';
import { useIsHidden } from 'src/utils/layout/hidden';
import { useDataModelBindingsFor, useExternalItem } from 'src/utils/layout/hooks';
Expand Down Expand Up @@ -61,11 +62,44 @@ export const RepeatingGroupContainer = forwardRef((_, ref: React.ForwardedRef<HT
>
<AllComponentValidations baseComponentId={baseComponentId} />
</Flex>
<RowDeletionAnnouncement />
</Flex>
);
});
RepeatingGroupContainer.displayName = 'RepeatingGroupContainer';

function RowDeletionAnnouncement() {
const { langAsString } = useLanguage();
const deletedRowsCount = useRepeatingGroupSelector((state) => state.deletedRowsCount);
const { numVisibleRows } = useRepeatingGroupRowState();
const [message, setMessage] = React.useState('');

// Only announce when the count actually increases. Initializing the ref to the current count means a
// remount (e.g. the group being hidden then shown again) with a non-zero count announces nothing.
const lastAnnouncedCount = React.useRef(deletedRowsCount);

React.useEffect(() => {
if (deletedRowsCount <= lastAnnouncedCount.current) {
return;
}
lastAnnouncedCount.current = deletedRowsCount;
// Include the remaining row count so the message text differs between consecutive deletions.
// Screen readers do not reliably re-announce live-region text that is identical to the
// previous announcement.
setMessage(langAsString('group.row_deleted_sr', [numVisibleRows]));
}, [deletedRowsCount, numVisibleRows, langAsString]);

return (
<div
role='status'
aria-live='polite'
className={utilClasses.visuallyHidden}
>
{message}
</div>
);
}

function ModeOnlyTable() {
return (
<>
Expand Down Expand Up @@ -180,7 +214,7 @@ export const alignStyle = (align: ButtonPosition): React.CSSProperties => {

function AddButton() {
const { lang, langAsString } = useLanguage();
const { triggerFocus } = useRepeatingGroupsFocusContext();
const { triggerFocus, registerAddButton } = useRepeatingGroupsFocusContext();
const baseComponentId = useRepeatingGroupComponentId();
const addRow = RepGroupContext.useAddRow();
const { visibleRows } = useRepeatingGroupRowState();
Expand Down Expand Up @@ -218,6 +252,7 @@ function AddButton() {

return (
<Button
ref={registerAddButton}
textAlign={addButton?.textAlign}
fullWidth={fullWidth}
id={`add-button-${id}`}
Expand All @@ -227,13 +262,6 @@ function AddButton() {
const newRow = await addRow();
newRow.index !== undefined && triggerFocus(newRow.index);
}}
onKeyUp={async (event: React.KeyboardEvent<HTMLButtonElement>) => {
const allowedKeys = ['enter', ' ', 'spacebar'];
if (allowedKeys.includes(event.key.toLowerCase())) {
const newRow = await addRow();
newRow.index !== undefined && triggerFocus(newRow.index);
}
}}
variant='secondary'
disabled={currentlyAddingRow}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import {
useRepeatingGroupComponentId,
useRepeatingGroupRowState,
} from 'src/layout/RepeatingGroup/Providers/RepeatingGroupContext';
import { useRepeatingGroupsFocusContext } from 'src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext';
import {
useDeleteRowAndFocus,
useRepeatingGroupsFocusContext,
} from 'src/layout/RepeatingGroup/Providers/RepeatingGroupFocusContext';
import classes from 'src/layout/RepeatingGroup/RepeatingGroup.module.css';
import { RepGroupHooks } from 'src/layout/RepeatingGroup/utils';
import { useIndexedId } from 'src/utils/layout/DataModelLocation';
Expand Down Expand Up @@ -60,9 +63,9 @@ function RepeatingGroupsEditContainerInternal({
}): JSX.Element | null {
const baseComponentId = useRepeatingGroupComponentId();
const closeForEditing = RepGroupContext.useCloseForEditing();
const deleteRow = RepGroupContext.useDeleteRow();
const deleteRow = useDeleteRowAndFocus();
const openNextForEditing = RepGroupContext.useOpenNextForEditing();
const { visibleRows } = useRepeatingGroupRowState();
const { visibleRows, editableRows } = useRepeatingGroupRowState();
const childIds = RepGroupHooks.useChildIdsWithMultiPage(baseComponentId);

const editingRowIndex = visibleRows.find((r) => r.uuid === editId)?.index;
Expand All @@ -82,7 +85,7 @@ function RepeatingGroupsEditContainerInternal({
const textsForRow = rowWithExpressions?.textResourceBindings;
const editForRow = rowWithExpressions?.edit;
const { textResourceBindings, edit: editForGroup, tableColumns } = useItemWhenType(baseComponentId, 'RepeatingGroup');
const { refSetter } = useRepeatingGroupsFocusContext();
const { refSetter, focusEditContainer, focusEditButton } = useRepeatingGroupsFocusContext();
const texts = {
...textResourceBindings,
...textsForRow,
Expand Down Expand Up @@ -187,7 +190,14 @@ function RepeatingGroupsEditContainerInternal({
<Button
variant='secondary'
color='second'
onClick={() => prevMultiPage()}
onClick={() => {
prevMultiPage();
if (editingRowIndex !== undefined) {
// Wait for the new page to render, then move focus to the top of the edit
// container instead of leaving it on this navigation button.
requestAnimationFrame(() => focusEditContainer(editingRowIndex));
}
}}
>
<ChevronLeftIcon
fontSize='1rem'
Expand All @@ -202,7 +212,14 @@ function RepeatingGroupsEditContainerInternal({
<Button
variant='secondary'
color='second'
onClick={() => nextMultiPage()}
onClick={() => {
nextMultiPage();
if (editingRowIndex !== undefined) {
// Wait for the new page to render, then move focus to the top of the edit
// container instead of leaving it on this navigation button.
requestAnimationFrame(() => focusEditContainer(editingRowIndex));
}
}}
>
<Lang id={texts.multipage_next_button ? texts.multipage_next_button : 'general.next'} />
<ChevronRightIcon
Expand All @@ -224,7 +241,20 @@ function RepeatingGroupsEditContainerInternal({
<Flex item>
<Button
id={`next-button-grp-${id}`}
onClick={() => openNextForEditing()}
onClick={async () => {
// Capture the next editable row before opening it, then move focus to the top of
// its edit container (not the next row's edit button) once it has rendered.
const currentEditableIndex = editableRows.findIndex((r) => r.uuid === editId);
const nextEditableRow = editableRows[currentEditableIndex + 1];
const opened = await openNextForEditing();
if (opened) {
requestAnimationFrame(() =>
// If there is no next editable row, openNextForEditing closes this one
// instead; fall back to this row's edit button like save and close.
nextEditableRow ? focusEditContainer(nextEditableRow.index) : focusEditButton(row.index),
);
}
}}
variant='primary'
color='first'
>
Expand All @@ -236,7 +266,14 @@ function RepeatingGroupsEditContainerInternal({
<Flex item>
<Button
id={`save-button-${id}`}
onClick={() => closeForEditing({ index: row.index, uuid: row.uuid })}
onClick={async () => {
const closed = await closeForEditing({ index: row.index, uuid: row.uuid });
// Move focus back to this row's edit button rather than letting it fall to the
// next row (or the page body) when the edit container unmounts.
if (closed) {
requestAnimationFrame(() => focusEditButton(row.index));
}
}}
variant={saveAndNextButtonVisible ? 'secondary' : 'primary'}
color='first'
>
Expand Down
Loading
Loading