@planningcenter/chat-react-native 4.5.0-rc.2 → 4.5.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/components/display/filter_dropdown.d.ts +16 -0
- package/build/components/display/filter_dropdown.d.ts.map +1 -1
- package/build/components/display/filter_dropdown.js +105 -57
- package/build/components/display/filter_dropdown.js.map +1 -1
- package/build/components/display/index.d.ts +1 -0
- package/build/components/display/index.d.ts.map +1 -1
- package/build/components/display/index.js +1 -0
- package/build/components/display/index.js.map +1 -1
- package/build/components/display/popover_menu_view.d.ts +8 -0
- package/build/components/display/popover_menu_view.d.ts.map +1 -0
- package/build/components/display/popover_menu_view.js +482 -0
- package/build/components/display/popover_menu_view.js.map +1 -0
- package/build/screens/conversations/components/filter_dropdown_list_header_component.d.ts.map +1 -1
- package/build/screens/conversations/components/filter_dropdown_list_header_component.js +159 -124
- package/build/screens/conversations/components/filter_dropdown_list_header_component.js.map +1 -1
- package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
- package/build/screens/conversations/conversations_screen.js +1 -2
- package/build/screens/conversations/conversations_screen.js.map +1 -1
- package/build/screens/design_system_screen.d.ts.map +1 -1
- package/build/screens/design_system_screen.js +289 -201
- package/build/screens/design_system_screen.js.map +1 -1
- package/build/utils/native_adapters/menu.d.ts +1 -2
- package/build/utils/native_adapters/menu.d.ts.map +1 -1
- package/build/utils/native_adapters/menu.js +2 -41
- package/build/utils/native_adapters/menu.js.map +1 -1
- package/package.json +2 -2
- package/src/components/display/__tests__/filter_dropdown.test.tsx +18 -2
- package/src/components/display/__tests__/popover_menu_view.test.tsx +63 -0
- package/src/components/display/filter_dropdown.tsx +53 -19
- package/src/components/display/index.ts +1 -0
- package/src/components/display/popover_menu_view.tsx +239 -0
- package/src/screens/conversations/__tests__/conversations_screen.test.tsx +2 -12
- package/src/screens/conversations/components/__tests__/filter_dropdown_list_header_component.test.tsx +0 -54
- package/src/screens/conversations/components/filter_dropdown_list_header_component.tsx +41 -27
- package/src/screens/conversations/conversations_screen.tsx +1 -2
- package/src/screens/design_system_screen.tsx +34 -1
- package/src/utils/native_adapters/menu.tsx +4 -18
- package/src/utils/native_adapters/__tests__/menu.test.tsx +0 -46
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../../src/utils/native_adapters/menu.tsx"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"menu.d.ts","sourceRoot":"","sources":["../../../src/utils/native_adapters/menu.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AASnD,MAAM,MAAM,eAAe,GAAG,IAAI,GAAG,KAAK,CAAA;AAE1C,MAAM,WAAW,oBAAoB;IACnC,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;CACjB;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,eAAe,CAAA;IACvB,UAAU,CAAC,EAAE,oBAAoB,CAAA;IACjC,UAAU,CAAC,EAAE,UAAU,EAAE,CAAA;IACzB,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE;QAAE,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,UAAU,EAAE,CAAA;IACrB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAA;IAChD,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAA;IACxB,qBAAqB,CAAC,EAAE,OAAO,CAAA;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC3B;AAED,MAAM,MAAM,QAAQ,GAAG,KAAK,CAAC,aAAa,CAAC,aAAa,CAAC,CAAA;AAMzD,qBAAa,WAAW;IACtB,IAAI,CAAC,EAAE,QAAQ,CAAA;gBAEH,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,QAAQ,CAAA;KAAE;CAGzC"}
|
|
@@ -1,56 +1,17 @@
|
|
|
1
|
-
import { c as _c } from "react/compiler-runtime";
|
|
2
|
-
import React, { useEffect } from 'react';
|
|
3
|
-
import { View } from 'react-native';
|
|
4
|
-
|
|
5
1
|
// =================================
|
|
6
2
|
// ====== Types ====================
|
|
7
3
|
// =================================
|
|
8
4
|
|
|
9
5
|
// Mirrors the props shared by `@expo/ui/community/menu` and
|
|
10
6
|
// `@react-native-menu/menu`, so a host can pass either `MenuView` straight in.
|
|
11
|
-
|
|
7
|
+
|
|
12
8
|
// =================================
|
|
13
9
|
// ====== Adapter ==================
|
|
14
10
|
// =================================
|
|
15
11
|
|
|
16
|
-
function UnconfiguredMenuView(t0) {
|
|
17
|
-
const $ = _c(5);
|
|
18
|
-
const {
|
|
19
|
-
children,
|
|
20
|
-
style,
|
|
21
|
-
testID
|
|
22
|
-
} = t0;
|
|
23
|
-
let t1;
|
|
24
|
-
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
|
25
|
-
t1 = [];
|
|
26
|
-
$[0] = t1;
|
|
27
|
-
} else {
|
|
28
|
-
t1 = $[0];
|
|
29
|
-
}
|
|
30
|
-
useEffect(_temp, t1);
|
|
31
|
-
let t2;
|
|
32
|
-
if ($[1] !== children || $[2] !== style || $[3] !== testID) {
|
|
33
|
-
t2 = /*#__PURE__*/_jsx(View, {
|
|
34
|
-
style: style,
|
|
35
|
-
testID: testID,
|
|
36
|
-
children: children
|
|
37
|
-
});
|
|
38
|
-
$[1] = children;
|
|
39
|
-
$[2] = style;
|
|
40
|
-
$[3] = testID;
|
|
41
|
-
$[4] = t2;
|
|
42
|
-
} else {
|
|
43
|
-
t2 = $[4];
|
|
44
|
-
}
|
|
45
|
-
return t2;
|
|
46
|
-
}
|
|
47
|
-
function _temp() {
|
|
48
|
-
console.warn("ChatAdapters.configure() must include a menu adapter before rendering a menu");
|
|
49
|
-
}
|
|
50
12
|
export class MenuAdapter {
|
|
51
13
|
constructor(methods) {
|
|
52
|
-
this.View = methods?.View
|
|
53
|
-
this.isConfigured = !!methods?.View;
|
|
14
|
+
this.View = methods?.View;
|
|
54
15
|
}
|
|
55
16
|
}
|
|
56
17
|
//# sourceMappingURL=menu.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"menu.js","names":["
|
|
1
|
+
{"version":3,"file":"menu.js","names":["MenuAdapter","constructor","methods","View"],"sources":["../../../src/utils/native_adapters/menu.tsx"],"sourcesContent":["import React from 'react'\nimport { StyleProp, ViewStyle } from 'react-native'\n\n// =================================\n// ====== Types ====================\n// =================================\n\n// Mirrors the props shared by `@expo/ui/community/menu` and\n// `@react-native-menu/menu`, so a host can pass either `MenuView` straight in.\n\nexport type MenuActionState = 'on' | 'off'\n\nexport interface MenuActionAttributes {\n destructive?: boolean\n disabled?: boolean\n hidden?: boolean\n}\n\nexport interface MenuAction {\n id?: string\n title: string\n state?: MenuActionState\n attributes?: MenuActionAttributes\n subactions?: MenuAction[]\n displayInline?: boolean\n}\n\nexport interface MenuActionEvent {\n nativeEvent: { event: string }\n}\n\nexport interface MenuViewProps {\n actions: MenuAction[]\n onPressAction?: (event: MenuActionEvent) => void\n onOpenMenu?: () => void\n onCloseMenu?: () => void\n shouldOpenOnLongPress?: boolean\n title?: string\n style?: StyleProp<ViewStyle>\n testID?: string\n children?: React.ReactNode\n}\n\nexport type MenuView = React.ComponentType<MenuViewProps>\n\n// =================================\n// ====== Adapter ==================\n// =================================\n\nexport class MenuAdapter {\n View?: MenuView\n\n constructor(methods?: { View: MenuView }) {\n this.View = methods?.View\n }\n}\n"],"mappings":"AAGA;AACA;AACA;;AAEA;AACA;;AAqCA;AACA;AACA;;AAEA,OAAO,MAAMA,WAAW,CAAC;EAGvBC,WAAWA,CAACC,OAA4B,EAAE;IACxC,IAAI,CAACC,IAAI,GAAGD,OAAO,EAAEC,IAAI;EAC3B;AACF","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@planningcenter/chat-react-native",
|
|
3
|
-
"version": "4.5.0-rc.
|
|
3
|
+
"version": "4.5.0-rc.4",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -84,5 +84,5 @@
|
|
|
84
84
|
"sf-symbols-typescript": "^2.2.0",
|
|
85
85
|
"typescript": "~6.0.3"
|
|
86
86
|
},
|
|
87
|
-
"gitHead": "
|
|
87
|
+
"gitHead": "04e33412207075acd08682ed7b8bb8e5424be8f2"
|
|
88
88
|
}
|
|
@@ -36,7 +36,7 @@ const FakeMenuView = ({ actions, onPressAction, children }: MenuViewProps) => {
|
|
|
36
36
|
)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
const configureAdapters = (menu?: MenuAdapter) => {
|
|
40
40
|
ChatAdapters.configure({
|
|
41
41
|
clipboard: new ClipboardAdapter({
|
|
42
42
|
getStringAsync: async () => '',
|
|
@@ -48,8 +48,12 @@ beforeAll(() => {
|
|
|
48
48
|
openCameraAsync: async () => ({ canceled: true, assets: null }),
|
|
49
49
|
openImageLibraryAsync: async () => ({ canceled: true, assets: null }),
|
|
50
50
|
}),
|
|
51
|
-
menu
|
|
51
|
+
menu,
|
|
52
52
|
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
configureAdapters(new MenuAdapter({ View: FakeMenuView }))
|
|
53
57
|
})
|
|
54
58
|
|
|
55
59
|
const options = [
|
|
@@ -111,4 +115,16 @@ describe('FilterDropdown', () => {
|
|
|
111
115
|
expect(onMoreFilters).toHaveBeenCalledTimes(1)
|
|
112
116
|
expect(onSelect).not.toHaveBeenCalled()
|
|
113
117
|
})
|
|
118
|
+
|
|
119
|
+
it('opens the popover menu when the host configured no menu', () => {
|
|
120
|
+
configureAdapters()
|
|
121
|
+
const { onSelect } = renderFilterDropdown()
|
|
122
|
+
|
|
123
|
+
expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
|
|
124
|
+
|
|
125
|
+
fireEvent.press(screen.getByRole('button', { name: 'Groups' }))
|
|
126
|
+
fireEvent.press(screen.getByRole('menuitem', { name: 'Teams' }))
|
|
127
|
+
|
|
128
|
+
expect(onSelect).toHaveBeenCalledWith('teams')
|
|
129
|
+
})
|
|
114
130
|
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react-native'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Text } from 'react-native'
|
|
4
|
+
import { MenuAction } from '../../../utils/native_adapters'
|
|
5
|
+
import { PopoverMenuView } from '../popover_menu_view'
|
|
6
|
+
|
|
7
|
+
const actions: MenuAction[] = [
|
|
8
|
+
{ id: 'all', title: 'All' },
|
|
9
|
+
{ id: 'groups', title: 'Groups', state: 'on' },
|
|
10
|
+
{ id: 'secret', title: 'Secret', attributes: { hidden: true } },
|
|
11
|
+
{
|
|
12
|
+
id: 'more',
|
|
13
|
+
title: '',
|
|
14
|
+
displayInline: true,
|
|
15
|
+
subactions: [{ id: 'more_filters', title: 'More filters' }],
|
|
16
|
+
},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const renderMenu = () => {
|
|
20
|
+
const onPressAction = jest.fn()
|
|
21
|
+
|
|
22
|
+
render(
|
|
23
|
+
<PopoverMenuView actions={actions} onPressAction={onPressAction}>
|
|
24
|
+
<Text>Trigger</Text>
|
|
25
|
+
</PopoverMenuView>
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
fireEvent.press(screen.getByText('Trigger'))
|
|
29
|
+
|
|
30
|
+
return { onPressAction }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('PopoverMenuView', () => {
|
|
34
|
+
it('opens with a row per visible action, inline sections flattened, the on action checked', () => {
|
|
35
|
+
renderMenu()
|
|
36
|
+
|
|
37
|
+
expect(screen.getAllByRole('menuitem').map(item => item.props.accessibilityState)).toEqual([
|
|
38
|
+
{ selected: false, disabled: false },
|
|
39
|
+
{ selected: true, disabled: false },
|
|
40
|
+
{ selected: false, disabled: false },
|
|
41
|
+
])
|
|
42
|
+
expect(screen.getByRole('menuitem', { name: 'More filters' })).toBeTruthy()
|
|
43
|
+
expect(screen.queryByRole('menuitem', { name: 'Secret' })).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('reports the pressed action by id and closes', () => {
|
|
47
|
+
const { onPressAction } = renderMenu()
|
|
48
|
+
|
|
49
|
+
fireEvent.press(screen.getByRole('menuitem', { name: 'More filters' }))
|
|
50
|
+
|
|
51
|
+
expect(onPressAction).toHaveBeenCalledWith({ nativeEvent: { event: 'more_filters' } })
|
|
52
|
+
expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('closes without an action when the scrim is pressed', () => {
|
|
56
|
+
const { onPressAction } = renderMenu()
|
|
57
|
+
|
|
58
|
+
fireEvent.press(screen.getByLabelText('Close menu'))
|
|
59
|
+
|
|
60
|
+
expect(onPressAction).not.toHaveBeenCalled()
|
|
61
|
+
expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { StyleSheet, View } from 'react-native'
|
|
2
|
+
import { LayoutChangeEvent, StyleSheet, View } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../hooks'
|
|
4
4
|
import {
|
|
5
5
|
MAX_FONT_SIZE_MULTIPLIER_LANDMARK,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../../utils'
|
|
11
11
|
import { tokens } from '../../vendor/tapestry/tokens'
|
|
12
12
|
import { Icon } from './icon'
|
|
13
|
+
import { PopoverMenuView } from './popover_menu_view'
|
|
13
14
|
import { Text } from './text'
|
|
14
15
|
|
|
15
16
|
// =================================
|
|
@@ -52,6 +53,7 @@ export function FilterDropdown({
|
|
|
52
53
|
maxWidth,
|
|
53
54
|
}: FilterDropdownProps) {
|
|
54
55
|
const styles = useStyles()
|
|
56
|
+
const MenuView = Menu.View ?? PopoverMenuView
|
|
55
57
|
|
|
56
58
|
const optionActions: MenuAction[] = options.map(option => ({
|
|
57
59
|
id: option.id,
|
|
@@ -75,28 +77,60 @@ export function FilterDropdown({
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
return (
|
|
78
|
-
<
|
|
79
|
-
<
|
|
80
|
-
|
|
80
|
+
<MenuView actions={actions} onPressAction={handlePressAction} style={styles.container}>
|
|
81
|
+
<FilterDropdownTrigger
|
|
82
|
+
label={triggerLabel}
|
|
83
|
+
maxWidth={maxWidth}
|
|
81
84
|
accessible
|
|
82
85
|
accessibilityRole="button"
|
|
83
86
|
accessibilityHint="Opens filter options"
|
|
87
|
+
/>
|
|
88
|
+
</MenuView>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// =================================
|
|
93
|
+
// ====== Trigger ==================
|
|
94
|
+
// =================================
|
|
95
|
+
|
|
96
|
+
export interface FilterDropdownTriggerProps {
|
|
97
|
+
label: string
|
|
98
|
+
maxWidth?: number
|
|
99
|
+
accessible?: boolean
|
|
100
|
+
accessibilityRole?: 'button'
|
|
101
|
+
accessibilityHint?: string
|
|
102
|
+
accessibilityElementsHidden?: boolean
|
|
103
|
+
importantForAccessibility?: 'no-hide-descendants'
|
|
104
|
+
onLayout?: (event: LayoutChangeEvent) => void
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The label and chevron the dropdown opens from. Exported so a screen can render a hidden copy
|
|
109
|
+
* to measure how wide a label is at the current font scale.
|
|
110
|
+
*/
|
|
111
|
+
export function FilterDropdownTrigger({
|
|
112
|
+
label,
|
|
113
|
+
maxWidth,
|
|
114
|
+
...viewProps
|
|
115
|
+
}: FilterDropdownTriggerProps) {
|
|
116
|
+
const styles = useStyles()
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<View style={[styles.trigger, maxWidth !== undefined && { maxWidth }]} {...viewProps}>
|
|
120
|
+
<Text
|
|
121
|
+
style={styles.triggerLabel}
|
|
122
|
+
numberOfLines={1}
|
|
123
|
+
maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
|
|
84
124
|
>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
style={styles.triggerIcon}
|
|
95
|
-
maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
|
|
96
|
-
accessibilityElementsHidden
|
|
97
|
-
/>
|
|
98
|
-
</View>
|
|
99
|
-
</Menu.View>
|
|
125
|
+
{label}
|
|
126
|
+
</Text>
|
|
127
|
+
<Icon
|
|
128
|
+
name="general.downChevron"
|
|
129
|
+
style={styles.triggerIcon}
|
|
130
|
+
maxFontSizeMultiplier={MAX_FONT_SIZE_MULTIPLIER_LANDMARK}
|
|
131
|
+
accessibilityElementsHidden
|
|
132
|
+
/>
|
|
133
|
+
</View>
|
|
100
134
|
)
|
|
101
135
|
}
|
|
102
136
|
|
|
@@ -25,6 +25,7 @@ export * from './text_inline_button'
|
|
|
25
25
|
export * from './text'
|
|
26
26
|
export * from './toggle_button'
|
|
27
27
|
export * from './filter_dropdown'
|
|
28
|
+
export * from './popover_menu_view'
|
|
28
29
|
export * from './keyboard_scroll_view'
|
|
29
30
|
export * from './keyboard_view'
|
|
30
31
|
export * from './platform_modal_header_buttons'
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { clamp } from 'lodash'
|
|
2
|
+
import React, { useRef, useState } from 'react'
|
|
3
|
+
import { Modal, Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'
|
|
4
|
+
import { useChatContext } from '../../contexts/chat_context'
|
|
5
|
+
import { useCreateAndroidRippleColor, useFontScale, useTheme } from '../../hooks'
|
|
6
|
+
import { MenuAction, MenuViewProps, platformPressedOpacityStyle } from '../../utils'
|
|
7
|
+
import { tokens } from '../../vendor/tapestry/tokens'
|
|
8
|
+
import { Icon } from './icon'
|
|
9
|
+
import { Text } from './text'
|
|
10
|
+
|
|
11
|
+
// =================================
|
|
12
|
+
// ====== Constants ================
|
|
13
|
+
// =================================
|
|
14
|
+
|
|
15
|
+
const MENU_WIDTH = 240
|
|
16
|
+
const MENU_OFFSET = 4
|
|
17
|
+
const SCREEN_MARGIN = 16
|
|
18
|
+
|
|
19
|
+
const CLOSED_ANCHOR = { x: 0, y: 0, width: 0, height: 0 }
|
|
20
|
+
|
|
21
|
+
// =================================
|
|
22
|
+
// ====== Rows =====================
|
|
23
|
+
// =================================
|
|
24
|
+
|
|
25
|
+
type Row = { kind: 'action'; action: MenuAction } | { kind: 'separator' }
|
|
26
|
+
|
|
27
|
+
const actionId = (action: MenuAction) => action.id ?? action.title
|
|
28
|
+
|
|
29
|
+
const visibleActions = (actions: MenuAction[]) =>
|
|
30
|
+
actions.filter(action => !action.attributes?.hidden)
|
|
31
|
+
|
|
32
|
+
// Lays the actions out the way the native menus draw them: an inline section's
|
|
33
|
+
// subactions sit at the top level, divided from neighboring rows by a separator.
|
|
34
|
+
const buildRows = (actions: MenuAction[]): Row[] => {
|
|
35
|
+
const rows: Row[] = []
|
|
36
|
+
const pushSeparator = () => {
|
|
37
|
+
if (rows.length > 0 && rows[rows.length - 1].kind !== 'separator') {
|
|
38
|
+
rows.push({ kind: 'separator' })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
visibleActions(actions).forEach((action, index, visible) => {
|
|
43
|
+
if (action.displayInline && action.subactions) {
|
|
44
|
+
pushSeparator()
|
|
45
|
+
visibleActions(action.subactions).forEach(subaction =>
|
|
46
|
+
rows.push({ kind: 'action', action: subaction })
|
|
47
|
+
)
|
|
48
|
+
if (index < visible.length - 1) pushSeparator()
|
|
49
|
+
} else {
|
|
50
|
+
rows.push({ kind: 'action', action })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return rows
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// =================================
|
|
58
|
+
// ====== Component ================
|
|
59
|
+
// =================================
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A JS-only `MenuView` for hosts that cannot supply a native menu. Wraps the
|
|
63
|
+
* trigger in a pressable and opens a popover of the actions under it, or above
|
|
64
|
+
* it when the trigger sits in the lower half of the window.
|
|
65
|
+
*/
|
|
66
|
+
export function PopoverMenuView({
|
|
67
|
+
actions,
|
|
68
|
+
onPressAction,
|
|
69
|
+
onOpenMenu,
|
|
70
|
+
onCloseMenu,
|
|
71
|
+
style,
|
|
72
|
+
testID,
|
|
73
|
+
children,
|
|
74
|
+
}: MenuViewProps) {
|
|
75
|
+
const triggerRef = useRef<View>(null)
|
|
76
|
+
const [expanded, setExpanded] = useState(false)
|
|
77
|
+
const [anchor, setAnchor] = useState(CLOSED_ANCHOR)
|
|
78
|
+
const { edgeToEdge = true } = useChatContext()
|
|
79
|
+
const { width: windowWidth, height: windowHeight } = useWindowDimensions()
|
|
80
|
+
const fontScale = useFontScale()
|
|
81
|
+
const styles = useStyles()
|
|
82
|
+
|
|
83
|
+
const open = () => {
|
|
84
|
+
triggerRef.current?.measureInWindow((x, y, width, height) => {
|
|
85
|
+
setAnchor({ x, y, width, height })
|
|
86
|
+
})
|
|
87
|
+
setExpanded(true)
|
|
88
|
+
onOpenMenu?.()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const close = () => {
|
|
92
|
+
setExpanded(false)
|
|
93
|
+
onCloseMenu?.()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const handlePress = (action: MenuAction) => {
|
|
97
|
+
onPressAction?.({ nativeEvent: { event: actionId(action) } })
|
|
98
|
+
close()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const menuWidth = clamp(MENU_WIDTH * fontScale, MENU_WIDTH, windowWidth - SCREEN_MARGIN * 2)
|
|
102
|
+
const anchorBottom = anchor.y + anchor.height
|
|
103
|
+
const opensUpward = anchor.y + anchor.height / 2 > windowHeight / 2
|
|
104
|
+
const menuPosition = {
|
|
105
|
+
width: menuWidth,
|
|
106
|
+
left: clamp(
|
|
107
|
+
anchor.x,
|
|
108
|
+
SCREEN_MARGIN,
|
|
109
|
+
Math.max(SCREEN_MARGIN, windowWidth - menuWidth - SCREEN_MARGIN)
|
|
110
|
+
),
|
|
111
|
+
...(opensUpward
|
|
112
|
+
? {
|
|
113
|
+
bottom: windowHeight - anchor.y + MENU_OFFSET,
|
|
114
|
+
maxHeight: anchor.y - MENU_OFFSET - SCREEN_MARGIN,
|
|
115
|
+
}
|
|
116
|
+
: {
|
|
117
|
+
top: anchorBottom + MENU_OFFSET,
|
|
118
|
+
maxHeight: windowHeight - anchorBottom - MENU_OFFSET - SCREEN_MARGIN,
|
|
119
|
+
}),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<Pressable ref={triggerRef} style={style} testID={testID} onPress={open} accessible={false}>
|
|
125
|
+
{children}
|
|
126
|
+
</Pressable>
|
|
127
|
+
<Modal
|
|
128
|
+
visible={expanded}
|
|
129
|
+
transparent
|
|
130
|
+
animationType="fade"
|
|
131
|
+
statusBarTranslucent={edgeToEdge}
|
|
132
|
+
navigationBarTranslucent={edgeToEdge}
|
|
133
|
+
onRequestClose={close}
|
|
134
|
+
>
|
|
135
|
+
<Pressable
|
|
136
|
+
style={styles.scrim}
|
|
137
|
+
onPress={close}
|
|
138
|
+
accessibilityRole="button"
|
|
139
|
+
accessibilityLabel="Close menu"
|
|
140
|
+
/>
|
|
141
|
+
<View style={[styles.menu, menuPosition]} accessibilityRole="menu">
|
|
142
|
+
<ScrollView bounces={false}>
|
|
143
|
+
{buildRows(actions).map((row, index) =>
|
|
144
|
+
row.kind === 'separator' ? (
|
|
145
|
+
<View key={index} style={styles.separator} />
|
|
146
|
+
) : (
|
|
147
|
+
<PopoverMenuItem
|
|
148
|
+
key={index}
|
|
149
|
+
action={row.action}
|
|
150
|
+
onPress={() => handlePress(row.action)}
|
|
151
|
+
/>
|
|
152
|
+
)
|
|
153
|
+
)}
|
|
154
|
+
</ScrollView>
|
|
155
|
+
</View>
|
|
156
|
+
</Modal>
|
|
157
|
+
</>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface PopoverMenuItemProps {
|
|
162
|
+
action: MenuAction
|
|
163
|
+
onPress: () => void
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function PopoverMenuItem({ action, onPress }: PopoverMenuItemProps) {
|
|
167
|
+
const styles = useStyles()
|
|
168
|
+
const { colors } = useTheme()
|
|
169
|
+
const androidRippleColor = useCreateAndroidRippleColor({ color: colors.fillColorNeutral050Base })
|
|
170
|
+
const selected = action.state === 'on'
|
|
171
|
+
const disabled = action.attributes?.disabled === true
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Pressable
|
|
175
|
+
style={({ pressed }) => [
|
|
176
|
+
styles.item,
|
|
177
|
+
pressed && platformPressedOpacityStyle,
|
|
178
|
+
disabled && styles.itemDisabled,
|
|
179
|
+
]}
|
|
180
|
+
onPress={onPress}
|
|
181
|
+
disabled={disabled}
|
|
182
|
+
accessibilityRole="menuitem"
|
|
183
|
+
accessibilityState={{ selected, disabled }}
|
|
184
|
+
android_ripple={{ color: androidRippleColor, borderless: false, foreground: true }}
|
|
185
|
+
>
|
|
186
|
+
<Text style={styles.itemLabel}>{action.title}</Text>
|
|
187
|
+
{selected && (
|
|
188
|
+
<Icon name="general.check" style={styles.itemCheck} accessibilityElementsHidden />
|
|
189
|
+
)}
|
|
190
|
+
</Pressable>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =================================
|
|
195
|
+
// ====== Styles ===================
|
|
196
|
+
// =================================
|
|
197
|
+
|
|
198
|
+
const useStyles = () => {
|
|
199
|
+
const { colors } = useTheme()
|
|
200
|
+
|
|
201
|
+
return StyleSheet.create({
|
|
202
|
+
scrim: {
|
|
203
|
+
...StyleSheet.absoluteFill,
|
|
204
|
+
},
|
|
205
|
+
menu: {
|
|
206
|
+
position: 'absolute',
|
|
207
|
+
paddingVertical: 4,
|
|
208
|
+
borderRadius: 8,
|
|
209
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
210
|
+
borderColor: colors.fillColorNeutral050Base,
|
|
211
|
+
backgroundColor: colors.fillColorNeutral100Inverted,
|
|
212
|
+
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.15)',
|
|
213
|
+
elevation: 4,
|
|
214
|
+
overflow: 'hidden',
|
|
215
|
+
},
|
|
216
|
+
item: {
|
|
217
|
+
flexDirection: 'row',
|
|
218
|
+
alignItems: 'center',
|
|
219
|
+
gap: 12,
|
|
220
|
+
paddingHorizontal: 16,
|
|
221
|
+
paddingVertical: 12,
|
|
222
|
+
},
|
|
223
|
+
itemDisabled: {
|
|
224
|
+
opacity: 0.4,
|
|
225
|
+
},
|
|
226
|
+
itemLabel: {
|
|
227
|
+
flex: 1,
|
|
228
|
+
},
|
|
229
|
+
itemCheck: {
|
|
230
|
+
fontSize: tokens.fontSizeSm,
|
|
231
|
+
color: colors.interaction,
|
|
232
|
+
},
|
|
233
|
+
separator: {
|
|
234
|
+
height: StyleSheet.hairlineWidth,
|
|
235
|
+
marginVertical: 4,
|
|
236
|
+
backgroundColor: colors.fillColorNeutral050Base,
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
}
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
ChatAdapters,
|
|
8
8
|
ClipboardAdapter,
|
|
9
9
|
ImagePickerAdapter,
|
|
10
|
-
MenuAdapter,
|
|
11
10
|
VideoAdapter,
|
|
12
11
|
} from '../../../utils/native_adapters'
|
|
13
12
|
import { ConversationsScreen } from '../conversations_screen'
|
|
@@ -41,7 +40,7 @@ jest.mock('../components/filter_dropdown_list_header_component', () => {
|
|
|
41
40
|
return { FilterDropdownListHeaderComponent: () => <Text>Filter dropdown header</Text> }
|
|
42
41
|
})
|
|
43
42
|
|
|
44
|
-
const configureAdapters = (
|
|
43
|
+
const configureAdapters = () => {
|
|
45
44
|
ChatAdapters.configure({
|
|
46
45
|
clipboard: new ClipboardAdapter({
|
|
47
46
|
getStringAsync: async () => '',
|
|
@@ -53,7 +52,6 @@ const configureAdapters = ({ menu }: { menu: boolean }) => {
|
|
|
53
52
|
openCameraAsync: async () => ({ canceled: true, assets: null }),
|
|
54
53
|
openImageLibraryAsync: async () => ({ canceled: true, assets: null }),
|
|
55
54
|
}),
|
|
56
|
-
...(menu ? { menu: new MenuAdapter({ View: () => null }) } : {}),
|
|
57
55
|
})
|
|
58
56
|
}
|
|
59
57
|
|
|
@@ -80,7 +78,7 @@ const renderWithFeatures = (enabledFeatures: string[]) => {
|
|
|
80
78
|
|
|
81
79
|
describe('ConversationsScreen header gating', () => {
|
|
82
80
|
beforeEach(() => {
|
|
83
|
-
configureAdapters(
|
|
81
|
+
configureAdapters()
|
|
84
82
|
})
|
|
85
83
|
|
|
86
84
|
it('shows the search header with only the QA flag on', async () => {
|
|
@@ -107,14 +105,6 @@ describe('ConversationsScreen header gating', () => {
|
|
|
107
105
|
expect(await view.findByText('Filter dropdown header')).toBeTruthy()
|
|
108
106
|
})
|
|
109
107
|
|
|
110
|
-
it('falls back to the search header when the host has no menu adapter', async () => {
|
|
111
|
-
configureAdapters({ menu: false })
|
|
112
|
-
|
|
113
|
-
const view = renderWithFeatures(['QA_MOBILE_filter_dropdown', 'ROLLOUT_MOBILE_search'])
|
|
114
|
-
|
|
115
|
-
expect(await view.findByText('Search header')).toBeTruthy()
|
|
116
|
-
})
|
|
117
|
-
|
|
118
108
|
it('shows the default header with neither flag on', async () => {
|
|
119
109
|
const view = renderWithFeatures(['ROLLOUT_WEB_search'])
|
|
120
110
|
|
|
@@ -21,15 +21,6 @@ const mockSearchEnabled = jest.fn()
|
|
|
21
21
|
const mockCanBrowseArchived = jest.fn()
|
|
22
22
|
const mockGroupsByGraphId: Record<string, { name: string }> = {}
|
|
23
23
|
|
|
24
|
-
jest.mock('react-native', () => {
|
|
25
|
-
const actual = jest.requireActual('react-native')
|
|
26
|
-
return Object.defineProperty(actual, 'useWindowDimensions', {
|
|
27
|
-
value: jest.fn(() => ({ fontScale: 1 })),
|
|
28
|
-
configurable: true,
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
const mockUseWindowDimensions = jest.requireMock('react-native').useWindowDimensions
|
|
32
|
-
|
|
33
24
|
jest.mock('@react-navigation/native', () => ({
|
|
34
25
|
...jest.requireActual('@react-navigation/native'),
|
|
35
26
|
useNavigation: () => ({ setParams: mockSetParams, navigate: mockNavigate }),
|
|
@@ -130,46 +121,10 @@ describe('FilterDropdownListHeaderComponent', () => {
|
|
|
130
121
|
mockSetParams.mockClear()
|
|
131
122
|
mockSearchEnabled.mockReturnValue(true)
|
|
132
123
|
mockCanBrowseArchived.mockReturnValue(false)
|
|
133
|
-
mockUseWindowDimensions.mockReturnValue({ fontScale: 1 })
|
|
134
124
|
setCapabilities({ groups: true, teams: true })
|
|
135
125
|
setRouteParams({})
|
|
136
126
|
})
|
|
137
127
|
|
|
138
|
-
it('keeps the dropdown and actions on one line at the default font scale', () => {
|
|
139
|
-
mockUseWindowDimensions.mockReturnValue({ fontScale: 1 })
|
|
140
|
-
|
|
141
|
-
render(<FilterDropdownListHeaderComponent />)
|
|
142
|
-
|
|
143
|
-
expect(screen.getByTestId('conversations-filter-heading-row')).toHaveStyle({
|
|
144
|
-
flexDirection: 'row',
|
|
145
|
-
})
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it('stacks the actions under the dropdown at the first font size above the default', () => {
|
|
149
|
-
mockUseWindowDimensions.mockReturnValue({ fontScale: 1.12 })
|
|
150
|
-
|
|
151
|
-
render(<FilterDropdownListHeaderComponent />)
|
|
152
|
-
|
|
153
|
-
expect(screen.getByTestId('conversations-filter-heading-row')).toHaveStyle({
|
|
154
|
-
flexDirection: 'column',
|
|
155
|
-
})
|
|
156
|
-
})
|
|
157
|
-
|
|
158
|
-
it('caps the trigger to the room beside the actions so a long label truncates', () => {
|
|
159
|
-
render(<FilterDropdownListHeaderComponent />)
|
|
160
|
-
|
|
161
|
-
fireEvent(screen.getByTestId('conversations-filter-heading-row'), 'layout', {
|
|
162
|
-
nativeEvent: { layout: { width: 360 } },
|
|
163
|
-
})
|
|
164
|
-
fireEvent(screen.getByTestId('conversations-filter-actions'), 'layout', {
|
|
165
|
-
nativeEvent: { layout: { width: 160 } },
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
expect(screen.getByRole('button', { name: 'All conversations' })).toHaveStyle({
|
|
169
|
-
maxWidth: 156,
|
|
170
|
-
})
|
|
171
|
-
})
|
|
172
|
-
|
|
173
128
|
it('offers every filter the person can use, then More filters', () => {
|
|
174
129
|
render(<FilterDropdownListHeaderComponent />)
|
|
175
130
|
|
|
@@ -344,13 +299,4 @@ describe('FilterDropdownListHeaderComponent', () => {
|
|
|
344
299
|
|
|
345
300
|
expect(mockNavigate).toHaveBeenCalledWith('Search')
|
|
346
301
|
})
|
|
347
|
-
|
|
348
|
-
it('renders the search button after the other actions', () => {
|
|
349
|
-
const view = render(<FilterDropdownListHeaderComponent />)
|
|
350
|
-
|
|
351
|
-
const buttons = view.getAllByRole('button').map(button => button.props.accessibilityLabel)
|
|
352
|
-
|
|
353
|
-
expect(buttons.indexOf('Search')).toBeGreaterThan(buttons.indexOf('Chat settings'))
|
|
354
|
-
expect(buttons.indexOf('Search')).toBeGreaterThan(buttons.indexOf('New conversation'))
|
|
355
|
-
})
|
|
356
302
|
})
|