diff --git a/app/client/src/constants/WidgetConstants.tsx b/app/client/src/constants/WidgetConstants.tsx index d5777458d5f7..078434aad6ae 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 = "4"; + 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/utils/paddingValidation.ts b/app/client/src/utils/paddingValidation.ts new file mode 100644 index 000000000000..df5174fa0b2f --- /dev/null +++ b/app/client/src/utils/paddingValidation.ts @@ -0,0 +1,59 @@ +import type { ValidationResponse } from "constants/WidgetValidation"; + +/** + * Shared validation for contentPadding-style properties. + * + * - Accepts 1–4 space-separated numbers (px), each >= 0 + * - Returns { isValid, parsed, messages? } in the shape expected by + * FUNCTION-type widget property validations. + * + * The default value is provided by the caller so different widgets + * (e.g. JSON Form vs Container) can use different defaults. + */ +export function validatePaddingString( + value: unknown, + defaultValue: string, +): ValidationResponse { + const raw = + value == null ? "" : typeof value === "string" ? value : String(value); + const trimmed = raw.trim(); + + if (!trimmed) { + return { isValid: true, parsed: defaultValue }; + } + + const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); + + if (tokens.length < 1 || tokens.length > 4) { + return { + isValid: false, + parsed: defaultValue, + messages: [ + { + name: "ValidationError", + message: + "Enter 1 to 4 space-separated numbers (e.g. 10 or 10 20 10 20).", + }, + ], + }; + } + + for (const token of tokens) { + const n = parseFloat(token); + + if (Number.isNaN(n) || n < 0) { + return { + isValid: false, + parsed: defaultValue, + messages: [ + { + name: "ValidationError", + message: "Each value must be a non-negative number.", + }, + ], + }; + } + } + + return { isValid: true, parsed: trimmed }; +} 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 ( + validatePaddingString(value, DEFAULT_CONTENT_PADDING), + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + // Keep default autocomplete type for consistency + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + }, { propertyName: "borderRadius", label: "Border radius", @@ -367,15 +397,16 @@ export class ContainerWidget extends BaseWidget< } renderChildren = () => { - 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..1ab1b6c5d818 100644 --- a/app/client/src/widgets/JSONFormWidget/widget/propertyConfig.ts +++ b/app/client/src/widgets/JSONFormWidget/widget/propertyConfig.ts @@ -16,6 +16,8 @@ import { SUCCESSFULL_BINDING_MESSAGE, } from "../constants/messages"; import { createMessage } from "ee/constants/messages"; +import { validatePaddingString } from "utils/paddingValidation"; +import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType"; import { FieldOptionsType } from "components/editorComponents/WidgetQueryGeneratorForm/WidgetSpecificControls/OtherFields/Field/Dropdown/types"; import { DROPDOWN_VARIANT } from "components/editorComponents/WidgetQueryGeneratorForm/CommonControls/DatasourceDropdown/types"; @@ -659,7 +661,7 @@ export const styleConfig = [ ], }, { - sectionName: "Border and shadow", + sectionName: "Border, shadow & padding", children: [ { propertyName: "borderWidth", @@ -671,6 +673,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: (value: unknown) => validatePaddingString(value, "25"), + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + }, { 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..dd6e32c994f0 100644 --- a/app/client/src/widgets/ListWidgetV2/widget/propertyConfig.ts +++ b/app/client/src/widgets/ListWidgetV2/widget/propertyConfig.ts @@ -13,6 +13,8 @@ import { LIST_WIDGET_V2_TOTAL_RECORD_TOOLTIP, createMessage, } from "ee/constants/messages"; +import { DEFAULT_CONTENT_PADDING } from "constants/WidgetConstants"; +import { validatePaddingString } from "utils/paddingValidation"; const MIN_ITEM_SPACING = 0; const MAX_ITEM_SPACING = 16; @@ -472,8 +474,30 @@ 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: (value: unknown) => + validatePaddingString(value, DEFAULT_CONTENT_PADDING), + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + }, { 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..f6901d73b619 100644 --- a/app/client/src/widgets/TabsWidget/widget/index.tsx +++ b/app/client/src/widgets/TabsWidget/widget/index.tsx @@ -23,7 +23,9 @@ import { isAutoHeightEnabledForWidget, isAutoHeightEnabledForWidgetWithLimits, DefaultAutocompleteDefinitions, + parseContentPadding, } from "widgets/WidgetUtils"; +import { validatePaddingString } from "utils/paddingValidation"; import type { AnvilConfig, AutocompletionDefinitions, @@ -32,6 +34,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 +105,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 +424,7 @@ class TabsWidget extends BaseWidget< static getPropertyPaneStyleConfig() { return [ { - sectionName: "Colors, Borders and Shadows", + sectionName: "Colors, border, shadow & padding", children: [ { propertyName: "accentColor", @@ -464,6 +468,28 @@ 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: (value: unknown) => + validatePaddingString(value, DEFAULT_CONTENT_PADDING), + expected: { + type: "1–4 space-separated numbers (px)", + example: "10 or 10 20 10 20", + autocompleteDataType: AutocompleteDataType.STRING, + }, + }, + }, + }, { propertyName: "borderRadius", label: "Border radius", @@ -572,6 +598,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]); + }); +}); diff --git a/app/client/src/widgets/WidgetUtils.ts b/app/client/src/widgets/WidgetUtils.ts index 933435896fc4..b8a43681c5cf 100644 --- a/app/client/src/widgets/WidgetUtils.ts +++ b/app/client/src/widgets/WidgetUtils.ts @@ -15,6 +15,7 @@ import type { PropertyUpdates } from "WidgetProvider/types"; import { CANVAS_SELECTOR, CONTAINER_GRID_PADDING, + DEFAULT_CONTENT_PADDING, GridDefaults, TextSizes, WidgetHeightLimits, @@ -74,6 +75,56 @@ export function getSnapSpaces(props: WidgetPositionProps) { }; } +/** + * 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 default from DEFAULT_CONTENT_PADDING. + */ +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) { + const d = parseInt(DEFAULT_CONTENT_PADDING, 10) || 0; + + return fallback ?? [d, d, d, d]; + } + + 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) { + const d = parseInt(DEFAULT_CONTENT_PADDING, 10) || 0; + + return fallback ?? [d, d, d, d]; + } + + 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: + const def = parseInt(DEFAULT_CONTENT_PADDING, 10) || 0; + + return fallback ?? [def, def, def, def]; + } +} + export const DefaultAutocompleteDefinitions = { isVisible: { "!type": "bool",