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
Binary file added docs/public/screenshots/fab-android.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/screenshots/fab-general.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/screenshots/fab-ios.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/public/screenshots/fab-web.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
469 changes: 348 additions & 121 deletions src/components/FAB/Menu.tsx

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/components/FAB/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export type ShellProps = {
* Indicates whether the element's controlled content is expanded.
*/
'aria-expanded'?: boolean;
/**
* Indicates that the element opens a popup, such as a menu.
*/
'aria-haspopup'?: boolean | 'menu';
/**
* Largest scale the label font can reach (auto-built content only).
*/
Expand Down Expand Up @@ -172,6 +176,10 @@ export type ShellProps = {
*/
theme?: ThemeProp;
ref?: React.Ref<React.ComponentRef<typeof View>>;
/**
* Ref to the pressable inside the shell, the element that receives focus.
*/
buttonRef?: React.Ref<React.ComponentRef<typeof View>>;
};

/**
Expand Down Expand Up @@ -202,6 +210,7 @@ const Shell = ({
'aria-selected': ariaSelected,
'aria-busy': ariaBusy,
'aria-expanded': ariaExpanded,
'aria-haspopup': ariaHasPopup,
labelMaxFontSizeMultiplier,
labelAnimatedStyle,
background,
Expand All @@ -215,6 +224,7 @@ const Shell = ({
testID,
theme: themeOverrides,
ref,
buttonRef,
}: ShellProps) => {
const theme = useInternalTheme(themeOverrides);

Expand Down Expand Up @@ -324,6 +334,7 @@ const Shell = ({
<Animated.View style={[styles.clip, clipStyle]}>
{overlay}
<TouchableRipple
ref={buttonRef}
borderless
background={background}
onPress={onPress}
Expand All @@ -335,6 +346,7 @@ const Shell = ({
aria-selected={ariaSelected}
aria-busy={ariaBusy}
aria-expanded={ariaExpanded}
aria-haspopup={ariaHasPopup}
testID={testID}
style={[
children ? styles.fill : null,
Expand Down
25 changes: 25 additions & 0 deletions src/components/FAB/focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ComponentRef } from 'react';
import { Platform } from 'react-native';
import type { View } from 'react-native';

// This is a rough list of selectors that can be focused.
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

/**
* Resolves the DOM element that can receive keyboard focus for a ref: its first
* focusable descendant, or the node itself when it is the focusable element.
* Web only; returns `null` on native.
*/
export const getFocusableElement = (
el: ComponentRef<typeof View> | null | undefined
): HTMLElement | null => {
if (Platform.OS !== 'web' || !(el instanceof HTMLElement)) {
return null;
}

return (
el.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ??
(el.matches(FOCUSABLE_SELECTOR) ? el : null)
);
};
20 changes: 20 additions & 0 deletions src/components/FAB/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,23 @@ const closeButton = {
shape: 'full',
} as const;

export type Interaction = keyof typeof stateElevation;

const listItem = {
height: 56,
shape: 'full',
iconSize: 24,
leading: 24,
trailing: 24,
iconLabelGap: 8,
labelTypescale: 'titleMedium' satisfies TypescaleKey,
// Items are tonal, so they sit flat and lift on hover like tonal buttons.
elevation: {
enabled: 0,
hover: 1,
focus: 0,
pressed: 0,
} as const satisfies Record<Interaction, Elevation>,
} as const;

const spacing = {
Expand All @@ -120,10 +130,20 @@ export const MenuTokens = {
spacing,
};

const stateOpacity = tokens.md.sys.state.opacity;
export const STATE_LAYER_OPACITY = {
enabled: 0,
hover: stateOpacity.hovered,
focus: stateOpacity.focused,
pressed: stateOpacity.pressed,
} as const satisfies Record<Interaction, number>;

const focusIndicator = tokens.md.sys.state.focusIndicator;
export const FOCUS_RING_THICKNESS = focusIndicator.thickness;
export const FOCUS_RING_OUTER_OFFSET = focusIndicator.outerOffset;
export const FOCUS_RING_INSET = FOCUS_RING_OUTER_OFFSET + FOCUS_RING_THICKNESS;

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
export const webNoOutline = { outline: 'none' } as unknown as ViewStyle;

export const webNoHoverTint: ViewStyle = { backgroundColor: 'transparent' };
56 changes: 56 additions & 0 deletions src/components/__tests__/FABMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,59 @@ it('calls onDismiss when trigger is pressed while menu is open', async () => {
await userEvent.press(screen.getByTestId('fab-shell'));
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('exposes the expanded state on the trigger', async () => {
const { rerender } = await render(
<FAB.Menu
expanded={false}
onDismiss={jest.fn()}
trigger={{ icon: 'plus', testID: 'fab-shell' }}
items={makeItems()}
/>
);
expect(screen.getByTestId('fab-shell')).toBeCollapsed();

await rerender(
<FAB.Menu
expanded
onDismiss={jest.fn()}
trigger={{ icon: 'plus', testID: 'fab-shell' }}
items={makeItems()}
/>
);
expect(screen.getByTestId('fab-shell')).toBeExpanded();
});

it('renders item labels with the title medium typescale', async () => {
await render(
<FAB.Menu
expanded
onDismiss={jest.fn()}
trigger={{ icon: 'plus' }}
items={makeItems()}
/>
);
expect(screen.getByText('Send email')).toHaveStyle({ fontSize: 16 });
});

it('hides collapsed items from assistive technology and exposes expanded ones', async () => {
const { rerender } = await render(
<FAB.Menu
expanded={false}
onDismiss={jest.fn()}
trigger={{ icon: 'plus' }}
items={makeItems()}
/>
);
expect(screen.queryByRole('button', { name: 'Send email' })).toBeNull();

await rerender(
<FAB.Menu
expanded
onDismiss={jest.fn()}
trigger={{ icon: 'plus' }}
items={makeItems()}
/>
);
expect(screen.getByRole('button', { name: 'Send email' })).toBeOnTheScreen();
});
Loading
Loading