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
2 changes: 1 addition & 1 deletion build-tools/tasks/generate-i18n-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ module.exports = function generateI18nMessages() {
const dynamicFile = [
`import { warnOnce } from '@cloudscape-design/component-toolkit/internal';
import { isDevelopment } from '../internal/is-development';
import { getMatchableLocales } from './get-matchable-locales';
import { getMatchableLocales } from './utils/locales';

export function importMessages(locale) {
for (const matchableLocale of getMatchableLocales(locale)) {
Expand Down
15 changes: 0 additions & 15 deletions src/app-layout-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { AppLayoutProps } from '../app-layout/interfaces';
import { useAppLayoutPlacement } from '../app-layout/utils/use-app-layout-placement';
import AppLayoutToolbarInternal from '../app-layout/visual-refresh-toolbar';
import { AppLayoutToolbarPublicContext } from '../app-layout/visual-refresh-toolbar/contexts';
import { useInternalI18n } from '../i18n/context';
import { getBaseProps } from '../internal/base-component';
import { NonCancelableCustomEvent } from '../internal/events';
import useBaseComponent from '../internal/hooks/use-base-component';
Expand Down Expand Up @@ -79,19 +78,6 @@ const AppLayoutToolbar = React.forwardRef(
);
const isMobile = useMobile();

const i18n = useInternalI18n('app-layout');
const ariaLabels = {
navigation: i18n('ariaLabels.navigation', rest.ariaLabels?.navigation),
navigationClose: i18n('ariaLabels.navigationClose', rest.ariaLabels?.navigationClose),
navigationToggle: i18n('ariaLabels.navigationToggle', rest.ariaLabels?.navigationToggle),
notifications: i18n('ariaLabels.notifications', rest.ariaLabels?.notifications),
tools: i18n('ariaLabels.tools', rest.ariaLabels?.tools),
toolsClose: i18n('ariaLabels.toolsClose', rest.ariaLabels?.toolsClose),
toolsToggle: i18n('ariaLabels.toolsToggle', rest.ariaLabels?.toolsToggle),
drawers: i18n('ariaLabels.drawers', rest.ariaLabels?.drawers),
drawersOverflow: i18n('ariaLabels.drawersOverflow', rest.ariaLabels?.drawersOverflow),
drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', rest.ariaLabels?.drawersOverflowWithBadge),
};
const { navigationOpen: defaultNavigationOpen, ...restDefaults } = applyDefaults(
contentType,
{ maxContentWidth, minContentWidth },
Expand Down Expand Up @@ -120,7 +106,6 @@ const AppLayoutToolbar = React.forwardRef(
onNavigationChange,
...restDefaults,
...rest,
ariaLabels,
placement,
};

Expand Down
86 changes: 86 additions & 0 deletions src/app-layout/__tests__/toolbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { waitFor } from '@testing-library/react';

import AppLayout from '../../../lib/components/app-layout';
import { loadFormatter } from '../../../lib/components/app-layout/visual-refresh-toolbar/internal';
import { I18nProvider } from '../../../lib/components/i18n';
import { I18nFormatter } from '../../../lib/components/i18n/utils/i18n-formatter';
import SplitPanel from '../../../lib/components/split-panel';
import { describeEachAppLayout, manyDrawers, renderComponent } from './utils';

// no-op function to suppress controllability warnings
function noop() {}

jest.mock('../../../lib/components/app-layout/visual-refresh-toolbar/internal', () => ({
...jest.requireActual('../../../lib/components/app-layout/visual-refresh-toolbar/internal'),
loadFormatter: jest.fn(() => Promise.resolve(null)),
}));

afterEach(() => {
jest.clearAllMocks();
});

describe('toolbar mode only features', () => {
describeEachAppLayout({ themes: ['refresh-toolbar'] }, ({ size }) => {
test('does not render the toolbar when all panels are hidden', () => {
Expand Down Expand Up @@ -78,5 +91,78 @@ describe('toolbar mode only features', () => {
expect(wrapper.findDrawerTriggerById(manyDrawers[0].id)!.getElement()).toHaveAttribute('aria-expanded', 'true');
});
});

describe('RemoteI18nProvider integration', () => {
test('does not block content while formatter is pending or null', async () => {
// Keep the promise pending until we manually resolve it.
let resolveFn: (value: null) => void = () => {};
const loadFormatterImpl = () => {
return new Promise<null>(resolve => {
resolveFn = resolve;
});
};
jest.mocked(loadFormatter).mockImplementation(loadFormatterImpl);

const { wrapper } = renderComponent(<AppLayout content="App layout content" />);
expect(wrapper.findContentRegion().getElement()).toHaveTextContent('App layout content');

resolveFn(null);
await waitFor(() => {
expect(wrapper.findContentRegion().getElement()).toHaveTextContent('App layout content');
});
});

test('does not fail if loading formatter fails', async () => {
jest.mocked(loadFormatter).mockImplementation(() => Promise.reject(new Error('failed :(')));
const { wrapper } = renderComponent(<AppLayout content="App layout content" />);

await waitFor(() => {
expect(wrapper.findContentRegion().getElement()).toHaveTextContent('App layout content');
});
});

test('does not call loadFormatter if app layout is wrapped by I18nProvider', () => {
renderComponent(
// It doesn't need to have messages, just the existence of a wrapping provider is enough.
<I18nProvider locale="en" messages={[]}>
<AppLayout content="App layout content" />
</I18nProvider>
);
expect(loadFormatter).not.toHaveBeenCalled();
});

test('rerenders app layout with labels when formatter is correctly loaded', async () => {
// Keep the promise pending until we manually resolve it.
let resolveFn: (value: I18nFormatter) => void = () => {};
const loadFormatterImpl = () => {
return new Promise<I18nFormatter>(resolve => {
resolveFn = resolve;
});
};
jest.mocked(loadFormatter as unknown as () => Promise<I18nFormatter>).mockImplementation(loadFormatterImpl);

const { wrapper } = renderComponent(<AppLayout navigationOpen={true} toolsOpen={true} />);
expect(wrapper.findNavigationClose().getElement()).not.toHaveAttribute('aria-label');
expect(wrapper.findToolsClose().getElement()).not.toHaveAttribute('aria-label');

resolveFn(
new I18nFormatter('en-US', {
'cloudscape-design-components': {
'en-US': {
'app-layout': {
'ariaLabels.navigationClose': 'remote navigationClose',
'ariaLabels.toolsClose': 'remote toolsClose',
},
},
},
})
);

await waitFor(() => {
expect(wrapper.findNavigationClose().getElement()).toHaveAttribute('aria-label', 'remote navigationClose');
expect(wrapper.findToolsClose().getElement()).toHaveAttribute('aria-label', 'remote toolsClose');
});
});
});
});
});
16 changes: 2 additions & 14 deletions src/app-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import React from 'react';

import { useMergeRefs, warnOnce } from '@cloudscape-design/component-toolkit/internal';

import { useInternalI18n } from '../i18n/context';
import { getBaseProps } from '../internal/base-component';
import { NonCancelableCustomEvent } from '../internal/events';
import useBaseComponent from '../internal/hooks/use-base-component';
Expand All @@ -18,6 +17,7 @@ import { applyDefaults } from './defaults';
import { AppLayoutProps } from './interfaces';
import { AppLayoutInternal } from './internal';
import { useAppLayoutPlacement } from './utils/use-app-layout-placement';
import { useAriaLabels } from './utils/use-aria-labels';

export { AppLayoutProps };

Expand Down Expand Up @@ -72,19 +72,7 @@ const AppLayout = React.forwardRef(
const isRefresh = useVisualRefresh();
const isMobile = useMobile();

const i18n = useInternalI18n('app-layout');
const ariaLabels = {
navigation: i18n('ariaLabels.navigation', rest.ariaLabels?.navigation),
navigationClose: i18n('ariaLabels.navigationClose', rest.ariaLabels?.navigationClose),
navigationToggle: i18n('ariaLabels.navigationToggle', rest.ariaLabels?.navigationToggle),
notifications: i18n('ariaLabels.notifications', rest.ariaLabels?.notifications),
tools: i18n('ariaLabels.tools', rest.ariaLabels?.tools),
toolsClose: i18n('ariaLabels.toolsClose', rest.ariaLabels?.toolsClose),
toolsToggle: i18n('ariaLabels.toolsToggle', rest.ariaLabels?.toolsToggle),
drawers: i18n('ariaLabels.drawers', rest.ariaLabels?.drawers),
drawersOverflow: i18n('ariaLabels.drawersOverflow', rest.ariaLabels?.drawersOverflow),
drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', rest.ariaLabels?.drawersOverflowWithBadge),
};
const ariaLabels = useAriaLabels(rest.ariaLabels);
const { navigationOpen: defaultNavigationOpen, ...restDefaults } = applyDefaults(
contentType,
{ maxContentWidth, minContentWidth },
Expand Down
21 changes: 21 additions & 0 deletions src/app-layout/utils/use-aria-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { useInternalI18n } from '../../i18n/context';
import { AppLayoutProps } from '../interfaces';

export function useAriaLabels(ariaLabelsOverride: AppLayoutProps.Labels | undefined): AppLayoutProps.Labels {
const i18n = useInternalI18n('app-layout');
return {
navigation: i18n('ariaLabels.navigation', ariaLabelsOverride?.navigation),
navigationClose: i18n('ariaLabels.navigationClose', ariaLabelsOverride?.navigationClose),
navigationToggle: i18n('ariaLabels.navigationToggle', ariaLabelsOverride?.navigationToggle),
notifications: i18n('ariaLabels.notifications', ariaLabelsOverride?.notifications),
tools: i18n('ariaLabels.tools', ariaLabelsOverride?.tools),
toolsClose: i18n('ariaLabels.toolsClose', ariaLabelsOverride?.toolsClose),
toolsToggle: i18n('ariaLabels.toolsToggle', ariaLabelsOverride?.toolsToggle),
drawers: i18n('ariaLabels.drawers', ariaLabelsOverride?.drawers),
drawersOverflow: i18n('ariaLabels.drawersOverflow', ariaLabelsOverride?.drawersOverflow),
drawersOverflowWithBadge: i18n('ariaLabels.drawersOverflowWithBadge', ariaLabelsOverride?.drawersOverflowWithBadge),
};
}
83 changes: 57 additions & 26 deletions src/app-layout/visual-refresh-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { unstable_batchedUpdates } from 'react-dom';

import RemoteI18nProvider from '../../i18n/providers/remote-provider';
import ScreenreaderOnly from '../../internal/components/screenreader-only';
import { AppLayoutProps } from '../interfaces';
import { useAriaLabels } from '../utils/use-aria-labels';
import { AppLayoutVisibilityContext } from './contexts';
import { AppLayoutInternalProps, AppLayoutPendingState } from './interfaces';
import { AppLayoutWidgetizedState } from './internal';
import { AppLayoutWidgetizedState, loadFormatter } from './internal';
import { SkeletonLayout } from './skeleton';
import { SkeletonSlotsAttributes } from './skeleton/interfaces';
import { DeduplicationType, useMultiAppLayout } from './skeleton/multi-layout';
Expand Down Expand Up @@ -62,36 +64,65 @@ const AppLayoutStateProvider: React.FC<{

const AppLayoutVisualRefreshToolbar = React.forwardRef<AppLayoutProps.Ref, AppLayoutInternalProps>(
(props, forwardRef) => {
const stateManager = useRef<StateManager>({ setState: undefined, hasToolbar: false, setToolbar: undefined });
const { __forceDeduplicationType: forceDeduplicationType, __embeddedViewMode: embeddedViewMode } = props as any;
const stateManagerRef = useRef<StateManager>({ setState: undefined, hasToolbar: false, setToolbar: undefined });

return (
<>
<AppLayoutStateProvider
forceDeduplicationType={forceDeduplicationType}
<RemoteI18nProvider loadFormatter={loadFormatter}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to wrap the whole thing with this provider? We can only wrap SkeletonLayout and call useAriaLabels inside

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seemed cleaner and safer this way. I wasn't sure if the weird "hidden copy of breadcrumbs" thing should also be wrapped by the provider or if it's safe for its strings to not match the skeleton's strings, and if the deduplication breadcrumbs are included, the wrapped component ends up requiring passing along a lot of internal props and state tracking, which seemed unmaintainable to me.

If the breadcrumbs are 100% safe to exclude, I can go for this approach.

<AppLayoutVisualRefreshToolbarWithI18n
appLayoutRef={forwardRef}
stateManagerRef={stateManagerRef}
appLayoutProps={props}
stateManager={stateManager}
>
{(registered, appLayoutState, toolbarProps, skeletonAttributes) => (
<AppLayoutVisibilityContext.Provider value={appLayoutState.isIntersecting}>
{/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */}
{(embeddedViewMode || !toolbarProps) && props.breadcrumbs ? (
<ScreenreaderOnly>{props.breadcrumbs}</ScreenreaderOnly>
) : null}
<SkeletonLayout
registered={registered}
toolbarProps={toolbarProps}
appLayoutProps={props}
appLayoutState={appLayoutState}
skeletonSlotsAttributes={skeletonAttributes}
/>
</AppLayoutVisibilityContext.Provider>
)}
</AppLayoutStateProvider>
<AppLayoutWidgetizedState forwardRef={forwardRef} appLayoutProps={props} stateManager={stateManager} />
</>
/>
</RemoteI18nProvider>
);
}
);

function AppLayoutVisualRefreshToolbarWithI18n({
appLayoutRef,
stateManagerRef,
appLayoutProps,
}: {
appLayoutRef: React.ForwardedRef<AppLayoutProps.Ref>;
stateManagerRef: React.MutableRefObject<StateManager>;
appLayoutProps: AppLayoutInternalProps;
}) {
const { __forceDeduplicationType: forceDeduplicationType, __embeddedViewMode: embeddedViewMode } =
appLayoutProps as any;

const ariaLabels = useAriaLabels(appLayoutProps.ariaLabels);
const appLayoutPropsWithI18n = { ...appLayoutProps, ariaLabels };

return (
<>
<AppLayoutStateProvider
forceDeduplicationType={forceDeduplicationType}
appLayoutProps={appLayoutPropsWithI18n}
stateManager={stateManagerRef}
>
{(registered, appLayoutState, toolbarProps, skeletonAttributes) => (
<AppLayoutVisibilityContext.Provider value={appLayoutState.isIntersecting}>
{/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */}
{(embeddedViewMode || !toolbarProps) && appLayoutPropsWithI18n.breadcrumbs ? (
<ScreenreaderOnly>{appLayoutPropsWithI18n.breadcrumbs}</ScreenreaderOnly>
) : null}
<SkeletonLayout
registered={registered}
toolbarProps={toolbarProps}
appLayoutProps={appLayoutPropsWithI18n}
appLayoutState={appLayoutState}
skeletonSlotsAttributes={skeletonAttributes}
/>
</AppLayoutVisibilityContext.Provider>
)}
</AppLayoutStateProvider>
<AppLayoutWidgetizedState
forwardRef={appLayoutRef}
appLayoutProps={appLayoutPropsWithI18n}
stateManager={stateManagerRef}
/>
</>
);
}

export default AppLayoutVisualRefreshToolbar;
1 change: 1 addition & 0 deletions src/app-layout/visual-refresh-toolbar/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ export const AppLayoutBottomContentSlot = createWidgetizedAppLayoutBottomContent
export const AppLayoutWidgetizedState = createWidgetizedAppLayoutState(
createLoadableComponent(AppLayoutStateImplementation)
);
export const loadFormatter = () => Promise.resolve(null);
15 changes: 0 additions & 15 deletions src/i18n/get-matchable-locales.ts

This file was deleted.

Loading
Loading