-
Notifications
You must be signed in to change notification settings - Fork 990
feat(mobile): wave-2 chat-view molecules — 19 molecules (Sprint 1 / PR 4 of 7) #4871
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
deb2c2f
a6b586e
e0b818a
bbf7ab1
59eeb8a
d91b0cd
5dcf47e
2c39d89
cd9786e
a107400
93545ba
272ae39
f92c0c2
2762ff6
52add93
9aee0bd
4a82191
cb71817
5931639
309c706
b915485
d7a54e7
7cc47f0
9929588
36bbbb6
01e524f
a04ca1a
a98b606
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| storybook.requires.ts |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { | ||
| PreviewRouteContext, | ||
| type PreviewRouteContextType, | ||
| } from "expo-router/build/link/preview/PreviewRouteContext"; | ||
| import { | ||
| LinkingContext, | ||
| NavigationContainer, | ||
| NavigationContainerRefContext, | ||
| ThemeProvider, | ||
| UNSTABLE_UnhandledLinkingContext as UnhandledLinkingContext, | ||
| } from "expo-router/react-navigation"; | ||
| import { type PropsWithChildren, useContext } from "react"; | ||
| import { NAV_THEME } from "@/lib/theme"; | ||
|
|
||
| import { | ||
| storybookLinkingContext, | ||
| storybookLinkingOptions, | ||
| } from "./router/LinkingContext"; | ||
| import { storybookUnhandledLinkingContext } from "./router/UnhandledLinkingContext"; | ||
|
|
||
| const storybookRoute = { | ||
| pathname: "/storybook", | ||
| params: {}, | ||
| segments: ["storybook"], | ||
| } satisfies PreviewRouteContextType; | ||
|
|
||
| export function StorybookRouterProvider({ children }: PropsWithChildren) { | ||
| const navigationRef = useContext(NavigationContainerRefContext); | ||
|
|
||
| const content = ( | ||
| <ThemeProvider value={NAV_THEME.dark}> | ||
| <UnhandledLinkingContext.Provider | ||
| value={storybookUnhandledLinkingContext} | ||
| > | ||
| <LinkingContext.Provider value={storybookLinkingContext}> | ||
| <PreviewRouteContext.Provider value={storybookRoute}> | ||
| {children} | ||
| </PreviewRouteContext.Provider> | ||
| </LinkingContext.Provider> | ||
| </UnhandledLinkingContext.Provider> | ||
| </ThemeProvider> | ||
| ); | ||
|
|
||
| if (navigationRef) { | ||
| return content; | ||
| } | ||
|
|
||
| return ( | ||
| <NavigationContainer | ||
| fallback={null} | ||
| linking={storybookLinkingOptions} | ||
| theme={NAV_THEME.dark} | ||
| > | ||
| {content} | ||
| </NavigationContainer> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // Entry point used by app/_layout.tsx when EXPO_PUBLIC_STORYBOOK=true. | ||
| // `storybook.requires` is generated by `sb-rn-get-stories` at dev-time — | ||
| // see the "storybook" script in package.json. It is gitignored. | ||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||
| import { StorybookRouterProvider } from "./StorybookRouterProvider"; | ||
| import { view } from "./storybook.requires"; | ||
|
|
||
| // Storybook v9 react-native requires the `storage` adapter to be passed | ||
| // explicitly when `shouldPersistSelection: true` — it does not auto-detect | ||
| // AsyncStorage. Without this, attempts to read the persisted story selection | ||
| // throw `TypeError: Cannot read property 'getItem' of undefined`. | ||
| const StorybookUIRoot = view.getStorybookUI({ | ||
| shouldPersistSelection: true, | ||
| storage: { | ||
| getItem: AsyncStorage.getItem, | ||
| setItem: AsyncStorage.setItem, | ||
| }, | ||
| }); | ||
|
|
||
| export default function StorybookRoot() { | ||
| return ( | ||
| <StorybookRouterProvider> | ||
| <StorybookUIRoot /> | ||
| </StorybookRouterProvider> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| /** @type {import('@storybook/react-native').StorybookConfig} */ | ||
| const main = { | ||
| stories: [ | ||
| "./stories/**/*.stories.?(ts|tsx|js|jsx)", | ||
| "../components/**/*.stories.?(ts|tsx|js|jsx)", | ||
| ], | ||
| addons: [ | ||
| "@storybook/addon-ondevice-controls", | ||
| "@storybook/addon-ondevice-actions", | ||
| ], | ||
| }; | ||
|
|
||
| module.exports = main; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| module.exports = {}; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { PortalHost } from "@rn-primitives/portal"; | ||
| import type { Preview } from "@storybook/react-native"; | ||
| import { View } from "react-native"; | ||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| // NavigationContainer is provided by StorybookRouterProvider — | ||
| // do NOT add one here or SDK 56's expo-router compat check will fail. | ||
| const preview: Preview = { | ||
| decorators: [ | ||
| (Story, context) => { | ||
| const isFullscreen = context.parameters?.layout === "fullscreen"; | ||
| return ( | ||
| <View className={cn("flex-1 bg-background", !isFullscreen && "p-4")}> | ||
| <Story /> | ||
| <PortalHost /> | ||
| </View> | ||
| ); | ||
| }, | ||
| ], | ||
| parameters: { | ||
| controls: { | ||
| matchers: { | ||
| color: /(background|color|foreground)$/i, | ||
| date: /Date$/i, | ||
| }, | ||
| }, | ||
| moduleMock: { | ||
| mockingPairedModules: { | ||
| tty: () => require("./mocks/tty"), | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export default preview; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import type { | ||
| LinkingOptions, | ||
| ParamListBase, | ||
| } from "expo-router/react-navigation"; | ||
| import * as React from "react"; | ||
|
|
||
| export const storybookLinkingOptions: LinkingOptions<ParamListBase> = { | ||
| enabled: false, | ||
| prefixes: [], | ||
| config: { | ||
| screens: { | ||
| Storybook: "*", | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export const storybookLinkingContext = { | ||
| options: storybookLinkingOptions, | ||
| }; | ||
|
|
||
| export const LinkingContext = React.createContext(storybookLinkingContext); | ||
|
|
||
| LinkingContext.displayName = "LinkingContext"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import * as React from "react"; | ||
|
|
||
| export type StorybookUnhandledLinkingContextValue = { | ||
| lastUnhandledLink: string | undefined; | ||
| setLastUnhandledLink: (lastUnhandledUrl: string | undefined) => void; | ||
| }; | ||
|
|
||
| export const storybookUnhandledLinkingContext: StorybookUnhandledLinkingContextValue = | ||
| { | ||
| lastUnhandledLink: undefined, | ||
| setLastUnhandledLink: () => {}, | ||
| }; | ||
|
|
||
| export const UnhandledLinkingContext = | ||
| React.createContext<StorybookUnhandledLinkingContextValue>( | ||
| storybookUnhandledLinkingContext, | ||
| ); | ||
|
|
||
| UnhandledLinkingContext.displayName = "UnhandledLinkingContext"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,51 @@ | ||
| import "react-native-get-random-values"; // MUST BE FIRST IMPORT | ||
| import "../global.css"; | ||
|
|
||
| import { | ||
| Geist_400Regular, | ||
| Geist_500Medium, | ||
| Geist_600SemiBold, | ||
| Geist_700Bold, | ||
| useFonts, | ||
| } from "@expo-google-fonts/geist"; | ||
| import { | ||
| GeistMono_400Regular, | ||
| GeistMono_500Medium, | ||
| } from "@expo-google-fonts/geist-mono"; | ||
| import * as SplashScreen from "expo-splash-screen"; | ||
| import { useEffect } from "react"; | ||
| import { RootLayout } from "@/screens/RootLayout"; | ||
|
|
||
| export default RootLayout; | ||
| SplashScreen.preventAutoHideAsync().catch(() => { | ||
| /* splash already hidden — fine to swallow */ | ||
| }); | ||
|
|
||
| const StorybookRoot = | ||
| process.env.EXPO_PUBLIC_STORYBOOK === "true" | ||
| ? require("../.rnstorybook").default | ||
| : null; | ||
|
|
||
| export default function App() { | ||
| const [fontsLoaded, fontError] = useFonts({ | ||
| Geist_400Regular, | ||
| Geist_500Medium, | ||
| Geist_600SemiBold, | ||
| Geist_700Bold, | ||
| GeistMono_400Regular, | ||
| GeistMono_500Medium, | ||
| }); | ||
|
|
||
| useEffect(() => { | ||
| if (fontsLoaded || fontError) { | ||
| if (fontError) { | ||
| console.error("Font loading failed:", fontError); | ||
| } | ||
| SplashScreen.hideAsync().catch(() => {}); | ||
| } | ||
| }, [fontsLoaded, fontError]); | ||
|
|
||
| if (!fontsLoaded && !fontError) return null; | ||
|
|
||
| const Root = StorybookRoot ?? RootLayout; | ||
| return <Root />; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import type { Meta, StoryObj } from "@storybook/react-native"; | ||
| import { Settings } from "lucide-react-native"; | ||
| import { AppHeader } from "./AppHeader"; | ||
|
|
||
| const meta: Meta<typeof AppHeader> = { | ||
| title: "Molecules/AppHeader", | ||
| component: AppHeader, | ||
| parameters: { | ||
| docs: { | ||
| description: { | ||
| component: | ||
| "Top navigation header on every chat view. Three-region flex: leading back IconButton (optional) + centered title/subtitle + trailing actions IconButton (optional). `isScrolled` adds a layered shadow for separation from scrolling content. Composes first-party IconButton + Text.", | ||
| }, | ||
| }, | ||
| layout: "fullscreen", | ||
| }, | ||
| args: { | ||
| title: "Fix auth bug", | ||
| subtitle: "superset · main", | ||
| showBack: true, | ||
| showActions: true, | ||
| isScrolled: false, | ||
| }, | ||
| argTypes: { | ||
| title: { control: "text" }, | ||
| subtitle: { control: "text" }, | ||
| showBack: { control: "boolean" }, | ||
| showActions: { control: "boolean" }, | ||
| isScrolled: { control: "boolean", description: "Adds 1px bottom shadow" }, | ||
| }, | ||
| }; | ||
|
|
||
| export default meta; | ||
|
|
||
| type Story = StoryObj<typeof AppHeader>; | ||
|
|
||
| export const Default: Story = {}; | ||
|
|
||
| export const NoSubtitle: Story = { | ||
| args: { subtitle: undefined }, | ||
| }; | ||
|
|
||
| export const NoBack: Story = { | ||
| args: { showBack: false, title: "Sessions" }, | ||
| }; | ||
|
|
||
| export const SimpleNoActions: Story = { | ||
| args: { subtitle: undefined, showActions: false }, | ||
| }; | ||
|
|
||
| export const Scrolled: Story = { | ||
| args: { isScrolled: true }, | ||
| }; | ||
|
|
||
| export const CustomActionsIcon: Story = { | ||
| args: { actionsIcon: Settings, actionsAccessibilityLabel: "Settings" }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { ArrowLeft, type LucideIcon, MoreVertical } from "lucide-react-native"; | ||
| import { View, type ViewProps } from "react-native"; | ||
| import { IconButton } from "@/components/IconButton"; | ||
| import { Text } from "@/components/ui/text"; | ||
| import { cn } from "@/lib/utils"; | ||
|
|
||
| export type AppHeaderProps = ViewProps & { | ||
| title: string; | ||
| subtitle?: string; | ||
| /** Show the leading back button. Default true. */ | ||
| showBack?: boolean; | ||
| onBack?: () => void; | ||
| backAccessibilityLabel?: string; | ||
| /** Show the trailing actions button. Default true. */ | ||
| showActions?: boolean; | ||
| onActions?: () => void; | ||
| actionsAccessibilityLabel?: string; | ||
| /** Override the trailing actions icon (default MoreVertical). */ | ||
| actionsIcon?: LucideIcon; | ||
| /** Adds a layered shadow for visual separation from scrolling content. */ | ||
| isScrolled?: boolean; | ||
| }; | ||
|
|
||
| /** | ||
| * Top navigation header on every chat view (UC-RENDER-01 §A, UC-SESS-04 §A). | ||
| * | ||
| * Per mol-app-header spec: | ||
| * - Three-region flex: leading back (optional) + centered title/subtitle + trailing actions (optional) | ||
| * - Subtitle (project · branch) appears below the title in --md type-meta | ||
| * - `isScrolled` adds a 1px bottom shadow for layered separation | ||
| * | ||
| * Composes first-party IconButton + Text. | ||
| */ | ||
| export function AppHeader({ | ||
| title, | ||
| subtitle, | ||
| showBack = true, | ||
| onBack, | ||
| backAccessibilityLabel = "Back to sessions", | ||
| showActions = true, | ||
| onActions, | ||
| actionsAccessibilityLabel = "Session actions", | ||
| actionsIcon = MoreVertical, | ||
|
Comment on lines
+37
to
+43
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prevent no-op header actions when callbacks are missing. Both action buttons can render as tappable controls even when Suggested diff- showBack = true,
+ showBack = true,
onBack,
@@
- showActions = true,
+ showActions = true,
onActions,
@@
- {showBack ? (
+ {showBack && onBack ? (
<IconButton
@@
- {showActions ? (
+ {showActions && onActions ? (
<IconButtonAlso applies to: 58-65, 86-93 🤖 Prompt for AI Agents |
||
| isScrolled = false, | ||
| className, | ||
| ...props | ||
| }: AppHeaderProps) { | ||
| return ( | ||
| <View | ||
| accessibilityRole="header" | ||
| className={cn( | ||
| "flex-row items-center min-h-touch-min px-3 py-2 bg-background border-b border-border", | ||
| isScrolled && "shadow-sm", | ||
| className, | ||
| )} | ||
| {...props} | ||
| > | ||
| {showBack ? ( | ||
| <IconButton | ||
| icon={ArrowLeft} | ||
| accessibilityLabel={backAccessibilityLabel} | ||
| variant="ghost" | ||
| size="md" | ||
| onPress={onBack} | ||
| /> | ||
| ) : ( | ||
| <View className="w-1" /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use fixed-width spacers matching icon-button width.
Suggested diff- <View className="w-1" />
+ <View className="w-touch-min" />
@@
- <View className="w-1" />
+ <View className="w-touch-min" />Also applies to: 95-95 🤖 Prompt for AI Agents |
||
| )} | ||
|
|
||
| <View | ||
| className={cn( | ||
| "flex-1 gap-0.5", | ||
| showBack ? "items-center" : "items-start pl-2", | ||
| )} | ||
| > | ||
| <Text className="font-semibold text-foreground" numberOfLines={1}> | ||
| {title} | ||
| </Text> | ||
| {subtitle ? ( | ||
| <Text variant="muted" className="text-xs" numberOfLines={1}> | ||
| {subtitle} | ||
| </Text> | ||
| ) : null} | ||
| </View> | ||
|
|
||
| {showActions ? ( | ||
| <IconButton | ||
| icon={actionsIcon} | ||
| accessibilityLabel={actionsAccessibilityLabel} | ||
| variant="ghost" | ||
| size="md" | ||
| onPress={onActions} | ||
| /> | ||
| ) : ( | ||
| <View className="w-1" /> | ||
| )} | ||
| </View> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { AppHeader, type AppHeaderProps } from "./AppHeader"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Include
screensstories in Storybook discovery.The current
storiesglobs skipapps/mobile/screens/**/*.stories.*, so those stories in this cohort won’t load.Suggested diff
const main = { stories: [ "./stories/**/*.stories.?(ts|tsx|js|jsx)", "../components/**/*.stories.?(ts|tsx|js|jsx)", + "../screens/**/*.stories.?(ts|tsx|js|jsx)", ],📝 Committable suggestion
🤖 Prompt for AI Agents