notify components when a child's _grow_ changes

This commit is contained in:
Aran-Fey
2024-06-15 10:04:21 +02:00
committed by Jakob Pinterits
parent a47d2222ba
commit e0c19ba166
5 changed files with 224 additions and 160 deletions
+27 -4
View File
@@ -245,18 +245,31 @@ export function updateComponentStates(
? null
: getComponentByElement(focusedElement as HTMLElement);
// Create a HTML element to hold all latent components, so they aren't
// garbage collected while updating the DOM.
// Create a set to hold all latent components, so they aren't garbage
// collected while updating the DOM.
let latentComponents = new Set<ComponentBase>();
// Make sure all components mentioned in the message have a corresponding HTML
// element
// Keep track of all components whose `_grow_` changed, because their
// parents have to be notified so they can update their CSS
let growChangedComponents: ComponentBase[] = [];
// Make sure all components mentioned in the message have a corresponding
// HTML element
for (let componentIdAsString in deltaStates) {
let deltaState = deltaStates[componentIdAsString];
let component = componentsById[componentIdAsString];
// This is a reused component, no need to instantiate a new one
if (component) {
// Check if its `_grow_` changed
if (deltaState._grow_ !== undefined) {
if (
deltaState._grow_[0] !== component.state._grow_[0] ||
deltaState._grow_[1] !== component.state._grow_[1]
) {
growChangedComponents.push(component);
}
}
continue;
}
@@ -313,6 +326,16 @@ export function updateComponentStates(
document.body.appendChild(rootElement);
}
// Notify the parents of all elements whose `_grow_` changed to update their
// CSS
let parents = new Set<ComponentBase>();
for (let child of growChangedComponents) {
parents.add(child.parent!);
}
for (let parent of parents) {
parent.onChildGrowChanged();
}
// Restore the keyboard focus
if (focusedComponent !== null) {
restoreKeyboardFocus(focusedComponent, latentComponents);
+83 -44
View File
@@ -36,6 +36,9 @@ export class GridComponent extends ComponentBase {
let element = this.element;
if (deltaState._children !== undefined) {
let childPositions =
deltaState._child_positions ?? this.state._child_positions;
this.replaceChildren(
latentComponents,
deltaState._children,
@@ -45,7 +48,7 @@ export class GridComponent extends ComponentBase {
for (let [childWrapper, childPos] of zip(
this.element.children,
deltaState._child_positions!
childPositions
)) {
// Note: `rio.Grid` starts counting at row/column 0, but CSS
// starts counting at 1
@@ -58,10 +61,7 @@ export class GridComponent extends ComponentBase {
}`;
}
this.updateTrackSizes(
deltaState._children,
deltaState._child_positions!
);
this.updateTrackSizes(deltaState._children, childPositions);
}
if (deltaState.row_spacing !== undefined) {
@@ -84,38 +84,77 @@ export class GridComponent extends ComponentBase {
childIds: ComponentId[],
childPositions: GridChildPosition[]
): void {
let maxRow = 0;
let maxCol = 0;
let growingColumns = new Map<number, boolean>();
let growingRows = new Map<number, boolean>();
let hasGrowingColumns = false;
let hasGrowingRows = false;
let childrenWithPositions: [ComponentBase, GridChildPosition][] =
childIds.map((childId: ComponentId, index: number) => [
componentsById[childId]!,
childPositions[index],
]);
for (let [i, childPos] of childPositions.entries()) {
let child = componentsById[childIds[i]]!;
// Sort the children by the number of rows they take up
let childrenByNumberOfRows = Array.from(childrenWithPositions);
childrenByNumberOfRows.sort((a, b) => a[1].height - b[1].height);
maxCol = Math.max(maxCol, childPos.column + childPos.width);
maxRow = Math.max(maxRow, childPos.row + childPos.height);
let nRows = 0;
let growingRows = new Set();
if (child.state._grow_[0]) {
for (
let column = childPos.column + childPos.width - 1;
column >= childPos.column;
column--
) {
hasGrowingColumns = true;
growingColumns.set(column, true);
}
for (let [childComponent, childPosition] of childrenByNumberOfRows) {
// Keep track of how how many rows this grid has
nRows = Math.max(nRows, childPosition.row + childPosition.height);
let allRows = range(
childPosition.row,
childPosition.row + childPosition.height
);
// Determine which rows need to grow
if (!childComponent.state._grow_[1]) {
continue;
}
if (child.state._grow_[1]) {
for (
let row = childPos.row + childPos.height - 1;
row >= childPos.row;
row--
) {
hasGrowingRows = true;
growingRows.set(row, true);
// Does any of the rows already grow?
let alreadyGrowing = allRows.some((row) => growingRows.has(row));
// If not, mark them all as growing
if (!alreadyGrowing) {
for (let row of allRows) {
growingRows.add(row);
}
}
}
// Sort the children by the number of columns they take up
let childrenByNumberOfColumns = Array.from(childrenWithPositions);
childrenByNumberOfColumns.sort((a, b) => a[1].width - b[1].width);
let nColumns = 0;
let growingColumns = new Set();
for (let [childComponent, childPosition] of childrenByNumberOfColumns) {
// Keep track of how how many columns this grid has
nColumns = Math.max(
nColumns,
childPosition.column + childPosition.width
);
let allColumns = range(
childPosition.column,
childPosition.column + childPosition.width
);
// Determine which columns need to grow
if (!childComponent.state._grow_[0]) {
continue;
}
// Does any of the rows already grow?
let alreadyGrowing = allColumns.some((column) =>
growingColumns.has(column)
);
// If not, mark them all as growing
if (!alreadyGrowing) {
for (let column of allColumns) {
growingColumns.add(column);
}
}
}
@@ -124,26 +163,26 @@ export class GridComponent extends ComponentBase {
const NO_GROW = 'min-content';
let columnWidths: string[] = [];
if (hasGrowingColumns) {
for (let i = 0; i < maxCol; i++) {
columnWidths.push(growingColumns.get(i) ? GROW : NO_GROW);
if (growingColumns.size === 0) {
// If nobody wants to grow, all of them do
for (let i = 0; i < nColumns; i++) {
columnWidths.push(GROW);
}
} else {
// If nobody wants to grow, all of them do
for (let i = 0; i < maxCol; i++) {
columnWidths.push(GROW);
for (let i = 0; i < nColumns; i++) {
columnWidths.push(growingColumns.has(i) ? GROW : NO_GROW);
}
}
let rowHeights: string[] = [];
if (hasGrowingRows) {
for (let i = 0; i < maxRow; i++) {
rowHeights.push(growingRows.get(i) ? GROW : NO_GROW);
if (growingRows.size === 0) {
// If nobody wants to grow, all of them do
for (let i = 0; i < nRows; i++) {
rowHeights.push(GROW);
}
} else {
// If nobody wants to grow, all of them do
for (let i = 0; i < maxRow; i++) {
rowHeights.push(GROW);
for (let i = 0; i < nRows; i++) {
rowHeights.push(growingRows.has(i) ? GROW : NO_GROW);
}
}
+21 -11
View File
@@ -1,7 +1,7 @@
import { textStyleToCss } from '../cssUtils';
import { applyIcon } from '../designApplication';
import { ComponentId, TextStyle } from '../dataModels';
import { firstDefined } from '../utils';
import { commitCss, firstDefined } from '../utils';
import { ComponentBase, ComponentState } from './componentBase';
import { RippleEffect } from '../rippleEffect';
@@ -163,26 +163,22 @@ export class RevealerComponent extends ComponentBase {
}
// Update the CSS to trigger the expand animation
this.contentOuterElement.style.maxHeight = '0';
this.element.classList.add('rio-revealer-open');
// The components may currently be in flux due to a pending re-layout.
// If that is the case, reading the `scrollHeight` would lead to an
// incorrect value. Wait for the resize to finish before fetching it.
requestAnimationFrame(() => {
let contentHeight = this.contentInnerElement.scrollHeight;
let selfHeight = this.element.scrollHeight;
let headerHeight = this.headerElement.scrollHeight;
let targetHeight = Math.max(
contentHeight,
selfHeight - headerHeight
);
this.contentOuterElement.style.maxHeight = `${targetHeight}px`;
// Animating max-height only works with fixed values (and not
// `unset`, etc), so we have to assign the child's exact height in
// pixels
this.setMaxHeightToChildHeight();
// Once the animation is finished, remove the max-height so that the
// child component can freely resize itself
setTimeout(() => {
this.contentOuterElement.style.removeProperty('maxHeight');
this.contentOuterElement.style.maxHeight = 'unset';
}, 1000 * 0.25);
});
}
@@ -193,7 +189,21 @@ export class RevealerComponent extends ComponentBase {
return;
}
// Again, animating from `max-height: unset` doesn't work, so we have to
// set it to the child's size in pixels
this.setMaxHeightToChildHeight();
commitCss(this.contentOuterElement);
this.element.classList.remove('rio-revealer-open');
this.contentOuterElement.style.maxHeight = '0';
}
private setMaxHeightToChildHeight(): void {
let contentHeight = this.contentInnerElement.scrollHeight;
let selfHeight = this.element.scrollHeight;
let headerHeight = this.headerElement.scrollHeight;
let targetHeight = Math.max(contentHeight, selfHeight - headerHeight);
this.contentOuterElement.style.maxHeight = `${targetHeight}px`;
}
}
+90 -98
View File
@@ -41,109 +41,101 @@ export class SwitcherComponent extends ComponentBase {
deltaState.content !== undefined &&
deltaState.content !== this.state.content
) {
this.replaceContent(latentComponents, deltaState.content);
this.removeCurrentChild(latentComponents);
this.addNewChild(deltaState.content, latentComponents);
}
}
private replaceContent(
latentComponents: Set<ComponentBase>,
content: ComponentId | null
private removeCurrentChild(latentComponents: Set<ComponentBase>): void {
if (this.activeChildContainer === null) {
return;
}
// The old component may be used somewhere else in the UI, so
// the switcher can't rely on it still being available. To get
// around this, create a copy of the element's HTML tree and use
// that for the animation.
//
// Moreover, teh component may have already been removed from
// the switcher. This can happen when it was moved into another
// component. Thus, fetch the component by its id, rather than
// using the contained HTML node.
let oldComponent = componentsById[this.state.content!]!;
let oldElementClone = oldComponent.element.cloneNode(
true
) as HTMLElement;
// Discard the old component
this.replaceOnlyChild(
latentComponents,
null,
this.activeChildContainer
);
// Animate out the old component
this.activeChildContainer.appendChild(oldElementClone);
this.activeChildContainer.classList.remove('rio-switcher-active-child');
this.activeChildContainer.style.maxWidth = '0';
this.activeChildContainer.style.maxHeight = '0';
// Make sure to remove the child after the animation finishes
let oldChildContainer = this.activeChildContainer;
setTimeout(() => {
oldChildContainer.remove();
}, this.state.transition_time * 1000);
// No more children :(
this.activeChildContainer = null;
}
private addNewChild(
content: ComponentId | null,
latentComponents: Set<ComponentBase>
): void {
// Out with the old
if (this.activeChildContainer !== null) {
// The old component may be used somewhere else in the UI, so
// the switcher can't rely on it still being available. To get
// around this, create a copy of the element's HTML tree and use
// that for the animation.
//
// Moreover, teh component may have already been removed from
// the switcher. This can happen when it was moved into another
// component. Thus, fetch the component by its id, rather than
// using the contained HTML node.
let oldComponent = componentsById[this.state.content!]!;
let oldElementClone = oldComponent.element.cloneNode(
true
) as HTMLElement;
// Discard the old component
this.replaceOnlyChild(
latentComponents,
null,
this.activeChildContainer
);
// Animate out the old component
this.activeChildContainer.appendChild(oldElementClone);
this.activeChildContainer.classList.remove(
'rio-switcher-active-child'
);
this.activeChildContainer.style.maxWidth = '0';
this.activeChildContainer.style.maxHeight = '0';
// Make sure to remove the child after the animation finishes
let oldChildContainer = this.activeChildContainer;
setTimeout(() => {
oldChildContainer.remove();
}, this.state.transition_time * 1000);
// No more children :(
this.activeChildContainer = null;
}
// In with the new
if (content === null) {
this.activeChildContainer = null;
} else {
// Add the child into a helper container
this.activeChildContainer = document.createElement('div');
this.activeChildContainer.style.maxWidth = '0';
this.activeChildContainer.style.maxHeight = '0';
this.element.appendChild(this.activeChildContainer);
this.replaceOnlyChild(
latentComponents,
content,
this.activeChildContainer
);
// Animate the child in
commitCss(this.activeChildContainer);
this.activeChildContainer.classList.add(
'rio-switcher-active-child'
);
// The components may currently be in flux due to a pending re-layout.
// If that is the case, reading the `scrollHeight` would lead to an
// incorrect value. Wait for the resize to finish before fetching it.
requestAnimationFrame(() => {
if (this.activeChildContainer === null) {
return;
}
let contentWidth = this.activeChildContainer.scrollWidth;
let selfWidth = this.element.scrollWidth;
let targetWidth = Math.max(contentWidth, selfWidth);
let contentHeight = this.activeChildContainer.scrollHeight;
let selfHeight = this.element.scrollHeight;
let targetHeight = Math.max(contentHeight, selfHeight);
this.activeChildContainer.style.maxWidth = `${targetWidth}px`;
this.activeChildContainer.style.maxHeight = `${targetHeight}px`;
// Once the animation is finished, remove the
// max-width/max-height so that the child component can freely
// resize itself
setTimeout(() => {
if (this.activeChildContainer === null) {
return;
}
this.activeChildContainer.style.removeProperty('maxWidth');
this.activeChildContainer.style.removeProperty('maxHeight');
}, 1000 * this.state.transition_time);
});
return;
}
// Add the child into a helper container
this.activeChildContainer = document.createElement('div');
this.activeChildContainer.style.maxWidth = '0';
this.activeChildContainer.style.maxHeight = '0';
this.element.appendChild(this.activeChildContainer);
this.replaceOnlyChild(
latentComponents,
content,
this.activeChildContainer
);
// Animate the child in
commitCss(this.activeChildContainer);
this.activeChildContainer.classList.add('rio-switcher-active-child');
// The components may currently be in flux due to a pending re-layout.
// If that is the case, reading the `scrollHeight` would lead to an
// incorrect value. Wait for the resize to finish before fetching it.
let activeChildContainer = this.activeChildContainer;
requestAnimationFrame(() => {
let contentWidth = activeChildContainer.scrollWidth;
let selfWidth = this.element.scrollWidth;
let targetWidth = Math.max(contentWidth, selfWidth);
let contentHeight = activeChildContainer.scrollHeight;
let selfHeight = this.element.scrollHeight;
let targetHeight = Math.max(contentHeight, selfHeight);
activeChildContainer.style.maxWidth = `${targetWidth}px`;
activeChildContainer.style.maxHeight = `${targetHeight}px`;
// Once the animation is finished, remove the max-width/max-height
// so that the child component can freely resize itself
setTimeout(() => {
activeChildContainer.style.removeProperty('max-width');
activeChildContainer.style.removeProperty('max-height');
}, this.state.transition_time * 1000);
});
}
}
+3 -3
View File
@@ -1001,11 +1001,9 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label,
}
.rio-revealer-content-outer {
overflow: hidden;
flex-grow: 1;
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease-in-out;
}
@@ -2575,6 +2573,8 @@ textarea:not(:placeholder-shown) ~ .rio-input-box-label,
}
.rio-switcher > * {
@include single-container();
grid-row: 1;
grid-column: 1;