diff --git a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx
index c1a04c14ea..5073dddc9a 100644
--- a/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx
+++ b/client/packages/lowcoder/src/comps/comps/layout/mobileTabLayout.tsx
@@ -18,9 +18,9 @@ import { ExternalEditorContext } from "util/context/ExternalEditorContext";
import { default as Skeleton } from "antd/es/skeleton";
import { hiddenPropertyView } from "comps/utils/propertyUtils";
import { dropdownControl } from "@lowcoder-ee/comps/controls/dropdownControl";
-import { DataOption, DataOptionType, ModeOptions, menuItemStyleOptions, mobileNavJsonMenuItems } from "./navLayoutConstants";
+import { DataOption, DataOptionType, menuItemStyleOptions, mobileNavJsonMenuItems, MobileModeOptions, MobileMode, HamburgerPositionOptions, DrawerPlacementOptions } from "./navLayoutConstants";
import { styleControl } from "@lowcoder-ee/comps/controls/styleControl";
-import { NavLayoutItemActiveStyle, NavLayoutItemActiveStyleType, NavLayoutItemHoverStyle, NavLayoutItemHoverStyleType, NavLayoutItemStyle, NavLayoutItemStyleType, NavLayoutStyle, NavLayoutStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants";
+import { HamburgerButtonStyle, DrawerContainerStyle, NavLayoutItemActiveStyle, NavLayoutItemActiveStyleType, NavLayoutItemHoverStyle, NavLayoutItemHoverStyleType, NavLayoutItemStyle, NavLayoutItemStyleType, NavLayoutStyle, NavLayoutStyleType } from "@lowcoder-ee/comps/controls/styleControlConstants";
import Segmented from "antd/es/segmented";
import { controlItem } from "components/control";
import { check } from "@lowcoder-ee/util/convertUtils";
@@ -34,6 +34,8 @@ import { LayoutActionComp } from "./layoutActionComp";
import { defaultTheme } from "@lowcoder-ee/constants/themeConstants";
import { clickEvent, eventHandlerControl } from "@lowcoder-ee/comps/controls/eventHandlerControl";
import { childrenToProps } from "@lowcoder-ee/comps/generators/multi";
+import { useAppPathParam } from "util/hooks";
+import { ALL_APPLICATIONS_URL } from "constants/routesURL";
const TabBar = React.lazy(() => import("antd-mobile/es/components/tab-bar"));
const TabBarItem = React.lazy(() =>
@@ -41,6 +43,7 @@ const TabBarItem = React.lazy(() =>
default: module.TabBarItem,
}))
);
+const Popup = React.lazy(() => import("antd-mobile/es/components/popup"));
const EventOptions = [clickEvent] as const;
const AppViewContainer = styled.div`
@@ -65,6 +68,139 @@ const TabLayoutViewContainer = styled.div<{
flex-direction: column;
`;
+const HamburgerButton = styled.button<{
+ $size: string;
+ $position: string; // bottom-right | bottom-left | top-right | top-left
+ $zIndex: number;
+ $background?: string;
+ $borderColor?: string;
+ $radius?: string;
+ $margin?: string;
+ $padding?: string;
+ $borderWidth?: string;
+}>`
+ position: fixed;
+ ${(props) => (props.$position.includes('bottom') ? 'bottom: 16px;' : 'top: 16px;')}
+ ${(props) => (props.$position.includes('right') ? 'right: 16px;' : 'left: 16px;')}
+ width: ${(props) => props.$size};
+ height: ${(props) => props.$size};
+ border-radius: ${(props) => props.$radius || '50%'};
+ border: ${(props) => props.$borderWidth || '1px'} solid ${(props) => props.$borderColor || 'rgba(0,0,0,0.1)'};
+ background: ${(props) => props.$background || 'white'};
+ margin: ${(props) => props.$margin || '0px'};
+ padding: ${(props) => props.$padding || '0px'};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: ${(props) => props.$zIndex};
+ cursor: pointer;
+ box-shadow: 0 6px 16px rgba(0,0,0,0.15);
+`;
+
+const BurgerIcon = styled.div<{
+ $lineColor?: string;
+}>`
+ width: 60%;
+ height: 2px;
+ background: ${(p) => p.$lineColor || '#333'};
+ position: relative;
+ &::before, &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background: inherit;
+ }
+ &::before { top: -6px; }
+ &::after { top: 6px; }
+`;
+
+const IconWrapper = styled.div<{
+ $iconColor?: string;
+}>`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ svg {
+ color: ${(p) => p.$iconColor || 'inherit'};
+ fill: ${(p) => p.$iconColor || 'currentColor'};
+ }
+`;
+
+const DrawerContent = styled.div<{
+ $background: string;
+ $padding?: string;
+ $borderColor?: string;
+ $borderWidth?: string;
+ $margin?: string;
+}>`
+ background: ${(p) => p.$background};
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ padding: ${(p) => p.$padding || '12px'};
+ margin: ${(p) => p.$margin || '0px'};
+ box-sizing: border-box;
+ border: ${(p) => p.$borderWidth || '1px'} solid ${(p) => p.$borderColor || 'transparent'};
+`;
+
+const DrawerHeader = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+`;
+
+const DrawerCloseButton = styled.button<{
+ $color: string;
+}>`
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: ${(p) => p.$color};
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 16px;
+`;
+
+const DrawerList = styled.div<{
+ $itemStyle: NavLayoutItemStyleType;
+ $hoverStyle: NavLayoutItemHoverStyleType;
+ $activeStyle: NavLayoutItemActiveStyleType;
+}>`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .drawer-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background-color: ${(p) => p.$itemStyle.background};
+ color: ${(p) => p.$itemStyle.text};
+ border-radius: ${(p) => p.$itemStyle.radius};
+ border: 1px solid ${(p) => p.$itemStyle.border};
+ margin: ${(p) => p.$itemStyle.margin};
+ padding: ${(p) => p.$itemStyle.padding};
+ cursor: pointer;
+ user-select: none;
+ }
+ .drawer-item:hover {
+ background-color: ${(p) => p.$hoverStyle.background};
+ color: ${(p) => p.$hoverStyle.text};
+ border: 1px solid ${(p) => p.$hoverStyle.border};
+ }
+ .drawer-item.active {
+ background-color: ${(p) => p.$activeStyle.background};
+ color: ${(p) => p.$activeStyle.text};
+ border: 1px solid ${(p) => p.$activeStyle.border};
+ }
+`;
+
const TabBarWrapper = styled.div<{
$readOnly: boolean,
$canvasBg: string,
@@ -116,7 +252,7 @@ const StyledTabBar = styled(TabBar)<{
.adm-tab-bar-item-icon, .adm-tab-bar-item-title {
color: ${(props) => props.$tabStyle.text};
}
- .adm-tab-bar-item-icon, {
+ .adm-tab-bar-item-icon {
font-size: ${(props) => props.$navIconSize};
}
@@ -287,6 +423,73 @@ const TabOptionComp = (function () {
.build();
})();
+function renderDataSection(children: any): any {
+ return (
+
+ {children.dataOptionType.propertyView({
+ radioButton: true,
+ type: "oneline",
+ })}
+ {children.dataOptionType.getView() === DataOption.Manual
+ ? children.tabs.propertyView({})
+ : children.jsonItems.propertyView({
+ label: "Json Data",
+ })}
+
+ );
+}
+
+function renderEventHandlersSection(children: any): any {
+ return (
+
+ {children.onEvent.getPropertyView()}
+
+ );
+}
+
+function renderHamburgerLayoutSection(children: any): any {
+ const drawerPlacement = children.drawerPlacement.getView();
+ return (
+ <>
+ {children.hamburgerIcon.propertyView({ label: "Menu Icon" })}
+ {children.drawerCloseIcon.propertyView({ label: "Close Icon" })}
+ {children.hamburgerPosition.propertyView({ label: "Hamburger Position" })}
+ {children.hamburgerSize.propertyView({ label: "Hamburger Size" })}
+ {children.drawerPlacement.propertyView({ label: "Drawer Placement" })}
+ {(drawerPlacement === 'top' || drawerPlacement === 'bottom') &&
+ children.drawerHeight.propertyView({ label: "Drawer Height" })}
+ {(drawerPlacement === 'left' || drawerPlacement === 'right') &&
+ children.drawerWidth.propertyView({ label: "Drawer Width" })}
+ {children.shadowOverlay.propertyView({ label: "Shadow Overlay" })}
+ {children.backgroundImage.propertyView({
+ label: `Background Image`,
+ placeholder: 'https://temp.im/350x400',
+ })}
+ >
+ );
+}
+
+function renderVerticalLayoutSection(children: any): any {
+ return (
+ <>
+ {children.backgroundImage.propertyView({
+ label: `Background Image`,
+ placeholder: 'https://temp.im/350x400',
+ })}
+ {children.showSeparator.propertyView({label: trans("navLayout.mobileNavVerticalShowSeparator")})}
+ {children.tabBarHeight.propertyView({label: trans("navLayout.mobileNavBarHeight")})}
+ {children.navIconSize.propertyView({label: trans("navLayout.mobileNavIconSize")})}
+ {children.maxWidth.propertyView({label: trans("navLayout.mobileNavVerticalMaxWidth")})}
+ {children.verticalAlignment.propertyView({
+ label: trans("navLayout.mobileNavVerticalOrientation"),
+ radioButton: true
+ })}
+ >
+ );
+}
+
+
+
let MobileTabLayoutTmp = (function () {
const childrenMap = {
onEvent: eventHandlerControl(EventOptions),
@@ -311,6 +514,16 @@ let MobileTabLayoutTmp = (function () {
jsonTabs: manualOptionsControl(TabOptionComp, {
initOptions: [],
}),
+ // Mode & hamburger/drawer config
+ menuMode: dropdownControl(MobileModeOptions, MobileMode.Vertical),
+ hamburgerIcon: IconControl,
+ drawerCloseIcon: IconControl,
+ hamburgerPosition: dropdownControl(HamburgerPositionOptions, "bottom-right"),
+ hamburgerSize: withDefault(StringControl, "56px"),
+ drawerPlacement: dropdownControl(DrawerPlacementOptions, "right"),
+ drawerHeight: withDefault(StringControl, "60%"),
+ drawerWidth: withDefault(StringControl, "250px"),
+ shadowOverlay: withDefault(BoolCodeControl, true),
backgroundImage: withDefault(StringControl, ""),
tabBarHeight: withDefault(StringControl, "56px"),
navIconSize: withDefault(StringControl, "32px"),
@@ -321,47 +534,38 @@ let MobileTabLayoutTmp = (function () {
navItemStyle: styleControl(NavLayoutItemStyle, 'navItemStyle'),
navItemHoverStyle: styleControl(NavLayoutItemHoverStyle, 'navItemHoverStyle'),
navItemActiveStyle: styleControl(NavLayoutItemActiveStyle, 'navItemActiveStyle'),
+ hamburgerButtonStyle: styleControl(HamburgerButtonStyle, 'hamburgerButtonStyle'),
+ drawerContainerStyle: styleControl(DrawerContainerStyle, 'drawerContainerStyle'),
};
return new MultiCompBuilder(childrenMap, (props, dispatch) => {
return null;
})
.setPropertyViewFn((children) => {
- const [styleSegment, setStyleSegment] = useState('normal')
+ const [styleSegment, setStyleSegment] = useState('normal');
+ const isHamburgerMode = children.menuMode.getView() === MobileMode.Hamburger;
+
return (
-
-
- {children.dataOptionType.propertyView({
- radioButton: true,
- type: "oneline",
- })}
- {
- children.dataOptionType.getView() === DataOption.Manual
- ? children.tabs.propertyView({})
- : children.jsonItems.propertyView({
- label: "Json Data",
- })
- }
-
-
- { children.onEvent.getPropertyView() }
-
+ <>
+ {renderDataSection(children)}
+ {renderEventHandlersSection(children)}
- {children.backgroundImage.propertyView({
- label: `Background Image`,
- placeholder: 'https://temp.im/350x400',
- })}
- { children.showSeparator.propertyView({label: trans("navLayout.mobileNavVerticalShowSeparator")})}
- {children.tabBarHeight.propertyView({label: trans("navLayout.mobileNavBarHeight")})}
- {children.navIconSize.propertyView({label: trans("navLayout.mobileNavIconSize")})}
- {children.maxWidth.propertyView({label: trans("navLayout.mobileNavVerticalMaxWidth")})}
- {children.verticalAlignment.propertyView(
- { label: trans("navLayout.mobileNavVerticalOrientation"),radioButton: true }
- )}
+ {children.menuMode.propertyView({ label: "Mode", radioButton: true })}
+ {isHamburgerMode
+ ? renderHamburgerLayoutSection(children)
+ : renderVerticalLayoutSection(children)}
-
- { children.navStyle.getPropertyView() }
-
-
+ {!isHamburgerMode && (
+
+ {children.navStyle.getPropertyView()}
+
+ )}
+
+ {isHamburgerMode && (
+
+ {children.hamburgerButtonStyle.getPropertyView()}
+
+ )}
+
{controlItem({}, (
setStyleSegment(k as MenuItemStyleOptionValue)}
/>
))}
- {styleSegment === 'normal' && (
- children.navItemStyle.getPropertyView()
- )}
- {styleSegment === 'hover' && (
- children.navItemHoverStyle.getPropertyView()
- )}
- {styleSegment === 'active' && (
- children.navItemActiveStyle.getPropertyView()
- )}
+ {styleSegment === 'normal' && children.navItemStyle.getPropertyView()}
+ {styleSegment === 'hover' && children.navItemHoverStyle.getPropertyView()}
+ {styleSegment === 'active' && children.navItemActiveStyle.getPropertyView()}
-
+ {isHamburgerMode && (
+
+ {children.drawerContainerStyle.getPropertyView()}
+
+ )}
+ >
);
})
.build();
@@ -388,7 +591,9 @@ let MobileTabLayoutTmp = (function () {
MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => {
const [tabIndex, setTabIndex] = useState(0);
+ const [drawerVisible, setDrawerVisible] = useState(false);
const { readOnly } = useContext(ExternalEditorContext);
+ const pathParam = useAppPathParam();
const navStyle = comp.children.navStyle.getView();
const navItemStyle = comp.children.navItemStyle.getView();
const navItemHoverStyle = comp.children.navItemHoverStyle.getView();
@@ -396,11 +601,22 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => {
const backgroundImage = comp.children.backgroundImage.getView();
const jsonItems = comp.children.jsonItems.getView();
const dataOptionType = comp.children.dataOptionType.getView();
+ const menuMode = comp.children.menuMode.getView();
+ const hamburgerPosition = comp.children.hamburgerPosition.getView();
+ const hamburgerSize = comp.children.hamburgerSize.getView();
+ const hamburgerIconComp = comp.children.hamburgerIcon;
+ const drawerCloseIconComp = comp.children.drawerCloseIcon;
+ const hamburgerButtonStyle = comp.children.hamburgerButtonStyle.getView();
+ const drawerPlacement = comp.children.drawerPlacement.getView();
+ const drawerHeight = comp.children.drawerHeight.getView();
+ const drawerWidth = comp.children.drawerWidth.getView();
+ const shadowOverlay = comp.children.shadowOverlay.getView();
const tabBarHeight = comp.children.tabBarHeight.getView();
const navIconSize = comp.children.navIconSize.getView();
const maxWidth = comp.children.maxWidth.getView();
const verticalAlignment = comp.children.verticalAlignment.getView();
const showSeparator = comp.children.showSeparator.getView();
+ const drawerContainerStyle = comp.children.drawerContainerStyle.getView();
const bgColor = (useContext(ThemeContext)?.theme || defaultTheme).canvas;
const onEvent = comp.children.onEvent.getView();
@@ -455,6 +671,21 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => {
backgroundStyle = `center / cover url('${backgroundImage}') no-repeat, ${backgroundStyle}`;
}
+ const navigateToApp = (nextIndex: number) => {
+ if (dataOptionType === DataOption.Manual) {
+ const selectedTab = tabViews[nextIndex];
+ if (selectedTab) {
+ const url = [
+ ALL_APPLICATIONS_URL,
+ pathParam.applicationId,
+ pathParam.viewMode,
+ nextIndex,
+ ].join("/");
+ selectedTab.children.action.act(url);
+ }
+ }
+ };
+
const tabBarView = (
{
: undefined,
}))}
selectedKey={tabIndex + ""}
- onChange={(key) => setTabIndex(Number(key))}
+ onChange={(key) => {
+ const nextIndex = Number(key);
+ setTabIndex(nextIndex);
+ // push URL with query/hash params
+ navigateToApp(nextIndex);
+ }}
readOnly={!!readOnly}
canvasBg={bgColor}
tabStyle={{
@@ -488,11 +724,106 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => {
/>
);
+ const containerTabBarHeight = menuMode === MobileMode.Hamburger ? '0px' : tabBarHeight;
+
+ const hamburgerButton = (
+ setDrawerVisible(true)}
+ >
+ {hamburgerIconComp.toJsonValue() ? (
+
+ {hamburgerIconComp.getView()}
+
+ ) : (
+
+ )}
+
+ );
+
+ const drawerBodyStyle = useMemo(() => {
+ if (drawerPlacement === 'left' || drawerPlacement === 'right') {
+ return { width: drawerWidth } as React.CSSProperties;
+ }
+ return { height: drawerHeight } as React.CSSProperties;
+ }, [drawerPlacement, drawerHeight, drawerWidth]);
+
+ const drawerView = (
+ }>
+ setDrawerVisible(false)}
+ onClose={() => setDrawerVisible(false)}
+ position={drawerPlacement as any}
+ mask={shadowOverlay}
+ bodyStyle={drawerBodyStyle}
+ >
+
+
+ setDrawerVisible(false)}
+ >
+ {drawerCloseIconComp.toJsonValue()
+ ? drawerCloseIconComp.getView()
+ : ×}
+
+
+
+ {tabViews.map((tab, index) => (
+ {
+ setTabIndex(index);
+ setDrawerVisible(false);
+ onEvent('click');
+ navigateToApp(index);
+ }}
+ >
+ {tab.children.icon.toJsonValue() ? (
+ {tab.children.icon.getView()}
+ ) : null}
+ {tab.children.label.getView()}
+
+ ))}
+
+
+
+
+ );
+
if (readOnly) {
return (
-
+
{appView}
- {tabBarView}
+ {menuMode === MobileMode.Hamburger ? (
+ <>
+ {hamburgerButton}
+ {drawerView}
+ >
+ ) : (
+ tabBarView
+ )}
);
}
@@ -500,7 +831,14 @@ MobileTabLayoutTmp = withViewFn(MobileTabLayoutTmp, (comp) => {
return (
{appView}
- {tabBarView}
+ {menuMode === MobileMode.Hamburger ? (
+ <>
+ {hamburgerButton}
+ {drawerView}
+ >
+ ) : (
+ tabBarView
+ )}
);
});
diff --git a/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts b/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts
index 66043303ac..aa33423d02 100644
--- a/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts
+++ b/client/packages/lowcoder/src/comps/comps/layout/navLayoutConstants.ts
@@ -6,6 +6,45 @@ export const ModeOptions = [
{ label: trans("navLayout.modeHorizontal"), value: "horizontal" },
] as const;
+// Mobile navigation specific modes and options
+export const MobileMode = {
+ Vertical: "vertical",
+ Hamburger: "hamburger",
+} as const;
+
+export const MobileModeOptions = [
+ { label: "Normal", value: MobileMode.Vertical },
+ { label: "Hamburger", value: MobileMode.Hamburger },
+];
+
+export const HamburgerPosition = {
+ BottomRight: "bottom-right",
+ BottomLeft: "bottom-left",
+ TopRight: "top-right",
+ TopLeft: "top-left",
+} as const;
+
+export const HamburgerPositionOptions = [
+ { label: "Bottom Right", value: HamburgerPosition.BottomRight },
+ { label: "Bottom Left", value: HamburgerPosition.BottomLeft },
+ { label: "Top Right", value: HamburgerPosition.TopRight },
+ { label: "Top Left", value: HamburgerPosition.TopLeft },
+] as const;
+
+export const DrawerPlacement = {
+ Bottom: "bottom",
+ Top: "top",
+ Left: "left",
+ Right: "right",
+} as const;
+
+export const DrawerPlacementOptions = [
+ { label: "Bottom", value: DrawerPlacement.Bottom },
+ { label: "Top", value: DrawerPlacement.Top },
+ { label: "Left", value: DrawerPlacement.Left },
+ { label: "Right", value: DrawerPlacement.Right },
+];
+
export const DataOption = {
Manual: 'manual',
Json: 'json',
diff --git a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx
index 569ada9c4d..175448bf3b 100644
--- a/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx
+++ b/client/packages/lowcoder/src/comps/controls/styleControlConstants.tsx
@@ -1382,6 +1382,30 @@ export const FloatButtonStyle = [
BORDER_WIDTH,
] as const;
+export const HamburgerButtonStyle = [
+ getBackground(),
+ {
+ name: "iconFill",
+ label: trans("style.fill"),
+ depTheme: "primary",
+ depType: DEP_TYPE.SELF,
+ transformer: toSelf,
+ },
+ MARGIN,
+ PADDING,
+ BORDER,
+ RADIUS,
+ BORDER_WIDTH,
+] as const;
+
+export const DrawerContainerStyle = [
+ getBackground(),
+ MARGIN,
+ PADDING,
+ BORDER,
+ BORDER_WIDTH,
+] as const;
+
export const TransferStyle = [
getStaticBackground(SURFACE_COLOR),
...STYLING_FIELDS_CONTAINER_SEQUENCE.filter(style=>style.name!=='rotation'),
diff --git a/client/packages/lowcoder/src/pages/editor/right/PropertyView.tsx b/client/packages/lowcoder/src/pages/editor/right/PropertyView.tsx
index f5e90bd7b2..f051a28983 100644
--- a/client/packages/lowcoder/src/pages/editor/right/PropertyView.tsx
+++ b/client/packages/lowcoder/src/pages/editor/right/PropertyView.tsx
@@ -26,7 +26,7 @@ export default function PropertyView(props: PropertyViewProps) {
let propertyView;
if (selectedComp) {
- return <>{selectedComp.getPropertyView()}>;
+ propertyView = selectedComp.getPropertyView();
} else if (selectedCompNames.size > 1) {
propertyView = (