diff --git a/app/client/src/constants/WidgetConstants.tsx b/app/client/src/constants/WidgetConstants.tsx index d5777458d5f7..2a6c2d60bc68 100644 --- a/app/client/src/constants/WidgetConstants.tsx +++ b/app/client/src/constants/WidgetConstants.tsx @@ -110,6 +110,9 @@ export const AUTO_LAYOUT_CONTAINER_PADDING = 5; export const WIDGET_PADDING = GridDefaults.DEFAULT_GRID_ROW_HEIGHT * 0.4; +/** Default inner content padding (px) for container-like widgets when contentPadding is not set. */ +export const DEFAULT_CONTENT_PADDING = "0"; + export const WIDGET_CLASSNAME_PREFIX = "WIDGET_"; export const MAIN_CONTAINER_WIDGET_ID = "0"; export const MAIN_CONTAINER_WIDGET_NAME = "MainContainer"; diff --git a/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutEditorCanvas.tsx b/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutEditorCanvas.tsx index 5ea5047ccb0d..5f07a0a32c5d 100644 --- a/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutEditorCanvas.tsx +++ b/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutEditorCanvas.tsx @@ -1,6 +1,7 @@ import { CANVAS_DEFAULT_MIN_HEIGHT_PX } from "constants/AppConstants"; import { GridDefaults, RenderModes } from "constants/WidgetConstants"; import { renderChildren } from "layoutSystems/common/utils/canvasUtils"; +import { parseContentPadding } from "widgets/WidgetUtils"; import { CanvasSelectionArena } from "layoutSystems/fixedlayout/editor/FixedLayoutCanvasArenas/CanvasSelectionArena"; import WidgetsMultiSelectBox from "layoutSystems/fixedlayout/common/widgetGrouping/WidgetsMultiSelectBox"; import React, { useMemo } from "react"; @@ -26,7 +27,12 @@ export type CanvasProps = DSLWidget; */ export const FixedLayoutEditorCanvas = (props: BaseWidgetProps) => { - const { snapGrid } = getSnappedGrid(props, props.componentWidth); + const [, pr, , pl] = parseContentPadding(props.parentContentPadding); + const paddingAdjustment = pl + pr; + const { snapGrid } = getSnappedGrid( + props, + props.componentWidth - paddingAdjustment, + ); const { snapColumnSpace } = snapGrid; const snapRows = getCanvasSnapRows(props.bottomRow); const fixedLayoutDropTargetProps = useMemo( diff --git a/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutViewerCanvas.tsx b/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutViewerCanvas.tsx index 8bcda421dee4..4d031a3e207c 100644 --- a/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutViewerCanvas.tsx +++ b/app/client/src/layoutSystems/fixedlayout/canvas/FixedLayoutViewerCanvas.tsx @@ -1,6 +1,7 @@ import { GridDefaults, RenderModes } from "constants/WidgetConstants"; import { Positioning } from "layoutSystems/common/utils/constants"; import { CanvasViewerWrapper } from "layoutSystems/common/canvasViewer/CanvasViewerWrapper"; +import { parseContentPadding } from "widgets/WidgetUtils"; import { renderChildren } from "layoutSystems/common/utils/canvasUtils"; import { compact, sortBy } from "lodash"; import React, { useMemo } from "react"; @@ -21,7 +22,12 @@ export type CanvasProps = ContainerWidgetProps; */ export const FixedLayoutViewerCanvas = (props: BaseWidgetProps) => { - const { snapGrid } = getSnappedGrid(props, props.componentWidth); + const [, pr, , pl] = parseContentPadding(props.parentContentPadding); + const paddingAdjustment = pl + pr; + const { snapGrid } = getSnappedGrid( + props, + props.componentWidth - paddingAdjustment, + ); const { snapColumnSpace } = snapGrid; const layoutSystemProps: AdditionalFixedLayoutProperties = { parentColumnSpace: snapColumnSpace, diff --git a/app/client/src/widgets/ContainerWidget/component/index.tsx b/app/client/src/widgets/ContainerWidget/component/index.tsx index 43ad5c133760..4c768645ffad 100644 --- a/app/client/src/widgets/ContainerWidget/component/index.tsx +++ b/app/client/src/widgets/ContainerWidget/component/index.tsx @@ -11,7 +11,7 @@ import fastdom from "fastdom"; import { generateClassName, getCanvasClassName } from "utils/generators"; import type { WidgetStyleContainerProps } from "components/designSystems/appsmith/WidgetStyleContainer"; import WidgetStyleContainer from "components/designSystems/appsmith/WidgetStyleContainer"; -import { scrollCSS } from "widgets/WidgetUtils"; +import { parseContentPadding, scrollCSS } from "widgets/WidgetUtils"; import { useSelector } from "react-redux"; import { LayoutSystemTypes } from "layoutSystems/types"; import { MAIN_CONTAINER_WIDGET_ID } from "constants/WidgetConstants"; @@ -32,6 +32,13 @@ const StyledContainerComponent = styled.div< opacity: ${(props) => props.resizeDisabled && !props.forceFullOpacity ? "0.8" : "1"}; + padding: ${(props) => { + const [t, r, b, l] = props.contentPaddingPx; + + return `${t}px ${r}px ${b}px ${l}px`; + }}; + box-sizing: border-box; + background: ${(props) => props.backgroundColor}; &:hover { background-color: ${(props) => { @@ -55,6 +62,7 @@ interface ContainerWrapperProps { dropDisabled?: boolean; $noScroll: boolean; forceFullOpacity?: boolean; + contentPaddingPx: [number, number, number, number]; } function ContainerComponentWrapper( @@ -134,6 +142,7 @@ function ContainerComponentWrapper( ? "auto-layout" : "" }`} + contentPaddingPx={props.contentPaddingPx} dropDisabled={props.dropDisabled} forceFullOpacity={props.forceFullOpacity} onClick={props.onClick} @@ -151,10 +160,13 @@ function ContainerComponentWrapper( } function ContainerComponent(props: ContainerComponentProps) { + const contentPaddingPx = parseContentPadding(props.contentPadding ?? ""); + if (props.detachFromLayout) { return ( { - return map( - // sort by row so stacking context is correct - // TODO(abhinav): This is hacky. The stacking context should increase for widgets rendered top to bottom, always. - // Figure out a way in which the stacking context is consistent. + const children = this.props.positioning !== Positioning.Fixed ? this.props.children - : sortBy(compact(this.props.children), (child) => child.topRow), - this.renderChildWidget, - ); + : sortBy(compact(this.props.children), (child) => child.topRow); + + return map(children, (child) => ( + + {this.renderChildWidget(child)} + + )); }; renderAsContainerComponent(props: ContainerWidgetProps) { @@ -411,6 +442,7 @@ export interface ContainerWidgetProps extends WidgetProps { children?: T[]; containerStyle?: ContainerStyle; + contentPadding?: string; onClick?: MouseEventHandler; onClickCapture?: MouseEventHandler; shouldScrollContents?: boolean; diff --git a/app/client/src/widgets/FormWidget/widget/index.tsx b/app/client/src/widgets/FormWidget/widget/index.tsx index 94a6116a948c..d2a059b5d0b2 100644 --- a/app/client/src/widgets/FormWidget/widget/index.tsx +++ b/app/client/src/widgets/FormWidget/widget/index.tsx @@ -22,7 +22,11 @@ import type { SetterConfig } from "entities/AppTheming"; import { ButtonVariantTypes, RecaptchaTypes } from "components/constants"; import { Colors } from "constants/Colors"; import { FILL_WIDGET_MIN_WIDTH } from "constants/minWidthConstants"; -import { GridDefaults, WIDGET_TAGS } from "constants/WidgetConstants"; +import { + DEFAULT_CONTENT_PADDING, + GridDefaults, + WIDGET_TAGS, +} from "constants/WidgetConstants"; import type { CanvasWidgetsReduxState } from "ee/reducers/entityReducers/canvasWidgetsReducer"; import { getWidgetBluePrintUpdates } from "utils/WidgetBlueprintUtils"; import { DynamicHeight } from "utils/WidgetFeatures"; @@ -82,6 +86,7 @@ class FormWidget extends ContainerWidget { columns: 24, borderColor: Colors.GREY_5, borderWidth: "1", + contentPadding: DEFAULT_CONTENT_PADDING, animateLoading: true, widgetName: "Form", backgroundColor: Colors.WHITE, diff --git a/app/client/src/widgets/JSONFormWidget/component/Form.tsx b/app/client/src/widgets/JSONFormWidget/component/Form.tsx index 353a0f528d77..157bbd3761f5 100644 --- a/app/client/src/widgets/JSONFormWidget/component/Form.tsx +++ b/app/client/src/widgets/JSONFormWidget/component/Form.tsx @@ -10,16 +10,20 @@ import useFixedFooter from "./useFixedFooter"; import type { ButtonStyleProps } from "widgets/ButtonWidget/component"; import { BaseButton as Button } from "widgets/ButtonWidget/component"; import { Colors } from "constants/Colors"; -import { FORM_PADDING_Y, FORM_PADDING_X } from "./styleConstants"; import type { Schema } from "../constants"; import { ROOT_SCHEMA_KEY } from "../constants"; import { convertSchemaItemToFormData, schemaItemDefaultValue } from "../helper"; import { klonaRegularWithTelemetry } from "utils/helpers"; +const DEFAULT_FORM_PADDING_PX: [number, number, number, number] = [ + 25, 25, 25, 25, +]; + // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FormProps = PropsWithChildren<{ backgroundColor?: string; + contentPaddingPx?: [number, number, number, number]; disabledWhenInvalid?: boolean; fixedFooter: boolean; getFormData: () => TValues; @@ -47,20 +51,21 @@ interface StyledFormProps { scrollContents: boolean; } -interface StyledFormBodyProps { - stretchBodyVertically: boolean; -} - interface StyledFooterProps { fixedFooter: boolean; backgroundColor?: string; + $contentPaddingPx: [number, number, number, number]; +} + +interface StyledFormBodyPropsWithPadding { + stretchBodyVertically: boolean; + $contentPaddingPx: [number, number, number, number]; } const BUTTON_WIDTH = 110; const FOOTER_BUTTON_GAP = 10; const TITLE_FONT_SIZE = "1.25rem"; const FOOTER_DEFAULT_BG_COLOR = "#fff"; -const FOOTER_PADDING_TOP = FORM_PADDING_Y; const TITLE_MARGIN_BOTTOM = 16; const FOOTER_SCROLL_ACTIVE_CLASS_NAME = "scroll-active"; @@ -71,8 +76,8 @@ const StyledFormFooter = styled.div` display: flex; gap: ${FOOTER_BUTTON_GAP}px; justify-content: flex-end; - padding: ${FORM_PADDING_Y}px ${FORM_PADDING_X}px; - padding-top: ${FOOTER_PADDING_TOP}px; + padding: ${({ $contentPaddingPx: p }) => + `${p[0]}px ${p[1]}px ${p[2]}px ${p[3]}px`}; position: ${({ fixedFooter }) => fixedFooter && "sticky"}; width: 100%; @@ -104,10 +109,11 @@ const StyledTitle = styled(Text)<{ margin-bottom: ${TITLE_MARGIN_BOTTOM}px; `; -const StyledFormBody = styled.div` +const StyledFormBody = styled.div` height: ${({ stretchBodyVertically }) => stretchBodyVertically ? "100%" : "auto"}; - padding: ${FORM_PADDING_Y}px ${FORM_PADDING_X}px; + padding: ${({ $contentPaddingPx: p }) => + `${p[0]}px ${p[1]}px ${p[2]}px ${p[3]}px`}; `; const StyledResetButtonWrapper = styled.div``; @@ -124,6 +130,7 @@ function Form( { backgroundColor, children, + contentPaddingPx, disabledWhenInvalid, fixedFooter, getFormData, @@ -281,6 +288,7 @@ function Form( scrollContents={scrollContents} > @@ -289,6 +297,7 @@ function Form( {!hideFooter && ( { borderWidth?: number; boxShadow?: BoxShadow; boxShadowColor?: string; + contentPaddingPx?: [number, number, number, number]; disabledWhenInvalid?: boolean; executeAction: (action: Action) => void; fieldLimitExceeded: boolean; @@ -213,6 +214,7 @@ function JSONFormComponent(
; + contentPadding?: string; disabledWhenInvalid?: boolean; fieldLimitExceeded: boolean; fieldState: Record; @@ -196,6 +198,7 @@ class JSONFormWidget extends BaseWidget< version: 1, borderWidth: "1", borderColor: Colors.GREY_5, + contentPadding: "25", widgetName: "JSONForm", autoGenerateForm: true, fieldLimitExceeded: false, @@ -830,6 +833,9 @@ class JSONFormWidget extends BaseWidget< getWidgetView() { const isAutoHeightEnabled = isAutoHeightEnabledForWidget(this.props); + const contentPaddingPx = parseContentPadding( + this.props.contentPadding ?? "25", + ); return ( // Warning!!! Do not ever introduce formData as a prop directly, @@ -842,6 +848,7 @@ class JSONFormWidget extends BaseWidget< borderWidth={this.props.borderWidth} boxShadow={this.props.boxShadow} boxShadowColor={this.props.boxShadowColor} + contentPaddingPx={contentPaddingPx} disabledWhenInvalid={this.props.disabledWhenInvalid} executeAction={this.onExecuteAction} fieldLimitExceeded={this.props.fieldLimitExceeded} diff --git a/app/client/src/widgets/JSONFormWidget/widget/propertyConfig.ts b/app/client/src/widgets/JSONFormWidget/widget/propertyConfig.ts index d4e4d47d873d..d237d88267ef 100644 --- a/app/client/src/widgets/JSONFormWidget/widget/propertyConfig.ts +++ b/app/client/src/widgets/JSONFormWidget/widget/propertyConfig.ts @@ -16,6 +16,7 @@ import { SUCCESSFULL_BINDING_MESSAGE, } from "../constants/messages"; import { createMessage } from "ee/constants/messages"; +import { contentPaddingValidation } from "widgets/contentPaddingUtils"; import { FieldOptionsType } from "components/editorComponents/WidgetQueryGeneratorForm/WidgetSpecificControls/OtherFields/Field/Dropdown/types"; import { DROPDOWN_VARIANT } from "components/editorComponents/WidgetQueryGeneratorForm/CommonControls/DatasourceDropdown/types"; @@ -659,7 +660,7 @@ export const styleConfig = [ ], }, { - sectionName: "Border and shadow", + sectionName: "Border, shadow & padding", children: [ { propertyName: "borderWidth", @@ -671,6 +672,27 @@ export const styleConfig = [ isTriggerProperty: false, validation: { type: ValidationTypes.NUMBER }, }, + { + helpText: + "Space between the border and the form body (where fields render), in pixels. Use one value for all sides, or 2–4 values for top, right, bottom, left (e.g. 10 20 10 20).", + propertyName: "contentPadding", + label: "Padding (px)", + placeholderText: "e.g. 10 or 10 20 10 20", + controlType: "INPUT_TEXT", + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.FUNCTION, + params: { + fn: contentPaddingValidation, + default: "25", + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + }, + }, + }, + }, { propertyName: "borderRadius", helpText: "Enter value for border radius", diff --git a/app/client/src/widgets/ListWidgetV2/component/index.tsx b/app/client/src/widgets/ListWidgetV2/component/index.tsx index 507d3703a696..12448eccf2cb 100644 --- a/app/client/src/widgets/ListWidgetV2/component/index.tsx +++ b/app/client/src/widgets/ListWidgetV2/component/index.tsx @@ -10,6 +10,7 @@ type ListComponentProps = React.PropsWithChildren<{ borderRadius: string; boxShadow?: string; componentRef: RefObject; + contentPaddingPx?: [number, number, number, number]; height: number; infiniteScroll?: boolean; }>; @@ -47,11 +48,39 @@ export const ListComponentEmpty = styled.div<{ `; // This is to be improved for infiniteScroll. +// Vertical padding is applied as margin so the scroll height stays correct and +// content is not clipped; horizontal padding is applied as padding. +// When padding is set, the wrapper height is reduced by top+bottom so that +// marginTop + height + marginBottom fits in the list container. const ScrollableCanvasWrapper = styled.div< - Pick + Pick & { + $contentPaddingPx?: [number, number, number, number]; + } >` + box-sizing: border-box; ${({ infiniteScroll }) => (infiniteScroll ? scrollCSS : ``)} - height: ${(props) => props.height - WIDGET_PADDING * 2}px; + height: ${(props) => { + const base = props.height - WIDGET_PADDING * 2; + const p = props.$contentPaddingPx; + + if (!p) return `${base}px`; + + return `${base - p[0] - p[2]}px`; + }}; + ${(props) => { + const p = props.$contentPaddingPx; + + if (!p) return ""; + + const [top, right, bottom, left] = p; + + return ` + margin-top: ${top}px; + margin-bottom: ${bottom}px; + padding-left: ${left}px; + padding-right: ${right}px; + `; + }} `; function ListComponent(props: ListComponentProps) { @@ -71,7 +100,11 @@ function ListComponent(props: ListComponentProps) { boxShadow={boxShadow} ref={componentRef} > - + {props.children} diff --git a/app/client/src/widgets/ListWidgetV2/widget/defaultProps.ts b/app/client/src/widgets/ListWidgetV2/widget/defaultProps.ts index ee735b07d311..570132ab0e82 100644 --- a/app/client/src/widgets/ListWidgetV2/widget/defaultProps.ts +++ b/app/client/src/widgets/ListWidgetV2/widget/defaultProps.ts @@ -9,7 +9,10 @@ import { } from "./helper"; import { FILL_WIDGET_MIN_WIDTH } from "constants/minWidthConstants"; import { getWidgetBluePrintUpdates } from "utils/WidgetBlueprintUtils"; -import { GridDefaults } from "constants/WidgetConstants"; +import { + DEFAULT_CONTENT_PADDING, + GridDefaults, +} from "constants/WidgetConstants"; import type { CanvasWidgetsReduxState } from "ee/reducers/entityReducers/canvasWidgetsReducer"; import { FlexLayerAlignment, @@ -45,6 +48,7 @@ const LIST_WIDGET_NESTING_ERROR = export default { backgroundColor: "transparent", + contentPadding: DEFAULT_CONTENT_PADDING, itemBackgroundColor: "#FFFFFF", requiresFlatWidgetChildren: true, hasMetaWidgets: true, diff --git a/app/client/src/widgets/ListWidgetV2/widget/index.tsx b/app/client/src/widgets/ListWidgetV2/widget/index.tsx index f14fb87f16ec..fb46fd0859aa 100644 --- a/app/client/src/widgets/ListWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/ListWidgetV2/widget/index.tsx @@ -31,6 +31,7 @@ import { PropertyPaneStyleConfig, } from "./propertyConfig"; import { + DEFAULT_CONTENT_PADDING, RenderModes, WIDGET_PADDING, WIDGET_TAGS, @@ -48,7 +49,10 @@ import { isListFullyEmpty, isTargetElementClickable, } from "./helper"; -import { DefaultAutocompleteDefinitions } from "widgets/WidgetUtils"; +import { + DefaultAutocompleteDefinitions, + parseContentPadding, +} from "widgets/WidgetUtils"; import type { ExtraDef } from "utils/autocomplete/defCreatorUtils"; import { LayoutSystemTypes } from "layoutSystems/types"; import { generateTypeDef } from "utils/autocomplete/defCreatorUtils"; @@ -1489,12 +1493,19 @@ class ListWidget extends BaseWidget< ); } + const contentPaddingPx = parseContentPadding( + this.props.contentPadding ?? DEFAULT_CONTENT_PADDING, + ); + const [, rightPx, , leftPx] = contentPaddingPx; + const paddedContentWidth = Math.max(0, componentWidth - leftPx - rightPx); + return ( @@ -1505,7 +1516,7 @@ class ListWidget extends BaseWidget< updateWidgetProperty={this.overrideUpdateWidgetProperty} > {this.renderChildren(this.props.metaWidgetChildrenStructure, { - componentWidth, + componentWidth: paddedContentWidth, parentColumnSpace, selectedItemKey, startIndex, @@ -1523,6 +1534,7 @@ export interface ListWidgetProps backgroundColor: string; borderRadius: string; boxShadow?: string; + contentPadding?: string; children?: T[]; currentItemStructure?: Record; itemSpacing?: number; diff --git a/app/client/src/widgets/ListWidgetV2/widget/propertyConfig.ts b/app/client/src/widgets/ListWidgetV2/widget/propertyConfig.ts index e82c72ab76ae..4ecaa9675689 100644 --- a/app/client/src/widgets/ListWidgetV2/widget/propertyConfig.ts +++ b/app/client/src/widgets/ListWidgetV2/widget/propertyConfig.ts @@ -13,6 +13,10 @@ import { LIST_WIDGET_V2_TOTAL_RECORD_TOOLTIP, createMessage, } from "ee/constants/messages"; +import { + contentPaddingValidation, + DEFAULT_CONTENT_PADDING, +} from "widgets/contentPaddingUtils"; const MIN_ITEM_SPACING = 0; const MAX_ITEM_SPACING = 16; @@ -472,8 +476,29 @@ export const PropertyPaneStyleConfig = [ ], }, { - sectionName: "Border and shadow", + sectionName: "Border, shadow & padding", children: [ + { + helpText: + "Space between the list border and the scrollable content, in pixels. Use one value for all sides, or 2–4 values for top, right, bottom, left (e.g. 10 20 10 20). Does not affect spacing between list items.", + propertyName: "contentPadding", + label: "Padding (px)", + placeholderText: "e.g. 10 or 10 20 10 20", + controlType: "INPUT_TEXT", + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.FUNCTION, + params: { + fn: contentPaddingValidation, + default: DEFAULT_CONTENT_PADDING, + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + }, + }, + }, + }, { propertyName: "borderRadius", label: "Border radius", diff --git a/app/client/src/widgets/TabsWidget/component/index.tsx b/app/client/src/widgets/TabsWidget/component/index.tsx index 0587b33327e1..638ccfd9325f 100644 --- a/app/client/src/widgets/TabsWidget/component/index.tsx +++ b/app/client/src/widgets/TabsWidget/component/index.tsx @@ -21,6 +21,7 @@ interface TabsComponentProps extends ComponentProps { borderColor?: string; accentColor?: string; primaryColor: string; + contentPaddingPx?: [number, number, number, number]; onTabChange: (tabId: string) => void; tabs: Array<{ id: string; @@ -110,10 +111,18 @@ export interface ScrollNavControlProps { className?: string; } -const ScrollCanvas = styled.div<{ $shouldScrollContents: boolean }>` +const ScrollCanvas = styled.div<{ + $shouldScrollContents: boolean; + $contentPaddingPx?: [number, number, number, number]; +}>` overflow: hidden; + box-sizing: border-box; ${(props) => (props.$shouldScrollContents ? scrollCSS : ``)} width: 100%; + ${(props) => + props.$contentPaddingPx + ? `padding: ${props.$contentPaddingPx[0]}px ${props.$contentPaddingPx[1]}px ${props.$contentPaddingPx[2]}px ${props.$contentPaddingPx[3]}px;` + : ""} `; function TabsComponent(props: TabsComponentProps) { @@ -199,6 +208,7 @@ function TabsComponent(props: TabsComponentProps) { )} borderRadius: string; boxShadow?: string; primaryColor: string; + contentPadding?: string; } export const SCROLL_NAV_CONTROL_CONTAINER_WIDTH = 30; diff --git a/app/client/src/widgets/TabsWidget/widget/index.tsx b/app/client/src/widgets/TabsWidget/widget/index.tsx index 2ae2afb32fe3..ee64a2eaa0b0 100644 --- a/app/client/src/widgets/TabsWidget/widget/index.tsx +++ b/app/client/src/widgets/TabsWidget/widget/index.tsx @@ -19,6 +19,10 @@ import TabsComponent from "../component"; import type { TabContainerWidgetProps, TabsWidgetProps } from "../constants"; import derivedProperties from "./parseDerivedProperties"; import type { SetterConfig, Stylesheet } from "entities/AppTheming"; +import { + contentPaddingValidation, + parseContentPadding, +} from "widgets/contentPaddingUtils"; import { isAutoHeightEnabledForWidget, isAutoHeightEnabledForWidgetWithLimits, @@ -32,6 +36,7 @@ import { ResponsiveBehavior } from "layoutSystems/common/utils/constants"; import { Colors } from "constants/Colors"; import { FILL_WIDGET_MIN_WIDTH } from "constants/minWidthConstants"; import { + DEFAULT_CONTENT_PADDING, GridDefaults, WIDGET_TAGS, WidgetHeightLimits, @@ -102,6 +107,7 @@ class TabsWidget extends BaseWidget< borderWidth: 1, borderColor: Colors.GREY_5, backgroundColor: Colors.WHITE, + contentPadding: DEFAULT_CONTENT_PADDING, minDynamicHeight: WidgetHeightLimits.MIN_CANVAS_HEIGHT_IN_ROWS + 5, tabsObj: { tab1: { @@ -420,7 +426,7 @@ class TabsWidget extends BaseWidget< static getPropertyPaneStyleConfig() { return [ { - sectionName: "Colors, Borders and Shadows", + sectionName: "Colors, border, shadow & padding", children: [ { propertyName: "accentColor", @@ -464,6 +470,27 @@ class TabsWidget extends BaseWidget< validation: { type: ValidationTypes.NUMBER }, postUpdateAction: ReduxActionTypes.CHECK_CONTAINERS_FOR_AUTO_HEIGHT, }, + { + helpText: + "Space between the border and the tab content, in pixels. Use one value for all sides, or 2–4 values for top, right, bottom, left (e.g. 10 20 10 20).", + propertyName: "contentPadding", + label: "Padding (px)", + placeholderText: "e.g. 10 or 10 20 10 20", + controlType: "INPUT_TEXT", + isBindProperty: true, + isTriggerProperty: false, + validation: { + type: ValidationTypes.FUNCTION, + params: { + fn: contentPaddingValidation, + default: DEFAULT_CONTENT_PADDING, + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + }, + }, + }, + }, { propertyName: "borderRadius", label: "Border radius", @@ -572,6 +599,10 @@ class TabsWidget extends BaseWidget< isAutoHeightEnabledForWidget(this.props) && !isAutoHeightEnabledForWidgetWithLimits(this.props); + const contentPaddingPx = parseContentPadding( + this.props.contentPadding ?? DEFAULT_CONTENT_PADDING, + ); + return ( { expect(isCompactMode(unCompactHeight)).toBeFalsy(); }); }); + +describe("parseContentPadding", () => { + it("parses 1 value to all sides", () => { + expect(parseContentPadding("10")).toEqual([10, 10, 10, 10]); + }); + it("parses 2 values to vertical, horizontal", () => { + expect(parseContentPadding("10 20")).toEqual([10, 20, 10, 20]); + }); + it("parses 3 values to top, left-right, bottom", () => { + expect(parseContentPadding("10 20 30")).toEqual([10, 20, 30, 20]); + }); + it("parses 4 values to top, right, bottom, left", () => { + expect(parseContentPadding("10 20 30 40")).toEqual([10, 20, 30, 40]); + }); + it("returns default for invalid input", () => { + expect(parseContentPadding("abc")).toEqual([4, 4, 4, 4]); + expect(parseContentPadding("-1")).toEqual([4, 4, 4, 4]); + expect(parseContentPadding("1 2 3 4 5")).toEqual([4, 4, 4, 4]); + }); + it("returns default for empty string", () => { + expect(parseContentPadding("")).toEqual([4, 4, 4, 4]); + expect(parseContentPadding(" ")).toEqual([4, 4, 4, 4]); + }); + it("uses custom fallback when provided", () => { + const fb: [number, number, number, number] = [0, 0, 0, 0]; + + expect(parseContentPadding("", fb)).toEqual([0, 0, 0, 0]); + expect(parseContentPadding("invalid", fb)).toEqual([0, 0, 0, 0]); + }); +}); + +describe("contentPaddingValidation", () => { + const noop = {} as Record; + const defaultConfig = { params: { default: "4" } }; + + it("accepts 1 value", () => { + const r = contentPaddingValidation( + "10", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(true); + expect(r.parsed).toBe("10"); + }); + + it("accepts 2 values", () => { + const r = contentPaddingValidation( + "10 20", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(true); + expect(r.parsed).toBe("10 20"); + }); + + it("accepts 3 values", () => { + const r = contentPaddingValidation( + "10 20 30", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(true); + expect(r.parsed).toBe("10 20 30"); + }); + + it("accepts 4 values", () => { + const r = contentPaddingValidation( + "10 20 10 20", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(true); + expect(r.parsed).toBe("10 20 10 20"); + }); + + it("returns valid with default for empty string", () => { + const r = contentPaddingValidation("", noop, null, null, "", defaultConfig); + + expect(r.isValid).toBe(true); + expect(r.parsed).toBe("4"); + }); + + it("rejects more than 4 tokens", () => { + const r = contentPaddingValidation( + "10 20 30 40 50", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(false); + expect(r.parsed).toBe("4"); + expect(r.messages?.length).toBeGreaterThan(0); + }); + + it("rejects non-numeric token", () => { + const r = contentPaddingValidation( + "10 abc 20", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(false); + expect(r.parsed).toBe("4"); + }); + + it("rejects negative value", () => { + const r = contentPaddingValidation( + "10 -5 20", + noop, + null, + null, + "", + defaultConfig, + ); + + expect(r.isValid).toBe(false); + expect(r.parsed).toBe("4"); + }); + + it("uses config default when empty", () => { + const r = contentPaddingValidation("", noop, null, null, "", { + params: { default: "25" }, + }); + + expect(r.isValid).toBe(true); + expect(r.parsed).toBe("25"); + }); +}); diff --git a/app/client/src/widgets/WidgetUtils.ts b/app/client/src/widgets/WidgetUtils.ts index 933435896fc4..e1b8fc8a6188 100644 --- a/app/client/src/widgets/WidgetUtils.ts +++ b/app/client/src/widgets/WidgetUtils.ts @@ -1034,3 +1034,8 @@ export function parseDerivedProperties(propertyFns: Record) { return derivedProperties; } + +export { + parseContentPadding, + contentPaddingValidation, +} from "./contentPaddingUtils"; diff --git a/app/client/src/widgets/contentPaddingUtils.ts b/app/client/src/widgets/contentPaddingUtils.ts new file mode 100644 index 000000000000..8f22cb97412f --- /dev/null +++ b/app/client/src/widgets/contentPaddingUtils.ts @@ -0,0 +1,130 @@ +// Defined here (not imported from constants/WidgetConstants) to keep this file import-free and avoid circular dependencies. +export const DEFAULT_CONTENT_PADDING = "0"; +const DEFAULT_PADDING_PX = 0; + +/** + * Parse a padding string (1–4 space-separated numbers, CSS shorthand) into [top, right, bottom, left] in px. + * 1 value → all sides; 2 → vertical, horizontal; 3 → top, left-right, bottom; 4 → top, right, bottom, left. + * Invalid or empty input returns fallback or [4,4,4,4]. + */ +export function parseContentPadding( + paddingStr: string | number | undefined | null, + fallback?: [number, number, number, number], +): [number, number, number, number] { + const raw = + paddingStr != null && typeof paddingStr === "string" + ? paddingStr + : paddingStr != null + ? String(paddingStr) + : ""; + const str = raw.trim(); + + if (!str) { + return ( + fallback ?? [ + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + ] + ); + } + + const tokens = str.split(/\s+/).map((t) => parseFloat(t)); + const valid = tokens.every((n) => !Number.isNaN(n) && n >= 0); + + if (!valid || tokens.length < 1 || tokens.length > 4) { + return ( + fallback ?? [ + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + ] + ); + } + + const [a, b, c, d] = tokens; + + switch (tokens.length) { + case 1: + return [a, a, a, a]; + case 2: + return [a, b, a, b]; + case 3: + return [a, b, c, b]; + case 4: + return [a, b, c, d]; + default: + return ( + fallback ?? [ + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + DEFAULT_PADDING_PX, + ] + ); + } +} + +/** + * Validation for contentPadding property: 1–4 space-separated numbers (px), each >= 0. + * Rejects empty tokens, non-numeric, negative, or wrong token count. + * Used by Container and Form (and other container-like widgets) in property pane. + */ +export function contentPaddingValidation( + value: unknown, + _props?: Record, + _lodash?: unknown, + _moment?: unknown, + _propertyPath?: string, + config?: { params?: { default?: string } }, +): { + isValid: boolean; + parsed: string; + messages?: Array<{ name: string; message: string }>; +} { + const defaultPadding = config?.params?.default ?? "0"; + const str = + value == null ? "" : typeof value === "string" ? value : String(value); + const trimmed = str.trim(); + + if (trimmed === "") { + return { isValid: true, parsed: defaultPadding }; + } + + const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); + + if (tokens.length < 1 || tokens.length > 4) { + return { + isValid: false, + parsed: defaultPadding, + messages: [ + { + name: "ValidationError", + message: + "Enter 1 to 4 space-separated numbers (e.g. 10 or 10 20 10 20)", + }, + ], + }; + } + + for (let i = 0; i < tokens.length; i++) { + const n = parseFloat(tokens[i]); + + if (Number.isNaN(n) || n < 0) { + return { + isValid: false, + parsed: defaultPadding, + messages: [ + { + name: "ValidationError", + message: "Each value must be a non-negative number", + }, + ], + }; + } + } + + return { isValid: true, parsed: trimmed }; +} diff --git a/app/client/src/widgets/withWidgetProps.tsx b/app/client/src/widgets/withWidgetProps.tsx index 692e9d6526f4..bde56aae67c9 100644 --- a/app/client/src/widgets/withWidgetProps.tsx +++ b/app/client/src/widgets/withWidgetProps.tsx @@ -230,6 +230,12 @@ function withWidgetProps(WrappedWidget: typeof BaseWidget) { props.noPad && props.dropDisabled && props.openParentPropertyPane; widgetProps.rightColumn = props.rightColumn; + // Canvas widgets use parentColumnSpace = 1 (rightColumn is in pixel units), + // so componentWidth must be kept in sync with the (possibly adjusted) rightColumn. + // The data-tree-computed componentWidth uses the Redux-stored rightColumn and + // would not reflect any padding adjustment made in renderChildWidget. + widgetProps.componentWidth = + props.rightColumn - (widgetProps.leftColumn || 0); if (isListWidgetCanvas) { widgetProps.bottomRow = props.bottomRow;