diff --git a/examples/expo-example/.rnstorybook/storybook.requires.ts b/examples/expo-example/.rnstorybook/storybook.requires.ts index 1c3ca9365c..e2a1116775 100644 --- a/examples/expo-example/.rnstorybook/storybook.requires.ts +++ b/examples/expo-example/.rnstorybook/storybook.requires.ts @@ -64,8 +64,9 @@ const annotations = [ globalThis.STORIES = normalizedStories; globalThis.STORYBOOK_WEBSOCKET = { + host: '192.168.1.172', port: 7007, - secured: true, + secured: false, }; module?.hot?.accept?.(); diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index 9933e415d5..381ca71b94 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -4,6 +4,7 @@ import { fn } from 'storybook/test'; const meta = { component: ActionButton, + tags: ['actions'], parameters: { notes: ` # Button diff --git a/examples/expo-example/components/BackgroundExample/BackgroundCsf.stories.tsx b/examples/expo-example/components/BackgroundExample/BackgroundCsf.stories.tsx index 103317e2fb..4c9fef05b6 100644 --- a/examples/expo-example/components/BackgroundExample/BackgroundCsf.stories.tsx +++ b/examples/expo-example/components/BackgroundExample/BackgroundCsf.stories.tsx @@ -11,6 +11,7 @@ const styles = StyleSheet.create({ const meta = { component: Background, + tags: ['backgrounds'], // parameters: { // backgrounds: { // default: 'warm', diff --git a/examples/expo-example/components/BackgroundExample/ThemedCard.stories.tsx b/examples/expo-example/components/BackgroundExample/ThemedCard.stories.tsx index bf0795104b..085ddd9667 100644 --- a/examples/expo-example/components/BackgroundExample/ThemedCard.stories.tsx +++ b/examples/expo-example/components/BackgroundExample/ThemedCard.stories.tsx @@ -23,6 +23,7 @@ const withAppTheme: Meta['decorators'] = [ const meta = { title: 'BackgroundExample/ThemedCard', component: ThemedCard, + tags: ['backgrounds', 'theme'], decorators: withAppTheme, args: { title: 'Themed Card', diff --git a/examples/expo-example/components/ControlExamples/Array/Array.stories.tsx b/examples/expo-example/components/ControlExamples/Array/Array.stories.tsx index f6746c6565..a870dcd25e 100644 --- a/examples/expo-example/components/ControlExamples/Array/Array.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Array/Array.stories.tsx @@ -3,6 +3,7 @@ import { Array } from './Array'; const meta = { component: Array, + tags: ['controls', 'array'], args: { list: ['a', 'b', 'c'], }, diff --git a/examples/expo-example/components/ControlExamples/Boolean/Boolean.stories.tsx b/examples/expo-example/components/ControlExamples/Boolean/Boolean.stories.tsx index c8cf8f6f31..764de5ad4d 100644 --- a/examples/expo-example/components/ControlExamples/Boolean/Boolean.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Boolean/Boolean.stories.tsx @@ -3,6 +3,7 @@ import { Switch } from './Boolean'; const meta = { component: Switch, + tags: ['controls', 'boolean'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/ControlExamples/Color/Color.stories.tsx b/examples/expo-example/components/ControlExamples/Color/Color.stories.tsx index d1734cf6f8..8ac836c65b 100644 --- a/examples/expo-example/components/ControlExamples/Color/Color.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Color/Color.stories.tsx @@ -9,6 +9,7 @@ const meta = { }, }, component: Color, + tags: ['controls', 'color'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/ControlExamples/ControlExample/ControlExample.stories.tsx b/examples/expo-example/components/ControlExamples/ControlExample/ControlExample.stories.tsx index 65070ba30b..07ba723d8c 100644 --- a/examples/expo-example/components/ControlExamples/ControlExample/ControlExample.stories.tsx +++ b/examples/expo-example/components/ControlExamples/ControlExample/ControlExample.stories.tsx @@ -3,6 +3,7 @@ import { ControlExample } from './ControlExample'; const meta = { component: ControlExample, + tags: ['controls'], args: { name: 'Storyteller', age: 70, diff --git a/examples/expo-example/components/ControlExamples/Date/Date.stories.tsx b/examples/expo-example/components/ControlExamples/Date/Date.stories.tsx index 0f9d275e49..564f5ec583 100644 --- a/examples/expo-example/components/ControlExamples/Date/Date.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Date/Date.stories.tsx @@ -5,6 +5,7 @@ const date = new Date(1983, 1, 25); const meta = { component: DateString, + tags: ['controls', 'date'], args: { date: date }, argTypes: { date: { control: { type: 'date' } } }, } satisfies Meta; diff --git a/examples/expo-example/components/ControlExamples/Number/Number.stories.tsx b/examples/expo-example/components/ControlExamples/Number/Number.stories.tsx index 4d037a652a..309b0f57ee 100644 --- a/examples/expo-example/components/ControlExamples/Number/Number.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Number/Number.stories.tsx @@ -3,6 +3,7 @@ import { Multiply } from './Number'; const meta = { component: Multiply, + tags: ['controls', 'number'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/ControlExamples/Object/Object.stories.tsx b/examples/expo-example/components/ControlExamples/Object/Object.stories.tsx index 9fadedc0a5..94329f0eeb 100644 --- a/examples/expo-example/components/ControlExamples/Object/Object.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Object/Object.stories.tsx @@ -3,6 +3,7 @@ import { Movie } from './Object'; const meta = { component: Movie, + tags: ['controls', 'object'], args: { filmInfo: { releaseYear: 1982, diff --git a/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx b/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx index cd2abb2d9d..8cd1202f80 100644 --- a/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Radio/Radio.stories.tsx @@ -5,6 +5,7 @@ const radio_stations = ['104.8MHz', '909 kHz', '90FM']; const meta = { component: Radio, + tags: ['controls', 'radio'], argTypes: { selection: { diff --git a/examples/expo-example/components/ControlExamples/Reproductions/SelectWithNumber.stories.tsx b/examples/expo-example/components/ControlExamples/Reproductions/SelectWithNumber.stories.tsx index b2eb8ebdb4..e0e121fa69 100644 --- a/examples/expo-example/components/ControlExamples/Reproductions/SelectWithNumber.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Reproductions/SelectWithNumber.stories.tsx @@ -4,6 +4,7 @@ import { MyButton } from './SelectWithNumber'; const meta = { component: MyButton, + tags: ['controls', 'reproduction'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/ControlExamples/Reproductions/UseArgsLatency.stories.tsx b/examples/expo-example/components/ControlExamples/Reproductions/UseArgsLatency.stories.tsx index 206e1cfdf8..f95eeb42ef 100644 --- a/examples/expo-example/components/ControlExamples/Reproductions/UseArgsLatency.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Reproductions/UseArgsLatency.stories.tsx @@ -59,6 +59,7 @@ const StorySurface = ({ const meta = { title: 'ControlExamples/Reproductions/UseArgsLatency', component: StorySurface, + tags: ['controls', 'reproduction', 'useargs'], args: { title: 'useArgs latency repro', description: 'Compare how this segmented control feels under different update strategies.', diff --git a/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx b/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx index 84a6058553..1c7f074702 100644 --- a/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Select/Select.stories.tsx @@ -5,6 +5,7 @@ const arrows = { ArrowUp: '⬆', ArrowDown: '⬇', ArrowLeft: '⬅️', ArrowRig const meta = { component: SelectExample, + tags: ['controls', 'select'], argTypes: { arrow: { options: Object.values(arrows), diff --git a/examples/expo-example/components/ControlExamples/Text/Text.stories.tsx b/examples/expo-example/components/ControlExamples/Text/Text.stories.tsx index 2162fac250..60c80dc930 100644 --- a/examples/expo-example/components/ControlExamples/Text/Text.stories.tsx +++ b/examples/expo-example/components/ControlExamples/Text/Text.stories.tsx @@ -3,6 +3,7 @@ import { Heading } from './Text'; const meta = { component: Heading, + tags: ['controls', 'text'], args: { text: 'Hello world!' }, } satisfies Meta; diff --git a/examples/expo-example/components/ControlExamples/WebCompatibility/WebCompatibility.stories.tsx b/examples/expo-example/components/ControlExamples/WebCompatibility/WebCompatibility.stories.tsx index 2f646968e4..3081f2f239 100644 --- a/examples/expo-example/components/ControlExamples/WebCompatibility/WebCompatibility.stories.tsx +++ b/examples/expo-example/components/ControlExamples/WebCompatibility/WebCompatibility.stories.tsx @@ -12,6 +12,7 @@ const ComponentExample = (props: any) => { export default { component: ComponentExample, + tags: ['controls', 'web'], argTypes: { boolean: { control: 'boolean' }, color: { control: 'color' }, diff --git a/examples/expo-example/components/DeepControls/DeepControls.stories.tsx b/examples/expo-example/components/DeepControls/DeepControls.stories.tsx index f5e19f8946..eb74cf87a8 100644 --- a/examples/expo-example/components/DeepControls/DeepControls.stories.tsx +++ b/examples/expo-example/components/DeepControls/DeepControls.stories.tsx @@ -22,6 +22,7 @@ const DeepControls = ({ const meta = { component: DeepControls, + tags: ['controls', 'deep-controls'], } satisfies Meta; export default meta; @@ -72,4 +73,5 @@ export const Basic: DeepControlsStory = { control: 'number', }, }, + tags: ['controls', 'deep-controls'], }; diff --git a/examples/expo-example/components/HiddenControls/HiddenControls.stories.tsx b/examples/expo-example/components/HiddenControls/HiddenControls.stories.tsx index 3e72313ce8..d2ae118a8d 100644 --- a/examples/expo-example/components/HiddenControls/HiddenControls.stories.tsx +++ b/examples/expo-example/components/HiddenControls/HiddenControls.stories.tsx @@ -20,6 +20,7 @@ const HiddenControls = ({ value, padding }: HiddenControlsProps) => { const meta = { component: HiddenControls, + tags: ['controls', 'conditional'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/InputExample/TextInput.stories.tsx b/examples/expo-example/components/InputExample/TextInput.stories.tsx index 6de0a40da7..f9dc75969f 100644 --- a/examples/expo-example/components/InputExample/TextInput.stories.tsx +++ b/examples/expo-example/components/InputExample/TextInput.stories.tsx @@ -3,6 +3,7 @@ import { Input } from './TextInput'; const meta = { component: Input, + tags: ['forms', 'input'], parameters: { notes: 'Use this example to test the software keyboard related issues.', }, diff --git a/examples/expo-example/components/InteractionExample/InteractionExample.stories.tsx b/examples/expo-example/components/InteractionExample/InteractionExample.stories.tsx index 6861749ad3..d0ab0a3ae9 100644 --- a/examples/expo-example/components/InteractionExample/InteractionExample.stories.tsx +++ b/examples/expo-example/components/InteractionExample/InteractionExample.stories.tsx @@ -6,6 +6,7 @@ const Placeholder = () => ; const meta = { component: Placeholder, + tags: ['interaction', 'keyboard'], parameters: { notes: ` Use these example to test that tapping the story view will dismiss the keyboard, diff --git a/examples/expo-example/components/LoginDocsExample/Button/Button.stories.tsx b/examples/expo-example/components/LoginDocsExample/Button/Button.stories.tsx index c8ed4e14ed..4ba115bb0e 100644 --- a/examples/expo-example/components/LoginDocsExample/Button/Button.stories.tsx +++ b/examples/expo-example/components/LoginDocsExample/Button/Button.stories.tsx @@ -4,6 +4,7 @@ import { Button } from './Button'; const meta = { component: Button, + tags: ['docs', 'forms', 'button'], args: { onPress: fn(), }, diff --git a/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx b/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx index 6f45d5301b..d11542b9af 100644 --- a/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx +++ b/examples/expo-example/components/LoginDocsExample/LoginForm/LoginForm.stories.tsx @@ -4,6 +4,7 @@ import { LoginForm } from './LoginForm'; const meta = { component: LoginForm, + tags: ['docs', 'forms', 'login'], args: { onSubmit: fn(), emailLabel: 'Email Address', diff --git a/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.stories.tsx b/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.stories.tsx index 330eae778e..c26cb09cf8 100644 --- a/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.stories.tsx +++ b/examples/expo-example/components/LoginDocsExample/TextInput/TextInput.stories.tsx @@ -3,6 +3,7 @@ import { TextInput } from './TextInput'; const meta = { component: TextInput, + tags: ['docs', 'forms', 'input'], args: { placeholder: 'Enter text...', }, diff --git a/examples/expo-example/components/NestingExample/ChatMessage.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessage.stories.tsx index 72aab9a882..389c032697 100644 --- a/examples/expo-example/components/NestingExample/ChatMessage.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessage.stories.tsx @@ -3,6 +3,7 @@ import { ChatMessage, ChatMessageProps } from './ChatComponents'; const meta = { component: ChatMessage, + tags: ['chat', 'message'], argTypes: { isOwn: { control: 'boolean' }, showAvatar: { control: 'boolean' }, diff --git a/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx index 96ba44a113..331a60c4e0 100644 --- a/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessageBubble.stories.tsx @@ -4,6 +4,7 @@ import { ChatBubble, ChatBubbleProps } from './ChatComponents'; const meta = { title: 'NestingExample/Message/bubble', component: ChatBubble, + tags: ['chat', 'bubble'], argTypes: { isOwn: { control: 'boolean' }, status: { diff --git a/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx index c980aa10d1..fb00ad9929 100644 --- a/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessageBubbleAgain.stories.tsx @@ -39,6 +39,7 @@ const meta = { title: 'NestingExample/Message/bubble/a very long name for a title that just keeps going and going', component: BubbleWithReactions, + tags: ['chat', 'bubble'], argTypes: { isOwn: { control: 'boolean' }, showReactions: { control: 'boolean' }, diff --git a/examples/expo-example/components/NestingExample/ChatMessageMessageInput.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessageMessageInput.stories.tsx index b10f02b166..fcc824100f 100644 --- a/examples/expo-example/components/NestingExample/ChatMessageMessageInput.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessageMessageInput.stories.tsx @@ -47,6 +47,7 @@ const styles = StyleSheet.create({ const meta = { title: 'NestingExample/MessageInput', component: InputDemo, + tags: ['chat', 'input'], argTypes: { disabled: { control: 'boolean' }, }, diff --git a/examples/expo-example/components/NestingExample/ChatMessageReactions.stories.tsx b/examples/expo-example/components/NestingExample/ChatMessageReactions.stories.tsx index c161786ecb..e79f9b3e31 100644 --- a/examples/expo-example/components/NestingExample/ChatMessageReactions.stories.tsx +++ b/examples/expo-example/components/NestingExample/ChatMessageReactions.stories.tsx @@ -30,6 +30,7 @@ const styles = StyleSheet.create({ const meta = { title: 'NestingExample/Message/Reactions', component: ReactionsDemo, + tags: ['chat', 'reactions'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/NotesExample/NotesExample.stories.tsx b/examples/expo-example/components/NotesExample/NotesExample.stories.tsx index 54f6149321..b0adff262f 100644 --- a/examples/expo-example/components/NotesExample/NotesExample.stories.tsx +++ b/examples/expo-example/components/NotesExample/NotesExample.stories.tsx @@ -2,6 +2,7 @@ import type { StoryObj, Meta } from '@storybook/react-native'; import { View, StyleSheet, Text } from 'react-native'; const NotesExampleMeta: Meta = { + tags: ['notes'], parameters: { notes: ` # H1 diff --git a/examples/expo-example/components/SafeAreaExample/SafeAreaInside.stories.tsx b/examples/expo-example/components/SafeAreaExample/SafeAreaInside.stories.tsx index f629598521..2a6a466905 100644 --- a/examples/expo-example/components/SafeAreaExample/SafeAreaInside.stories.tsx +++ b/examples/expo-example/components/SafeAreaExample/SafeAreaInside.stories.tsx @@ -4,6 +4,7 @@ import { ScrollView } from 'react-native'; const InsideSafeAreaMeta: Meta = { component: AButton, + tags: ['safe-area', 'inside'], argTypes: { onPress: { action: 'pressed the button' }, }, diff --git a/examples/expo-example/components/SafeAreaExample/SafeAreaOutside.stories.tsx b/examples/expo-example/components/SafeAreaExample/SafeAreaOutside.stories.tsx index 3d22ff1d01..e4d8b2e8ab 100644 --- a/examples/expo-example/components/SafeAreaExample/SafeAreaOutside.stories.tsx +++ b/examples/expo-example/components/SafeAreaExample/SafeAreaOutside.stories.tsx @@ -4,6 +4,7 @@ import { ScrollView } from 'react-native'; const OutsideSafeAreaMeta: Meta = { component: AButton, + tags: ['safe-area', 'outside'], parameters: { noSafeArea: true }, argTypes: { onPress: { action: 'pressed the button' }, diff --git a/examples/expo-example/components/SafeAreaExample/UsableArea.stories.tsx b/examples/expo-example/components/SafeAreaExample/UsableArea.stories.tsx index 6e99f9d7ae..32e757323e 100644 --- a/examples/expo-example/components/SafeAreaExample/UsableArea.stories.tsx +++ b/examples/expo-example/components/SafeAreaExample/UsableArea.stories.tsx @@ -18,6 +18,7 @@ function UsableAreaContent() { } const meta = { component: UsableAreaContent, + tags: ['safe-area', 'layout'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/StoryName/StoryName.stories.tsx b/examples/expo-example/components/StoryName/StoryName.stories.tsx index 597b2c0cfa..3f35b14cf3 100644 --- a/examples/expo-example/components/StoryName/StoryName.stories.tsx +++ b/examples/expo-example/components/StoryName/StoryName.stories.tsx @@ -5,6 +5,7 @@ const StoryName = ({ name }: { name: string }) => {name}; const meta = { component: StoryName, + tags: ['story-name'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/components/UserProfileCard/UserProfileCard.stories.tsx b/examples/expo-example/components/UserProfileCard/UserProfileCard.stories.tsx index 6d368862dd..2a823d6b9f 100644 --- a/examples/expo-example/components/UserProfileCard/UserProfileCard.stories.tsx +++ b/examples/expo-example/components/UserProfileCard/UserProfileCard.stories.tsx @@ -5,6 +5,7 @@ import { UserProfileCard } from './UserProfileCard'; const meta = { title: 'UserProfileCard', component: UserProfileCard, + tags: ['profile', 'card'], args: { name: 'Jane Doe', email: 'jane.doe@example.com', diff --git a/examples/expo-example/other_components/TestCase/TestCase.stories.tsx b/examples/expo-example/other_components/TestCase/TestCase.stories.tsx index d8a3b27476..a2e3107915 100644 --- a/examples/expo-example/other_components/TestCase/TestCase.stories.tsx +++ b/examples/expo-example/other_components/TestCase/TestCase.stories.tsx @@ -9,6 +9,7 @@ const TestCase = () => { const meta = { component: TestCase, + tags: ['testing'], } satisfies Meta; export default meta; diff --git a/examples/expo-example/other_components/TestCase2/TestCase2.stories.tsx b/examples/expo-example/other_components/TestCase2/TestCase2.stories.tsx index 4aab97065c..e511fad3d8 100644 --- a/examples/expo-example/other_components/TestCase2/TestCase2.stories.tsx +++ b/examples/expo-example/other_components/TestCase2/TestCase2.stories.tsx @@ -7,6 +7,7 @@ const TestCase2 = () => { const meta = { component: TestCase2, + tags: ['testing', 'globs'], } satisfies Meta; export default meta; diff --git a/packages/react-native-ui-common/package.json b/packages/react-native-ui-common/package.json index ff31ceedd9..99d77d5bd6 100644 --- a/packages/react-native-ui-common/package.json +++ b/packages/react-native-ui-common/package.json @@ -39,6 +39,7 @@ "typescript": "~5.9.3" }, "dependencies": { + "@gorhom/portal": "^1.0.14", "@nozbe/microfuzz": "^1.0.0", "@storybook/react": "10.3.0-alpha.14", "@storybook/react-native-theming": "^10.3.0-next.6", diff --git a/packages/react-native-ui-common/src/TagsFilter.tsx b/packages/react-native-ui-common/src/TagsFilter.tsx new file mode 100644 index 0000000000..a2997507a5 --- /dev/null +++ b/packages/react-native-ui-common/src/TagsFilter.tsx @@ -0,0 +1,383 @@ +import { Portal } from '@gorhom/portal'; +import { styled } from '@storybook/react-native-theming'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Pressable, ScrollView, useWindowDimensions, View, ViewStyle } from 'react-native'; +import type { API_IndexHash } from 'storybook/internal/types'; +import { Button } from './Button'; +import { + type SidebarTagSelection, + createSidebarTagFilters, + getVisibleSidebarTagFilters, + hasActiveSidebarTagSelection, +} from './util/tags'; + +const Wrapper = styled.View({ + marginBottom: 0, +}); + +const TriggerWrapper = styled.View({ + alignItems: 'flex-start', +}); + +const SelectedDot = styled.View(({ theme }) => ({ + position: 'absolute', + top: 5, + right: 5, + width: 6, + height: 6, + borderRadius: 999, + backgroundColor: theme.color.secondary, +})); + +const Panel = styled.View(({ theme }) => ({ + borderWidth: 1, + borderColor: theme.appBorderColor, + borderRadius: 8, + padding: 8, + backgroundColor: theme.background.content, + gap: 8, + boxShadow: `0 8px 24px 0 ${theme.color.border}`, + elevation: 12, +})); + +const PanelHeader = styled.View({ + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: 8, +}); + +const HeaderActions = styled.View({ + minWidth: 56, + alignItems: 'flex-end', +}); + +const PanelTitle = styled.Text(({ theme }) => ({ + color: theme.color.defaultText, + fontSize: theme.typography.size.s1, + fontWeight: theme.typography.weight.bold, +})); + +const EmptyText = styled.Text(({ theme }) => ({ + color: theme.textMutedColor, + fontSize: theme.typography.size.s1, + lineHeight: 18, +})); + +const FiltersScrollView = styled(ScrollView)({ + maxHeight: 220, +}); + +const FilterRow = styled.View({ + minHeight: 40, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 4, +}); + +const FilterToggle = styled.TouchableOpacity({ + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 10, + minHeight: 32, +}); + +const Checkbox = styled.View<{ checked: boolean }>(({ theme, checked }) => ({ + width: 18, + height: 18, + borderRadius: 4, + borderWidth: 1, + borderColor: checked ? theme.color.secondary : theme.appBorderColor, + backgroundColor: checked ? theme.color.secondary : 'transparent', + alignItems: 'center', + justifyContent: 'center', +})); + +const Checkmark = styled.Text<{ checked: boolean }>(({ theme, checked }) => ({ + color: checked ? theme.color.lightest : theme.color.defaultText, + fontSize: theme.typography.size.s1 - 1, + fontWeight: theme.typography.weight.bold, +})); + +const FilterMeta = styled.View({ + flex: 1, + gap: 2, +}); + +const FilterTitleRow = styled.View({ + flexDirection: 'row', + alignItems: 'center', + gap: 4, +}); + +const FilterTitle = styled.Text(({ theme }) => ({ + color: theme.color.defaultText, + fontSize: theme.typography.size.s1, +})); + +const FilterCount = styled.Text(({ theme }) => ({ + color: theme.textMutedColor, + fontSize: theme.typography.size.s1 - 1, +})); + +const ExcludedLabel = styled.Text(({ theme }) => ({ + color: theme.textMutedColor, + fontSize: theme.typography.size.s1 - 1, +})); + +const ExcludedCount = styled.Text(({ theme }) => ({ + color: theme.textMutedColor, + fontSize: theme.typography.size.s1 - 1, + textDecorationLine: 'line-through', +})); + +const RowActions = styled.View({ + width: 72, + alignItems: 'flex-end', +}); + +const PopoverContainer = styled.View({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1000, +}); + +const Backdrop = styled(Pressable)({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, +}); + +const PANEL_GAP = 8; +const SCREEN_PADDING = 8; +const PANEL_WIDTH = 300; +const COMPACT_TRIGGER_SIZE = 24; +const compactTriggerHitSlop = { top: 6, right: 6, bottom: 6, left: 0 }; +const filtersScrollViewStyle = { + marginRight: -6, +} satisfies ViewStyle; +const filtersScrollContentStyle = { + paddingRight: 6, +} satisfies ViewStyle; +const compactTriggerStyle = { + width: COMPACT_TRIGGER_SIZE, + height: COMPACT_TRIGGER_SIZE, + paddingHorizontal: 0, +} satisfies ViewStyle; + +type TagsFilterProps = { + storiesHash?: API_IndexHash; + selection: SidebarTagSelection; + onSelectionChange: (selection: SidebarTagSelection) => void; + TriggerIcon?: (props: { + color?: string; + width?: number; + height?: number; + size?: number; + }) => React.ReactElement; +}; + +export const TagsFilter = React.memo(function TagsFilter({ + storiesHash, + selection, + onSelectionChange, + TriggerIcon, +}: TagsFilterProps) { + const [isOpen, setIsOpen] = useState(false); + const [anchorRect, setAnchorRect] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + const triggerRef = useRef(null); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + + const filtersById = useMemo(() => createSidebarTagFilters(storiesHash), [storiesHash]); + const filters = useMemo(() => getVisibleSidebarTagFilters(filtersById), [filtersById]); + const activeCount = selection.included.size + selection.excluded.size; + const hasActiveSelection = hasActiveSidebarTagSelection(selection); + + const measureTrigger = useCallback(() => { + triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + setAnchorRect({ x: pageX, y: pageY, width, height }); + }); + }, []); + + useEffect(() => { + if (!isOpen) { + return; + } + + const frame = requestAnimationFrame(() => { + measureTrigger(); + }); + + return () => cancelAnimationFrame(frame); + }, [isOpen, measureTrigger, windowHeight, windowWidth]); + + const updateSelection = useCallback( + (id: string) => { + const included = new Set(selection.included); + const excluded = new Set(selection.excluded); + + if (included.has(id)) { + included.delete(id); + } else { + included.add(id); + excluded.delete(id); + } + + onSelectionChange({ included, excluded }); + }, + [onSelectionChange, selection.excluded, selection.included] + ); + + const toggleExcluded = useCallback( + (id: string) => { + const included = new Set(selection.included); + const excluded = new Set(selection.excluded); + + if (excluded.has(id)) { + excluded.delete(id); + } else { + excluded.add(id); + included.delete(id); + } + + onSelectionChange({ included, excluded }); + }, + [onSelectionChange, selection.excluded, selection.included] + ); + + const clearSelection = useCallback(() => { + onSelectionChange({ included: new Set(), excluded: new Set() }); + }, [onSelectionChange]); + + const popoverStyle = useMemo(() => { + if (!anchorRect) { + return undefined; + } + + const width = Math.min(PANEL_WIDTH, windowWidth - SCREEN_PADDING * 2); + const left = Math.min( + Math.max(SCREEN_PADDING, anchorRect.x), + windowWidth - width - SCREEN_PADDING + ); + const top = Math.min( + Math.max(SCREEN_PADDING, anchorRect.y + anchorRect.height + PANEL_GAP), + windowHeight - SCREEN_PADDING + ); + + return { + position: 'absolute', + top, + left, + width, + maxHeight: Math.max(180, windowHeight - top - SCREEN_PADDING), + }; + }, [anchorRect, windowHeight, windowWidth]); + + return ( + + + + + + {isOpen && popoverStyle ? ( + + + setIsOpen(false)} /> + + + Filter stories by tag + +