2023-01-08 14:22:41 +02:00
|
|
|
const React = require('react');
|
|
|
|
import { useState, useCallback, useMemo } from 'react';
|
|
|
|
import { FAB, Portal } from 'react-native-paper';
|
|
|
|
import { _ } from '@joplin/lib/locale';
|
2023-11-26 13:37:45 +02:00
|
|
|
import { Dispatch } from 'redux';
|
2024-11-08 15:01:29 +02:00
|
|
|
import { Platform, View, ViewStyle } from 'react-native';
|
2024-03-15 12:16:16 +02:00
|
|
|
import shim from '@joplin/lib/shim';
|
2024-08-02 15:51:49 +02:00
|
|
|
import AccessibleWebMenu from '../accessibility/AccessibleModalMenu';
|
2023-11-26 13:37:45 +02:00
|
|
|
const Icon = require('react-native-vector-icons/Ionicons').default;
|
2023-01-08 14:22:41 +02:00
|
|
|
|
2023-11-26 13:37:45 +02:00
|
|
|
// eslint-disable-next-line no-undef -- Don't know why it says React is undefined when it's defined above
|
|
|
|
type FABGroupProps = React.ComponentProps<typeof FAB.Group>;
|
2023-01-08 14:22:41 +02:00
|
|
|
|
|
|
|
type OnButtonPress = ()=> void;
|
|
|
|
interface ButtonSpec {
|
|
|
|
icon: string;
|
|
|
|
label: string;
|
|
|
|
color?: string;
|
|
|
|
onPress?: OnButtonPress;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ActionButtonProps {
|
|
|
|
buttons?: ButtonSpec[];
|
|
|
|
|
|
|
|
// If not given, an "add" button will be used.
|
|
|
|
mainButton?: ButtonSpec;
|
2023-11-26 13:37:45 +02:00
|
|
|
dispatch: Dispatch;
|
2023-01-08 14:22:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
const defaultOnPress = () => {};
|
|
|
|
|
|
|
|
// Returns a render function compatible with React Native Paper.
|
|
|
|
const getIconRenderFunction = (iconName: string) => {
|
2024-04-05 13:16:49 +02:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
2023-01-08 14:22:41 +02:00
|
|
|
return (props: any) => <Icon name={iconName} {...props} />;
|
|
|
|
};
|
|
|
|
|
|
|
|
const useIcon = (iconName: string) => {
|
|
|
|
return useMemo(() => {
|
|
|
|
return getIconRenderFunction(iconName);
|
|
|
|
}, [iconName]);
|
|
|
|
};
|
|
|
|
|
2024-08-02 15:51:49 +02:00
|
|
|
const FloatingActionButton = (props: ActionButtonProps) => {
|
2023-01-08 14:22:41 +02:00
|
|
|
const [open, setOpen] = useState(false);
|
2023-11-26 13:37:45 +02:00
|
|
|
const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => {
|
|
|
|
props.dispatch({
|
|
|
|
type: 'SIDE_MENU_CLOSE',
|
|
|
|
});
|
|
|
|
setOpen(state.open);
|
|
|
|
}, [setOpen, props.dispatch]);
|
2023-01-08 14:22:41 +02:00
|
|
|
|
|
|
|
const actions = useMemo(() => (props.buttons ?? []).map(button => {
|
|
|
|
return {
|
|
|
|
...button,
|
|
|
|
icon: getIconRenderFunction(button.icon),
|
|
|
|
onPress: button.onPress ?? defaultOnPress,
|
|
|
|
};
|
|
|
|
}), [props.buttons]);
|
|
|
|
|
2023-10-07 18:25:03 +02:00
|
|
|
const closedIcon = useIcon(props.mainButton?.icon ?? 'add');
|
2023-01-08 14:22:41 +02:00
|
|
|
const openIcon = useIcon('close');
|
|
|
|
|
2024-03-15 12:16:16 +02:00
|
|
|
// To work around an Android accessibility bug, we decrease the
|
|
|
|
// size of the container for the FAB. According to the documentation for
|
|
|
|
// RN Paper, a large action button has size 96x96. As such, we allocate
|
|
|
|
// a larger than this space for the button.
|
|
|
|
//
|
|
|
|
// To prevent the accessibility issue from regressing (which makes it
|
|
|
|
// very hard to access some UI features), we also enable this when Talkback
|
|
|
|
// is disabled.
|
|
|
|
//
|
|
|
|
// See https://github.com/callstack/react-native-paper/issues/4064
|
2024-11-08 15:01:29 +02:00
|
|
|
// May be possible to remove if https://github.com/callstack/react-native-paper/pull/4514
|
|
|
|
// is merged.
|
2024-03-15 12:16:16 +02:00
|
|
|
const adjustMargins = !open && shim.mobilePlatform() === 'android';
|
2024-11-08 15:01:29 +02:00
|
|
|
const marginStyles = useMemo((): ViewStyle => {
|
|
|
|
if (!adjustMargins) {
|
|
|
|
return {};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Internally, React Native Paper uses absolute positioning to make its
|
|
|
|
// (usually invisible) view fill the screen. Setting top and left to
|
|
|
|
// undefined causes the view to take up only part of the screen.
|
|
|
|
return {
|
|
|
|
top: undefined,
|
|
|
|
left: undefined,
|
|
|
|
};
|
|
|
|
}, [adjustMargins]);
|
2024-03-15 12:16:16 +02:00
|
|
|
|
2024-08-02 15:51:49 +02:00
|
|
|
const label = props.mainButton?.label ?? _('Add new');
|
|
|
|
|
|
|
|
// On Web, FAB.Group can't be used at all with accessibility tools. Work around this
|
|
|
|
// by hiding the FAB for accessibility, and providing a screen-reader-only custom menu.
|
|
|
|
const isWeb = Platform.OS === 'web';
|
|
|
|
const accessibleMenu = isWeb ? (
|
|
|
|
<AccessibleWebMenu
|
|
|
|
label={label}
|
|
|
|
onPress={props.mainButton?.onPress}
|
|
|
|
actions={props.buttons}
|
|
|
|
/>
|
|
|
|
) : null;
|
|
|
|
|
|
|
|
const menuContent = <FAB.Group
|
|
|
|
open={open}
|
|
|
|
accessibilityLabel={label}
|
2024-11-08 15:01:29 +02:00
|
|
|
style={marginStyles}
|
2024-08-02 15:51:49 +02:00
|
|
|
icon={ open ? openIcon : closedIcon }
|
|
|
|
fabStyle={{
|
|
|
|
backgroundColor: props.mainButton?.color ?? 'rgba(231,76,60,1)',
|
|
|
|
}}
|
|
|
|
onStateChange={onMenuToggled}
|
|
|
|
actions={actions}
|
|
|
|
onPress={props.mainButton?.onPress ?? defaultOnPress}
|
2024-10-11 23:04:29 +02:00
|
|
|
// The long press delay is too short by default (and we don't use the long press event). See https://github.com/laurent22/joplin/issues/11183.
|
|
|
|
// Increase to a large value:
|
|
|
|
delayLongPress={10_000}
|
2024-08-02 15:51:49 +02:00
|
|
|
visible={true}
|
|
|
|
/>;
|
|
|
|
const mainMenu = isWeb ? (
|
|
|
|
<View
|
|
|
|
aria-hidden={true}
|
|
|
|
pointerEvents='box-none'
|
|
|
|
tabIndex={-1}
|
|
|
|
style={{ flex: 1 }}
|
|
|
|
>{menuContent}</View>
|
|
|
|
) : menuContent;
|
|
|
|
|
2023-01-08 14:22:41 +02:00
|
|
|
return (
|
|
|
|
<Portal>
|
2024-08-02 15:51:49 +02:00
|
|
|
{mainMenu}
|
|
|
|
{accessibleMenu}
|
2023-01-08 14:22:41 +02:00
|
|
|
</Portal>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2024-08-02 15:51:49 +02:00
|
|
|
export default FloatingActionButton;
|