]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20721 Improve day picker accessiblity
authorMathieu Suen <mathieu.suen@sonarsource.com>
Thu, 2 Nov 2023 10:53:25 +0000 (11:53 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 2 Nov 2023 20:02:42 +0000 (20:02 +0000)
server/sonar-web/design-system/src/components/input/DatePickerCustomCalendarNavigation.tsx
server/sonar-web/design-system/src/components/input/__tests__/DatePicker-test.tsx
server/sonar-web/design-system/src/components/input/__tests__/DateRangePicker-test.tsx
server/sonar-web/design-system/src/helpers/testUtils.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 6ce279b7ae303d5f01870e96adeb7c850b73e5be..92ad29fae010a629661159893528c48a511bb436 100644 (file)
@@ -48,6 +48,16 @@ export function CustomCalendarNavigation(props: CaptionProps) {
 
   const intl = useIntl();
 
+  const formatChevronLabel = (date?: Date) => {
+    if (date === undefined) {
+      return intl.formatMessage({ id: 'disabled_' });
+    }
+    return `${intl.formatDate(date, { month: 'long', format: 'M' })} ${intl.formatDate(date, {
+      year: 'numeric',
+      format: 'y',
+    })}`;
+  };
+
   const baseDate = startOfMonth(displayMonth); // reference date
 
   const months = range(MONTHS_IN_A_YEAR).map((month) => {
@@ -74,8 +84,12 @@ export function CustomCalendarNavigation(props: CaptionProps) {
     <nav className="sw-flex sw-items-center sw-justify-between sw-py-1">
       <InteractiveIcon
         Icon={ChevronLeftIcon}
-        aria-label={intl.formatMessage({ id: 'previous_' })}
+        aria-label={intl.formatMessage(
+          { id: 'previous_month_x' },
+          { month: formatChevronLabel(previousMonth) },
+        )}
         className="sw-mr-2"
+        disabled={previousMonth === undefined}
         onClick={() => {
           if (previousMonth) {
             goToMonth(previousMonth);
@@ -116,8 +130,14 @@ export function CustomCalendarNavigation(props: CaptionProps) {
 
       <InteractiveIcon
         Icon={ChevronRightIcon}
-        aria-label={intl.formatMessage({ id: 'next_' })}
+        aria-label={intl.formatMessage(
+          { id: 'next_month_x' },
+          {
+            month: formatChevronLabel(nextMonth),
+          },
+        )}
         className="sw-ml-2"
+        disabled={nextMonth === undefined}
         onClick={() => {
           if (nextMonth) {
             goToMonth(nextMonth);
index dd68da9b97551a23433ba03e66a732eb60288599..010dbc2bccbfdbeb33eab3c1652504ba1d02d0c5 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { screen, within } from '@testing-library/react';
+import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { getMonth, getYear, parseISO } from 'date-fns';
+import { byRole } from '../../../../../src/main/js/helpers/testSelector';
 import { renderWithContext } from '../../../helpers/testUtils';
 import { DatePicker } from '../DatePicker';
 
@@ -40,7 +41,7 @@ it('behaves correctly', async () => {
   const nav = screen.getByRole('navigation');
   expect(nav).toBeInTheDocument();
 
-  await user.click(within(nav).getByRole('button', { name: 'previous_' }));
+  await user.click(byRole('navigation').byRole('button', { name: 'previous_month_x' }).get());
   await user.click(screen.getByText('7'));
 
   expect(onChange).toHaveBeenCalled();
@@ -54,7 +55,7 @@ it('behaves correctly', async () => {
    * Then check that onChange was correctly called with a date in the following month
    */
   await user.click(screen.getByRole('textbox'));
-  await user.click(screen.getByRole('button', { name: 'next_' }));
+  await user.click(screen.getByRole('button', { name: 'next_month_x' }));
   await user.click(screen.getByText('12'));
 
   expect(onChange).toHaveBeenCalled();
@@ -84,6 +85,25 @@ it('behaves correctly', async () => {
   expect(getYear(newDate3)).toBe(2019);
 });
 
+it('should disable next navigation when not in the accepted range', async () => {
+  const user = userEvent.setup();
+
+  const currentDate = parseISO('2022-11-13');
+
+  renderDatePicker({
+    currentMonth: currentDate,
+    maxDate: parseISO('2022-12-30'),
+    value: currentDate,
+    // eslint-disable-next-line jest/no-conditional-in-test
+    valueFormatter: (date?: Date) => (date ? 'formatted date' : 'no date'),
+  });
+
+  await user.click(screen.getByRole('textbox'));
+  await user.click(screen.getByRole('button', { name: 'next_month_x' }));
+
+  expect(screen.getByRole('button', { name: 'next_month_x' })).toBeDisabled();
+});
+
 it('should clear the value', async () => {
   const user = userEvent.setup();
 
@@ -136,6 +156,6 @@ function renderDatePicker(overrides: Partial<DatePicker['props']> = {}) {
       placeholder="placeholder"
       valueFormatter={defaultFormatter}
       {...overrides}
-    />
+    />,
   );
 }
index b4147d7ae30154fcefa9baf1cb8d32945f92b052..6dcbbe827624aa31770db993c08c797016d0f660 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { screen, within } from '@testing-library/react';
+import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { formatISO, parseISO } from 'date-fns';
+import { byRole } from '../../../../../src/main/js/helpers/testSelector';
 import { IntlWrapper, render } from '../../../helpers/testUtils';
 import { DateRangePicker } from '../DateRangePicker';
 
@@ -33,6 +34,7 @@ afterEach(() => {
 });
 
 it('behaves correctly', async () => {
+  const nav = byRole('navigation');
   // Remove delay to play nice with fake timers
   const user = userEvent.setup({ delay: null });
 
@@ -41,13 +43,12 @@ it('behaves correctly', async () => {
 
   await user.click(screen.getByRole('textbox', { name: 'from' }));
 
-  const fromDateNav = screen.getByRole('navigation');
-  expect(fromDateNav).toBeInTheDocument();
+  expect(nav.get()).toBeInTheDocument();
 
-  await user.click(within(fromDateNav).getByRole('button', { name: 'previous' }));
+  await user.click(nav.byRole('button', { name: 'previous_month_x' }).get());
   await user.click(screen.getByText('7'));
 
-  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+  expect(nav.query()).not.toBeInTheDocument();
 
   expect(onChange).toHaveBeenCalled();
   const { from } = onChange.mock.calls[0][0]; // first argument
@@ -58,15 +59,14 @@ it('behaves correctly', async () => {
 
   jest.runAllTimers();
 
-  const toDateNav = await screen.findByRole('navigation');
-  const previousButton = within(toDateNav).getByRole('button', { name: 'previous' });
-  const nextButton = within(toDateNav).getByRole('button', { name: 'next' });
-  await user.click(previousButton);
-  await user.click(nextButton);
-  await user.click(previousButton);
+  const previousButton = nav.byRole('button', { name: 'previous_month_x' });
+  const nextButton = nav.byRole('button', { name: 'next_month_x' });
+  await user.click(previousButton.get());
+  await user.click(nextButton.get());
+  await user.click(previousButton.get());
   await user.click(screen.getByText('12'));
 
-  expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
+  expect(nav.query()).not.toBeInTheDocument();
 
   expect(onChange).toHaveBeenCalled();
   const { to } = onChange.mock.calls[0][0]; // first argument
@@ -88,6 +88,6 @@ function renderDateRangePicker(overrides: Partial<DateRangePicker['props']> = {}
         valueFormatter={defaultFormatter}
         {...overrides}
       />
-    </IntlWrapper>
+    </IntlWrapper>,
   );
 }
index da50b78db85a93bc151e1723cbdf079b66598829..e6f8cfb61d88ca9112da04189bc0dd5589007cb2 100644 (file)
@@ -30,7 +30,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
 export function render(
   ui: React.ReactElement,
   options?: RenderOptions,
-  userEventOptions?: UserEventsOptions
+  userEventOptions?: UserEventsOptions,
 ) {
   return { ...rtlRender(ui, options), user: userEvent.setup(userEventOptions) };
 }
@@ -42,7 +42,7 @@ type RenderContextOptions = Omit<RenderOptions, 'wrapper'> & {
 
 export function renderWithContext(
   ui: React.ReactElement,
-  { userEventOptions, ...options }: RenderContextOptions = {}
+  { userEventOptions, ...options }: RenderContextOptions = {},
 ) {
   return render(ui, { ...options, wrapper: getContextWrapper() }, userEventOptions);
 }
@@ -53,7 +53,7 @@ interface RenderRouterOptions {
 
 export function renderWithRouter(
   ui: React.ReactElement,
-  options: RenderContextOptions & RenderRouterOptions = {}
+  options: RenderContextOptions & RenderRouterOptions = {},
 ) {
   const { additionalRoutes, userEventOptions, ...renderOptions } = options;
 
@@ -128,7 +128,10 @@ export function IntlWrapper({
       messages={messages}
       onError={(e) => {
         // ignore missing translations, there are none!
-        if (e.code !== ReactIntlErrorCode.MISSING_TRANSLATION) {
+        if (
+          e.code !== ReactIntlErrorCode.MISSING_TRANSLATION &&
+          e.code !== ReactIntlErrorCode.UNSUPPORTED_FORMATTER
+        ) {
           // eslint-disable-next-line no-console
           console.error(e);
         }
index 9f17c79b7f7e456e7e0e7045d31aab65034932ef..30f8f34858425dfcd4dc20d6f1eb1d9046f4e6e4 100644 (file)
@@ -73,6 +73,7 @@ descending=Descending
 description=Description
 directories=Directories
 directory=Directory
+disabled_=disabled
 dismiss=Dismiss
 dismiss_permanently=Dismiss permanently
 display=Display
@@ -146,6 +147,7 @@ navigation=Navigation
 never=Never
 new=New
 next=Next
+next_month_x=next month {month}
 new_name=New name
 next_=next
 none=None
@@ -166,6 +168,7 @@ path=Path
 permalink=Permanent Link
 plugin=Plugin
 previous_=previous
+previous_month_x=previous month {month}
 project=Project
 project_x=Project: {0}
 projects=Projects