diff --git a/packages/fiori/cypress/specs/FCL.cy.tsx b/packages/fiori/cypress/specs/FCL.cy.tsx index b440858e4f29..f946a1133a51 100644 --- a/packages/fiori/cypress/specs/FCL.cy.tsx +++ b/packages/fiori/cypress/specs/FCL.cy.tsx @@ -209,6 +209,25 @@ describe("FlexibleColumnLayout Behavior", () => { cy.get("@fcl") .should("have.attr", "_visible-columns", "3"); + // Assert default column widths + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--start") + .should("have.attr", "style") + .and("include", "width: 25%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--middle") + .should("have.attr", "style") + .and("include", "width: 50%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--end") + .should("have.attr", "style") + .and("include", "width: 25%"); + cy.get("@layoutChangeStub") .should("not.have.been.called"); }); @@ -233,6 +252,25 @@ describe("FlexibleColumnLayout Behavior", () => { cy.get("@fcl") .should("have.attr", "_visible-columns", "2"); + // Assert default column widths for tablet size + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--start") + .should("have.attr", "style") + .and("include", "width: 0px"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--middle") + .should("have.attr", "style") + .and("include", "width: 67%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--end") + .should("have.attr", "style") + .and("include", "width: 33%"); + cy.get("@layoutChangeStub") .should("have.been.calledOnce"); }); @@ -1116,4 +1154,258 @@ describe("Accessibility with Animation Disabled", () => { .find(".ui5-fcl-column--middle") .should("have.attr", "aria-hidden", "true"); }); -}); \ No newline at end of file +}); + +describe("Layouts configuration", () => { + const COLUMN_MIN_WIDTH = 248; + + it("initial configuration", () => { + + cy.mount( + +
some content
+
some content
+
some content
+
+ ); + + cy.get("[ui5-flexible-column-layout]") + .as("fcl") + .then($fcl => { + $fcl.get(0).addEventListener("layout-configuration-change", cy.stub().as("layoutConfigChangeStub")); + }); + + // Assert resulting column widths + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--start") + .should("have.attr", "style") + .and("include", "width: 67%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--middle") + .should("have.attr", "style") + .and("include", "width: 33%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--end") + .should("have.attr", "style") + .and("include", "width: 0px"); + + // Assert layoutsConfiguration is initially an empty object + cy.get("@fcl") + .invoke("prop", "layoutsConfiguration") + .should("deep.equal", {}); + + cy.get("@layoutConfigChangeStub") + .should("not.have.been.called"); + }); + + it("allows set configuration programatically", () => { + + cy.mount( + +
some content
+
some content
+
some content
+
+ ); + + cy.get("[ui5-flexible-column-layout]") + .as("fcl") + .then($fcl => { + $fcl.get(0).addEventListener("layout-configuration-change", cy.stub().as("layoutConfigChangeStub")); + }); + + // Set layoutsConfiguration programmatically + cy.get("@fcl") + .invoke("prop", "layoutsConfiguration", { + "desktop": { + "TwoColumnsStartExpanded": { + layout: ["75%", "25%", "0%"] + } + } + }); + + // Assert resulting column widths + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--start") + .should("have.attr", "style") + .and("include", "width: 75%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--middle") + .should("have.attr", "style") + .and("include", "width: 25%"); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--end") + .should("have.attr", "style") + .and("include", "width: 0px"); + + cy.get("@layoutConfigChangeStub") + .should("not.have.been.called"); + }); + + it("fires layout-configuration-change event when dragging separator within same layout", () => { + cy.mount( + +
Start
+
Mid
+
End
+
+ ); + + cy.get("[ui5-flexible-column-layout]").then(($fcl) => { + const fcl = $fcl[0]; + fcl.addEventListener("layout-configuration-change", cy.stub().as("layoutConfigurationChanged")); + }); + + cy.get("[ui5-flexible-column-layout]").as("fcl"); + + // resize the columns within the same layout-type + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-separator-start") + .should("be.visible") + .realMouseDown() + .realMouseMove(10, 0) + .realMouseUp(); + + cy.get("@layoutConfigurationChanged").should("have.been.calledOnce"); + cy.get("@fcl").should("have.prop", "layout", "TwoColumnsStartExpanded"); + + // Check that layoutsConfiguration property has the expected structure + cy.get("@fcl").invoke("prop", "layoutsConfiguration").then((layoutsConfig: any) => { + expect(layoutsConfig).to.have.property("desktop"); + expect(layoutsConfig.desktop).to.have.property("TwoColumnsStartExpanded"); + expect(layoutsConfig.desktop.TwoColumnsStartExpanded).to.have.property("layout"); + expect(layoutsConfig.desktop.TwoColumnsStartExpanded.layout).to.be.an("array"); + expect(layoutsConfig.desktop.TwoColumnsStartExpanded.layout).to.have.length(3); + expect(layoutsConfig.desktop.TwoColumnsStartExpanded.layout).to.satisfy((arr: any[]) => + arr.every(item => typeof item === "string") + ); + + // Check the exact values of the layout array + const layoutArray = layoutsConfig.desktop.TwoColumnsStartExpanded.layout; + + // 1) Calling parseInt on each of them should return a number + const parsedNumbers = layoutArray.map((item: string) => parseInt(item, 10)); + expect(parsedNumbers).to.satisfy((nums: number[]) => + nums.every(num => !isNaN(num)) + ); + + // 2) The last number should be 0 + expect(parsedNumbers[2]).to.equal(0); + + // 3) The first number should be greater than the second number + expect(parsedNumbers[0]).to.be.greaterThan(parsedNumbers[1]); + }); + }); + + it("fires layout-configuration-change event when dragging separator to update the layout", () => { + cy.mount( + +
Start
+
Mid
+
End
+
+ ); + + cy.get("[ui5-flexible-column-layout]").then(($fcl) => { + const fcl = $fcl[0]; + fcl.addEventListener("layout-configuration-change", cy.stub().as("layoutConfigurationChanged")); + }); + + cy.get("[ui5-flexible-column-layout]").as("fcl"); + + // resize the columns to a new layout-type + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-separator-start") + .should("be.visible") + .realMouseDown() + .realMouseMove(-400, 0) + .realMouseUp(); + + cy.get("@layoutConfigurationChanged").should("have.been.calledOnce"); + cy.get("@fcl").should("have.prop", "layout", "TwoColumnsMidExpanded"); + + // Check that layoutsConfiguration property has the expected structure + cy.get("@fcl").invoke("prop", "layoutsConfiguration").then((layoutsConfig: any) => { + expect(layoutsConfig).to.have.property("desktop"); + expect(layoutsConfig.desktop).to.have.property("TwoColumnsMidExpanded"); + expect(layoutsConfig.desktop.TwoColumnsMidExpanded).to.have.property("layout"); + expect(layoutsConfig.desktop.TwoColumnsMidExpanded.layout).to.be.an("array"); + expect(layoutsConfig.desktop.TwoColumnsMidExpanded.layout).to.have.length(3); + expect(layoutsConfig.desktop.TwoColumnsMidExpanded.layout).to.satisfy((arr: any[]) => + arr.every(item => typeof item === "string") + ); + + // Check the exact values of the layout array + const layoutArray = layoutsConfig.desktop.TwoColumnsMidExpanded.layout; + + // 1) Calling parseInt on each of them should return a number + const parsedNumbers = layoutArray.map((item: string) => parseInt(item, 10)); + expect(parsedNumbers).to.satisfy((nums: number[]) => + nums.every(num => !isNaN(num)) + ); + + // 2) The last number should be 0 + expect(parsedNumbers[2]).to.equal(0); + + // 3) The first number should be smaller than the second number + expect(parsedNumbers[0]).to.be.lessThan(parsedNumbers[1]); + }); + }); + + it("applies min width constraints", () => { + + cy.mount( + +
some content
+
some content
+
some content
+
+ ); + + cy.get("[ui5-flexible-column-layout]") + .as("fcl") + .then($fcl => { + $fcl.get(0).addEventListener("layout-configuration-change", cy.stub().as("layoutConfigChangeStub")); + }); + + // Set layoutsConfiguration programmatically + cy.get("@fcl") + .invoke("prop", "layoutsConfiguration", { + "desktop": { + "ThreeColumnsMidExpanded": { + layout: ["10%", "80%", "10%"] + } + } + }); + + // Assert resulting column widths respect minimum width constraint + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--start") + .should("have.prop", "offsetWidth", COLUMN_MIN_WIDTH); + + cy.get("@fcl") + .shadow() + .find(".ui5-fcl-column--end") + .should("have.prop", "offsetWidth", COLUMN_MIN_WIDTH); + + cy.get("@layoutConfigChangeStub") + .should("not.have.been.called"); + }); + +}); + diff --git a/packages/fiori/src/FlexibleColumnLayout.ts b/packages/fiori/src/FlexibleColumnLayout.ts index 06d0b24db6ba..adc911a15891 100644 --- a/packages/fiori/src/FlexibleColumnLayout.ts +++ b/packages/fiori/src/FlexibleColumnLayout.ts @@ -26,9 +26,8 @@ import { } from "@ui5/webcomponents-base/dist/Keys.js"; import type { PassiveEventListenerObject, AriaLandmarkRole } from "@ui5/webcomponents-base"; import FCLLayout from "./types/FCLLayout.js"; -import type { LayoutConfiguration } from "./fcl-utils/FCLLayout.js"; import { - getLayoutsByMedia, + getDefaultLayoutsByMedia, getNextLayoutByArrowPress, } from "./fcl-utils/FCLLayout.js"; @@ -77,7 +76,20 @@ type SeparatorMovementSession = { tmpFCLLayout: FCLLayout, // the layout that corresponds to the latest separator position }; -type FlexibleColumnLayoutColumnLayout = Array; +type FlexibleColumnLayoutColumnLayout = Array; + +type LayoutConfiguration = { + "tablet"?: { + [layoutName in FCLLayout]?: { + layout: FlexibleColumnLayoutColumnLayout, + } + }, + "desktop"?: { + [layoutName in FCLLayout]?: { + layout: FlexibleColumnLayoutColumnLayout, + } + }, +} type FlexibleColumnLayoutLayoutChangeEventDetail = { layout: `${FCLLayout}`, @@ -89,6 +101,12 @@ type FlexibleColumnLayoutLayoutChangeEventDetail = { resized: boolean, }; +type FlexibleColumnLayoutLayoutConfigurationChangeEventDetail = { + layout: `${FCLLayout}`, + media: `${MEDIA}`, + columnLayout: FlexibleColumnLayoutColumnLayout, +}; + type FCLAccessibilityRoles = Extract type FCLAccessibilityAttributes = { startColumn?: { @@ -113,15 +131,6 @@ type FCLAccessibilityAttributes = { }, } -type UserDefinedColumnLayouts = { - "tablet": { - [layoutName in FCLLayout]?: FlexibleColumnLayoutColumnLayout; - }, - "desktop": { - [layoutName in FCLLayout]?: FlexibleColumnLayoutColumnLayout; - }, -} - /** * @class * @@ -195,9 +204,23 @@ type UserDefinedColumnLayouts = { @event("layout-change", { bubbles: true, }) + +/** + * Fired when the `layoutsConfiguration` changes via user interaction by dragging the separators. + * @param {FCLLayout} layout The current layout + * @param {MEDIA} media The current media type + * @param {array} columnLayout The effective column layout, f.e ["67%", "33%", "0px"] + * @public + * @since 2.16.0 + * @experimental + */ +@event("layout-configuration-change", { + bubbles: true, +}) class FlexibleColumnLayout extends UI5Element { eventDetails!: { "layout-change": FlexibleColumnLayoutLayoutChangeEventDetail, + "layout-configuration-change": FlexibleColumnLayoutLayoutConfigurationChangeEventDetail, } /** * Defines the columns layout and their proportion. @@ -249,6 +272,26 @@ class FlexibleColumnLayout extends UI5Element { @property({ type: Object }) accessibilityAttributes: FCLAccessibilityAttributes = {}; + /** + * Allows to customize the column proportions per screen size and layout. + * If no custom proportion provided for a specific layout, the default will be used. + * + * **Notes:** + * + * - The proportions should be given in percentages. For example ["30%", "40%", "30%"], ["70%", "30%", 0], etc. + * - The proportions should add up to 100%. + * - Hidden columns are marked as "0px", e.g. ["0px", "70%", "30%"]. Specifying 0 or "0%" for hidden columns is also valid. + * - If the proportions do not match the layout (e.g. if provided proportions ["70%", "30%", "0px"] for "OneColumn" layout), then the default proportions will be used instead. + * - Whenever the user drags the columns separator to resize the columns, the `layoutsConfiguration` object will be updated with the user-specified proportions for the given layout (and the `layout-configuration-change` event will be fired). + * - No custom configuration available for the phone screen size, as the default of 100% column width is always used there. + * @default {} + * @public + * @since 2.16.0 + * @experimental + */ + @property({ type: Object }) + layoutsConfiguration: LayoutConfiguration = {}; + /** * Defines the component width in px. * @default 0 @@ -284,11 +327,20 @@ class FlexibleColumnLayout extends UI5Element { _resizing = false; /** - * Allows the user to replace the whole layouts configuration - * @private + * This property is no longer used and is replaced by `layoutsConfiguration`. + * The property will be removed once all adopters migrate to `layoutsConfiguration`. */ @property({ type: Object }) - _layoutsConfiguration?: LayoutConfiguration; + _layoutsConfiguration?: { + [device in MEDIA]: { + [layoutName in FCLLayout]: { + layout: Array; + separators: Array<{ + visible: boolean; + }>; + }; + }; + }; /** * Defines the content in the start column. @@ -321,10 +373,7 @@ class FlexibleColumnLayout extends UI5Element { static i18nBundle: I18nBundle; _prevLayout: `${FCLLayout}` | null; - _userDefinedColumnLayouts: UserDefinedColumnLayouts = { - tablet: {}, - desktop: {}, - }; + _prevLayoutsConfiguration: LayoutConfiguration | null; _ontouchstart: PassiveEventListenerObject; separatorMovementSession?: SeparatorMovementSession | null; @@ -332,6 +381,7 @@ class FlexibleColumnLayout extends UI5Element { super(); this._prevLayout = null; + this._prevLayoutsConfiguration = null; this.initialRendering = true; this._handleResize = this.handleResize.bind(this); this._onSeparatorMove = this.onSeparatorMove.bind(this); @@ -366,6 +416,7 @@ class FlexibleColumnLayout extends UI5Element { return; } + this.syncLayoutsConfiguration(); this.syncLayout(); } @@ -406,6 +457,15 @@ class FlexibleColumnLayout extends UI5Element { } } + syncLayoutsConfiguration() { + if (this._prevLayoutsConfiguration !== this.layoutsConfiguration) { + this._prevLayoutsConfiguration = this.layoutsConfiguration; + if (this.nextColumnLayout(this.layout).join() !== this._columnLayout?.join() && !this.separatorMovementSession) { + this.updateLayout(); + } + } + } + toggleColumns() { this.toggleColumn("start"); this.toggleColumn("mid"); @@ -481,11 +541,176 @@ class FlexibleColumnLayout extends UI5Element { } nextColumnLayout(layout: `${FCLLayout}`) { - let userDefinedLayout; - if (this.media !== MEDIA.PHONE) { - userDefinedLayout = this._userDefinedColumnLayouts[this.media][layout]; + return this.getCustomColumnLayout(layout) || this.getDefaultColumnLayout(layout); + } + + /** + * Gets custom column layout configuration if available and valid. + * Ensures all visible columns meet minimum width requirements. + * @param layout The FCL layout to get configuration for + * @returns Normalized column layout or undefined if invalid/unavailable + */ + getCustomColumnLayout(layout: `${FCLLayout}`) { + // Only allow custom configuration for tablet and desktop (not phone) + if (!this.mediaAllowsCustomConfiguration(this.media)) { + return undefined; + } + + const customLayout = this.layoutsConfiguration[this.media]?.[layout]?.layout; + if (!customLayout) { + return undefined; + } + + // ensure visible columns are above min-width given the current fcl total width + const constraintCompliantLayout = this.applyMinimumWidthConstraints(customLayout); + if (this.isValidColumnLayout(constraintCompliantLayout)) { // satisfy layout-specific contraints + return constraintCompliantLayout; + } + } + + getDefaultColumnLayout(layout: `${FCLLayout}`) { + return getDefaultLayoutsByMedia()[this.media][layout].layout; + } + + mediaAllowsCustomConfiguration(media: MEDIA) { + return media !== MEDIA.PHONE; + } + + /** + * Applies minimum width constraints to column layout configuration. + * Ensures all visible columns meet the minimum width requirement by transferring + * space from the wider columns to the undersized columns. + * @param columnLayout Original column layout (percentages or pixels) + * @returns Constraint-compliant column layout in same format as input + */ + applyMinimumWidthConstraints(columnLayout: (string | 0)[]) { + return this.doWithPixelConversion(columnLayout, pxWidths => { + return this.adjustColumnsToMinimumWidth(pxWidths); + }); + } + + /** + * Adjusts column widths to ensure minimum width constraints. + * Takes width from the widest columns to bring undersized columns up to minimum. + * @param pxWidths Array of column widths in pixels (modified in place) + */ + adjustColumnsToMinimumWidth(pxWidths: number[]) { + const adjustedWidths = [...pxWidths]; + + let totalDeficit = 0; + for (let i = 0; i < adjustedWidths.length; i++) { + const width = adjustedWidths[i]; + const isBelowMinimum = Math.ceil(width) < COLUMN_MIN_WIDTH; // ceil to avoid floating point precision issues + + if (!this._isColumnHidden(width) && isBelowMinimum) { + totalDeficit += COLUMN_MIN_WIDTH - width; + adjustedWidths[i] = COLUMN_MIN_WIDTH; + } + } + + if (totalDeficit === 0) { + return adjustedWidths; // no adjustments were needed + } + + // Create proportions for redistribution of the deficit based on available space above COLUMN_MIN_WIDTH + const columnProportions = this.getColumnProportionsAboveMinWidth(pxWidths); + + // Redistribute the deficit proportionally among columns that can contribute + for (let i = 0; i < adjustedWidths.length; i++) { + const isVisible = adjustedWidths[i] > 0; + if (isVisible && columnProportions[i] > 0) { + adjustedWidths[i] -= totalDeficit * columnProportions[i]; + } + } + + return adjustedWidths; + } + + getColumnProportionsAboveMinWidth(columnPxWidths: number[]) { + const widthsAboveMinWidth = columnPxWidths.map(width => { + if (width > COLUMN_MIN_WIDTH) { + return width - COLUMN_MIN_WIDTH; + } + return 0; + }); + + const total = widthsAboveMinWidth.reduce((sum, width) => sum + width, 0); + + if (total === 0) { + return widthsAboveMinWidth; + } + + return widthsAboveMinWidth.map(width => width / total); + } + + /** + * Helper that handles pixel conversion for column width operations. + * Converts input to pixels, applies the operation, then converts back to relative widths. + * @param columnLayout Column layout in mixed formats + * @param operation Function that operates on pixel widths + * @returns Column layout in percentage format + */ + doWithPixelConversion( + columnLayout: (string | 0)[], + operation: (pxWidths: number[]) => number[], + ) { + // Convert to pixels for calculations + const pxWidths = columnLayout.map(width => this.convertColumnWidthToPixels(width)); + + // Apply the operation + const adjustedPxWidths = operation(pxWidths); + + // Convert back to percentage-based widths + return adjustedPxWidths.map(width => this.convertToRelativeColumnWidth(width)); + } + + isValidColumnLayout(columnLayout: (string | 0)[]) { + const pxWidths = columnLayout?.map(w => this.convertColumnWidthToPixels(w)); + const totalWidth = pxWidths.reduce((i, sum) => i + sum); + + if (Math.round(totalWidth) !== Math.round(this._availableWidthForColumns)) { + return false; + } + + return this.verifyColumnWidthsMatchLayout(pxWidths); + } + + verifyColumnWidthsMatchLayout(pxWidths: number[]) { + const columnWidths = { + start: pxWidths[0], + mid: pxWidths[1], + end: pxWidths[2], + }, + startWidth = columnWidths.start, + startPercentWidth = parseInt(this.convertToRelativeColumnWidth(startWidth)); + + switch (this.layout) { + case FCLLayout.TwoColumnsStartExpanded: { + return columnWidths.start >= columnWidths.mid; + } + case FCLLayout.TwoColumnsMidExpanded: { + return columnWidths.mid > columnWidths.start; } - return userDefinedLayout || this._effectiveLayoutsByMedia[this.media][layout].layout; + case FCLLayout.ThreeColumnsEndExpanded: { + return (columnWidths.end > columnWidths.mid) && (startPercentWidth < 33); + } + case FCLLayout.ThreeColumnsStartExpandedEndHidden: { + return (columnWidths.start >= columnWidths.mid) && columnWidths.end === 0; + } + case FCLLayout.ThreeColumnsMidExpanded: { + return (columnWidths.mid >= columnWidths.end) + && ((this.media === MEDIA.DESKTOP && startPercentWidth < 33) // desktop + || (this.media === MEDIA.TABLET && startPercentWidth === 0)); // tablet + } + case FCLLayout.ThreeColumnsMidExpandedEndHidden: { + return (columnWidths.mid > columnWidths.start) + && columnWidths.end === 0 + && ((this.media === MEDIA.DESKTOP && startPercentWidth >= 33) + || (this.media === MEDIA.TABLET && startWidth >= COLUMN_MIN_WIDTH)); + } + } + + return false; } calcVisibleColumns(colLayout: FlexibleColumnLayoutColumnLayout) { @@ -493,9 +718,10 @@ class FlexibleColumnLayout extends UI5Element { } fireLayoutChange(separatorUsed: boolean, resized: boolean) { + const columnLayout = [...this._columnLayout!] as string[]; // do not leak reference to the private _columnLayout array to prevent apps modifying its content this.fireDecoratorEvent("layout-change", { layout: this.layout, - columnLayout: this._columnLayout!, + columnLayout, startColumnVisible: this.startColumnVisible, midColumnVisible: this.midColumnVisible, endColumnVisible: this.endColumnVisible, @@ -504,6 +730,15 @@ class FlexibleColumnLayout extends UI5Element { }); } + fireLayoutConfigurationChange() { + const columnLayout = [...this._columnLayout!] as string[]; // do not leak reference to the private _columnLayout array to prevent apps modifying its content + this.fireDecoratorEvent("layout-configuration-change", { + layout: this.layout, + media: this.media, + columnLayout, + }); + } + onSeparatorPress(e: TouchEvent | MouseEvent) { if (e.target as HTMLElement === this.startArrowDOM) { return; @@ -560,7 +795,7 @@ class FlexibleColumnLayout extends UI5Element { return; } const newLayout = this.separatorMovementSession.tmpFCLLayout; - const newColumnLayout = this._columnLayout!; + const newColumnLayout = [...this._columnLayout!] as string[]; // obtain the values only this.saveUserDefinedColumnLayout(newLayout, newColumnLayout); this.exitSeparatorMovementSession(); @@ -590,14 +825,24 @@ class FlexibleColumnLayout extends UI5Element { this.separatorMovementSession = null; } - saveUserDefinedColumnLayout(newLayout: FCLLayout, newColumnLayout: FlexibleColumnLayoutColumnLayout) { - const media = this.media as MEDIA.TABLET | MEDIA.DESKTOP; - - this._userDefinedColumnLayouts[media][newLayout] = newColumnLayout; + saveUserDefinedColumnLayout(newLayout: FCLLayout, newColumnLayout: string[]) { + const oldColumnLayout = this.getCustomColumnLayout(newLayout); if (this.layout !== newLayout) { this.layout = newLayout; this.fireLayoutChange(true, false); } + if (oldColumnLayout?.join() !== newColumnLayout.join()) { // compare arrays' content + this.updateLayoutsConfiguration(newLayout, newColumnLayout); + this.fireLayoutConfigurationChange(); + } + } + + updateLayoutsConfiguration(layout: `${FCLLayout}`, columnLayout: string[]) { + if (this.mediaAllowsCustomConfiguration(this.media)) { + this.layoutsConfiguration[this.media] ??= {}; + this.layoutsConfiguration[this.media]![layout] ??= { layout: columnLayout }; + this.layoutsConfiguration[this.media]![layout]!.layout = columnLayout; + } } private isSeparatorAheadOfCursor(cursorX: number, separatorX: number, isForwardMove: boolean) { @@ -689,13 +934,13 @@ class FlexibleColumnLayout extends UI5Element { _onArrowKeydown(e: KeyboardEvent) { if (isEnter(e) || isSpace(e)) { - e.preventDefault(); - const focusedElement = e.target as HTMLElement; - if (focusedElement === this.startArrowDOM) { + e.preventDefault(); + const focusedElement = e.target as HTMLElement; + if (focusedElement === this.startArrowDOM) { this.switchLayoutOnArrowPress(); - } + } } - } + } async _onSeparatorKeydown(e: KeyboardEvent) { const separator = e.target as HTMLElement; @@ -1109,7 +1354,7 @@ class FlexibleColumnLayout extends UI5Element { } get effectiveSeparatorsInfo() { - return this._effectiveLayoutsByMedia[this.media][this.effectiveLayout].separators; + return getDefaultLayoutsByMedia()[this.media][this.effectiveLayout].separators; } get effectiveLayout() { @@ -1229,10 +1474,6 @@ class FlexibleColumnLayout extends UI5Element { return this.accessibilityAttributes.endSeparator?.role || "separator"; } - get _effectiveLayoutsByMedia() { - return this._layoutsConfiguration || getLayoutsByMedia(); - } - get _accAttributes() { return { columns: { @@ -1260,6 +1501,8 @@ export default FlexibleColumnLayout; export type { MEDIA, FlexibleColumnLayoutLayoutChangeEventDetail, + FlexibleColumnLayoutLayoutConfigurationChangeEventDetail, FCLAccessibilityAttributes, FlexibleColumnLayoutColumnLayout, + LayoutConfiguration, }; diff --git a/packages/fiori/src/fcl-utils/FCLLayout.ts b/packages/fiori/src/fcl-utils/FCLLayout.ts index 25cefad2d518..da491fc3a99d 100644 --- a/packages/fiori/src/fcl-utils/FCLLayout.ts +++ b/packages/fiori/src/fcl-utils/FCLLayout.ts @@ -1,7 +1,7 @@ import type { MEDIA } from "../FlexibleColumnLayout.js"; import type FCLLayout from "../types/FCLLayout.js"; -type LayoutConfiguration = { +type DefaultLayoutConfiguration = { [device in MEDIA]: { [layoutName in FCLLayout]: { layout: Array; @@ -15,7 +15,7 @@ type LayoutConfiguration = { }; }; -const getLayoutsByMedia = (): LayoutConfiguration => { +const getDefaultLayoutsByMedia = (): DefaultLayoutConfiguration => { return { desktop: { "OneColumn": { @@ -287,11 +287,10 @@ const getNextLayoutByArrowPress = () => { }; export { - getLayoutsByMedia, + getDefaultLayoutsByMedia, getNextLayoutByArrowPress, }; export type { - LayoutConfiguration, FCLLayout, }; diff --git a/packages/fiori/test/pages/FCL.html b/packages/fiori/test/pages/FCL.html index ee8f7b5df815..ac70ca230906 100644 --- a/packages/fiori/test/pages/FCL.html +++ b/packages/fiori/test/pages/FCL.html @@ -19,12 +19,20 @@

List-Detail: List View Expanded Set to ThreeColumnsMidExpanded
-
-
-
-
-
- Switch RTL: +
+ Layout + + columns layout + + columns visibility + + resize or separator movement + + layout-change counter + + layout-distribution-change counter + + Switch RTL
@@ -776,8 +784,10 @@ var counter = 0; var counter2 = 0; + var layoutConfigurationChangeCounter = 0; fcl1.addEventListener("ui5-layout-change", function(e) { - layoutChangeRes.value = "layout ::" + e.detail.layout + " columns ::" + e.detail.columnLayout; + layoutChangeRes.value = e.detail.layout; + layoutChangeRes1.value = e.detail.columnLayout; layoutChangeRes2.value = "startColumnVisible :: " + e.detail.startColumnVisible + ", midColumnVisible :: " + e.detail.midColumnVisible @@ -787,6 +797,12 @@ }); + fcl1.addEventListener("ui5-layout-configuration-change", function(e) { + layoutChangeRes.value = e.detail.layout; + layoutChangeRes1.value = e.detail.columnLayout; + layoutChangeRes5.value = `${++layoutConfigurationChangeCounter}`; + }); + fcl3.addEventListener("ui5-layout-change", function(e) { testLayoutChange.value = `${++counter2}`; }); diff --git a/packages/fiori/test/pages/FCLCustom.html b/packages/fiori/test/pages/FCLCustom.html index 02f144db6286..58e5dd264199 100644 --- a/packages/fiori/test/pages/FCLCustom.html +++ b/packages/fiori/test/pages/FCLCustom.html @@ -15,7 +15,15 @@ - + Select layout + + TwoColumnsStartExpanded + TwoColumnsMidExpanded + ThreeColumnsMidExpanded + ThreeColumnsEndExpanded + ThreeColumnsStartExpandedEndHidden + ThreeColumnsMidExpandedEndHidden +
@@ -66,211 +74,53 @@ diff --git a/packages/fiori/test/pages/styles/FCL.css b/packages/fiori/test/pages/styles/FCL.css index 5e79a3bb053b..e700c028bd34 100644 --- a/packages/fiori/test/pages/styles/FCL.css +++ b/packages/fiori/test/pages/styles/FCL.css @@ -34,6 +34,15 @@ html, body { flex-wrap: wrap; } +.status { + display: grid; + grid-template-columns: max-content auto; +} + +.status [ui5-label] { + align-self: center;; +} + .fcl1auto { background-color: var(--sapBackgroundColor); } diff --git a/packages/website/docs/_components_pages/fiori/FlexibleColumnLayout.mdx b/packages/website/docs/_components_pages/fiori/FlexibleColumnLayout.mdx index b90c756cac12..384236dd1f0c 100644 --- a/packages/website/docs/_components_pages/fiori/FlexibleColumnLayout.mdx +++ b/packages/website/docs/_components_pages/fiori/FlexibleColumnLayout.mdx @@ -1,8 +1,16 @@ import Basic from "../../_samples/fiori/FlexibleColumnLayout/Basic/Basic.md"; +import LayoutsConfiguration from "../../_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/LayoutsConfiguration.md"; <%COMPONENT_OVERVIEW%> ## Basic Sample -<%COMPONENT_METADATA%> \ No newline at end of file +<%COMPONENT_METADATA%> + +## More Samples + +### LayoutsConfiguration +The FlexibleColumnLayout supports customization of the column sizes within the limits of the current layout. + + \ No newline at end of file diff --git a/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/LayoutsConfiguration.md b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/LayoutsConfiguration.md new file mode 100644 index 000000000000..0ac936771661 --- /dev/null +++ b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/LayoutsConfiguration.md @@ -0,0 +1,5 @@ +import html from '!!raw-loader!./sample.html'; +import js from '!!raw-loader!./main.js'; +import css from '!!raw-loader!./main.css'; + + diff --git a/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/main.css b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/main.css new file mode 100644 index 000000000000..80eb9664461d --- /dev/null +++ b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/main.css @@ -0,0 +1,54 @@ +.layout-grid { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + gap: 0.5rem 1rem; + align-items: center; + margin-bottom: 1rem; +} + +.layout-grid ui5-label:first-child, +.layout-grid ui5-label:nth-child(3) { + text-align: right; + justify-self: end; +} + +.fcl { + height: 600px; +} + +.col { + height: 100%; +} + +.colHeader { + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--sapList_BorderColor); +} + +.colSubHeader { + display: flex; + gap: 0.5rem; +} + +.configurationInfo { + color: var(--sapInformativeColor); +} + +.product-details { + padding: 1rem; +} + +.product-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.product-info ui5-label { + font-weight: bold; + margin-top: 0.5rem; +} diff --git a/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/main.js b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/main.js new file mode 100644 index 000000000000..c7b3b96dd070 --- /dev/null +++ b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/main.js @@ -0,0 +1,205 @@ +import "@ui5/webcomponents/dist/Select.js"; +import "@ui5/webcomponents/dist/Option.js"; +import "@ui5/webcomponents/dist/Label.js"; +import "@ui5/webcomponents/dist/List.js"; +import "@ui5/webcomponents/dist/ListItemStandard.js"; +import "@ui5/webcomponents/dist/Title.js"; +import "@ui5/webcomponents/dist/Text.js"; +import "@ui5/webcomponents/dist/Button.js"; +import "@ui5/webcomponents-fiori/dist/FlexibleColumnLayout.js"; +import "@ui5/webcomponents-icons/dist/decline.js"; +import "@ui5/webcomponents-icons/dist/slim-arrow-right.js"; + +fcl.layoutsConfiguration = { + desktop: { + "TwoColumnsStartExpanded": { + layout: ["80%", "20%", 0], + }, + "TwoColumnsMidExpanded": { + layout: ["20%", "80%", 0], + }, + "ThreeColumnsMidExpanded": { + layout: ["20%", "50%", "30%"], + }, + "ThreeColumnsEndExpanded": { + layout: ["15%", "15%", "70%"], + }, + "ThreeColumnsStartExpandedEndHidden": { + layout: ["70%", "30%", 0], + }, + "ThreeColumnsMidExpandedEndHidden": { + layout: ["20%", "80%", 0], + }, + }, + tablet: { + "TwoColumnsStartExpanded": { + layout: ["60%", "40%", 0], + }, + "TwoColumnsMidExpanded": { + layout: ["40%", "60%", 0], + }, + "ThreeColumnsMidExpanded": { + layout: [0, "60%", "40%"], + }, + "ThreeColumnsEndExpanded": { + layout: [0, "40%", "60%"], + }, + "ThreeColumnsStartExpandedEndHidden": { + layout: ["60%", "40%", 0], + }, + "ThreeColumnsMidExpandedEndHidden": { + layout: ["40%", "60%", 0], + }, + }, +}; + +fcl.addEventListener("layout-configuration-change", (e) => { + displayCustomLayoutConfigurationInfo(); +}); + +fcl.addEventListener("layout-change", (e) => { + selectLayout.value = e.detail.layout; +}); + +selectLayout.addEventListener("ui5-change", (e) => { + fcl.layout = e.detail.selectedOption.textContent; + displayCustomLayoutConfigurationInfo(); +}); + +function displayCustomLayoutConfigurationInfo() { + const configurationPerMedia = fcl.layoutsConfiguration[fcl.media]; + const layoutConfiguration = configurationPerMedia ? configurationPerMedia[fcl.layout] : undefined; + if (layoutConfiguration) { + configurationInfo.innerText = `[${layoutConfiguration.layout.join(", ")}]`; + } else { + configurationInfo.innerText = `none`; + } +} + +// Sample data for navigation +const categoryData = { + electronics: [ + { id: "laptop", name: "Laptop", description: "High-performance laptop with 16GB RAM and SSD storage. Perfect for work and gaming." }, + { id: "smartphone", name: "Smartphone", description: "Latest smartphone with advanced camera system and long battery life." }, + { id: "tablet", name: "Tablet", description: "Lightweight tablet with high-resolution display, ideal for reading and media consumption." } + ], + clothing: [ + { id: "jeans", name: "Jeans", description: "Premium denim jeans with comfortable fit and durable construction." }, + { id: "shirt", name: "Shirt", description: "Cotton shirt with modern cut and breathable fabric, perfect for any occasion." }, + { id: "jacket", name: "Jacket", description: "Stylish jacket with weather-resistant materials and multiple pockets." } + ], + books: [ + { id: "novel", name: "Novel", description: "Bestselling fiction novel with compelling characters and engaging plot." }, + { id: "cookbook", name: "Cookbook", description: "Collection of delicious recipes from around the world with step-by-step instructions." }, + { id: "biography", name: "Biography", description: "Inspiring life story of a remarkable person who changed the world." } + ], + home: [ + { id: "chair", name: "Chair", description: "Ergonomic office chair with lumbar support and adjustable height." }, + { id: "lamp", name: "Lamp", description: "Modern LED lamp with adjustable brightness and energy-efficient design." }, + { id: "plant", name: "Plant", description: "Low-maintenance indoor plant that purifies air and adds natural beauty." } + ], + sports: [ + { id: "shoes", name: "Running Shoes", description: "Professional running shoes with advanced cushioning and lightweight design." }, + { id: "ball", name: "Football", description: "Official size football with durable leather construction and excellent grip." }, + { id: "racket", name: "Tennis Racket", description: "Professional tennis racket with carbon fiber frame and comfortable grip." } + ] +}; + +// Navigation functionality +const categoriesList = document.getElementById("categoriesList"); +const productsList = document.getElementById("productsList"); +const categoryTitle = document.getElementById("categoryTitle"); +const productTitle = document.getElementById("productTitle"); +const productDetails = document.getElementById("productDetails"); +const closeEndColumn = document.getElementById("closeEndColumn"); +const productDetailsTemplate = document.getElementById("productDetailsTemplate"); + +// Helper function to create product details from template +function createProductDetailsFromTemplate(product, category) { + // Clone the template content + const templateContent = productDetailsTemplate.content.cloneNode(true); + + // Populate the template with product data + templateContent.getElementById("productName").textContent = product.name; + templateContent.getElementById("productDescription").textContent = product.description; + templateContent.getElementById("productCategory").textContent = category.charAt(0).toUpperCase() + category.slice(1); + templateContent.getElementById("productId").textContent = product.id; + + return templateContent; +} + +// Helper function to clear product details to initial state +function clearProductDetails() { + productDetails.innerHTML = ""; + const textElement = document.createElement("ui5-text"); + textElement.textContent = "Select a product to view details"; + productDetails.appendChild(textElement); +} + +// Handle category selection +categoriesList.addEventListener("ui5-item-click", (e) => { + const category = e.detail.item.dataset.category; + const categoryName = e.detail.item.textContent; + + // Update middle column + categoryTitle.textContent = categoryName; + productsList.innerHTML = ""; + + // Populate products list + categoryData[category].forEach(product => { + const li = document.createElement("ui5-li"); + li.textContent = product.name; + li.dataset.productId = product.id; + li.dataset.category = category; + li.setAttribute("icon", "slim-arrow-right"); + li.setAttribute("icon-end", ""); + productsList.appendChild(li); + }); + + productsList.style.display = "block"; + + // Clear product details + clearProductDetails(); + productTitle.textContent = "Product Details"; + + // Navigate to two column layout + fcl.layout = "TwoColumnsMidExpanded"; + selectLayout.value = "TwoColumnsMidExpanded"; + displayCustomLayoutConfigurationInfo(); +}); + +// Handle product selection +productsList.addEventListener("ui5-item-click", (e) => { + const productId = e.detail.item.dataset.productId; + const category = e.detail.item.dataset.category; + + // Find product data + const product = categoryData[category].find(p => p.id === productId); + + if (product) { + // Update end column + productTitle.textContent = product.name; + + // Clear existing content and add new content from template + productDetails.innerHTML = ""; + const productDetailsContent = createProductDetailsFromTemplate(product, category); + productDetails.appendChild(productDetailsContent); + + // Navigate to three column layout + fcl.layout = "ThreeColumnsMidExpanded"; + selectLayout.value = "ThreeColumnsMidExpanded"; + displayCustomLayoutConfigurationInfo(); + } +}); + +// Handle close button in end column +closeEndColumn.addEventListener("click", () => { + // Clear product details + clearProductDetails(); + productTitle.textContent = "Product Details"; + + // Navigate back to two column layout + fcl.layout = "TwoColumnsMidExpanded"; + selectLayout.value = "TwoColumnsMidExpanded"; + displayCustomLayoutConfigurationInfo(); +}); diff --git a/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/sample.html b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/sample.html new file mode 100644 index 000000000000..3d5a599a2d49 --- /dev/null +++ b/packages/website/docs/_samples/fiori/FlexibleColumnLayout/LayoutsConfiguration/sample.html @@ -0,0 +1,81 @@ + + + + + + + + Sample + + + + + + +
+ Current layout + + OneColumn + TwoColumnsStartExpanded + TwoColumnsMidExpanded + ThreeColumnsMidExpanded + ThreeColumnsEndExpanded + ThreeColumnsStartExpandedEndHidden + ThreeColumnsMidExpandedEndHidden + + Custom configuration for current layout + none +
+ +
+
+ Categories +
+ + Electronics + Clothing + Books + Home & Garden + Sports + +
+
+
+ Select a category +
+ +
+
+
+ Product Details +
+ +
+
+
+ Select a product to view details +
+
+
+ + + + + + + + + +