eqcss / eqcss

EQCSS is a CSS Reprocessor that introduces Element Queries, Scoped CSS, a Parent selector, and responsive JavaScript to all browsers IE8 and up

Home Page:https://elementqueries.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Equal / Match height in series

sebszocinski opened this issue · comments

Hi guys,

I'm new to EQCSS and just getting started, but i'm wondering if there is a way to combat the common issue of having a grid of items that all have different heights (say because of text overflowing onto multiple lines).

I know how to do it in JS but it's a fair few lines of code that I wouldn't want to chuck into an eval() (which i'm not even sure is possible?) so is there any other way of doing it? I guess what i'm after is being able to determine the tallest sibling in a series of elements.

Cheers

Hi @Rockethouse! Thanks for the question :D, this is something that could be in 'inside' EQCSS like you're talking about, but due to the nature of the calculations we would need to do, EQCSS would run it more frequently than we need.

If you're needing this 'match the tallest' behaviour in only new browsers, I believe some examples of that can be done using flexbox for layout. If you need a solution that works everywhere, here's the little snippet I use:

// Equal Height Columns
function resizeCols() {

  // Find all elements with a `data-col` attribute
  var cols = document.querySelectorAll('[data-col]')

  // Remember the columns we've found
  var encountered = []

  // For each element we find
  for (i=0;i<cols.length;i++){

    // Find which set of columns it belongs to
    var attr = cols[i].getAttribute('data-col')

    // If we haven't seen this set before, add it to those we've encountered
    if (encountered.indexOf(attr) == -1){
      encountered.push(attr)
    }
  }

  // Once all sets of columns have been found, for each set
  for (set=0;set<encountered.length;set++){

    // Find all elements from this set of columns
    var col = document.querySelectorAll('[data-col="'+encountered[set]+'"]')

    // Create a group to remember the height of these elements
    var group = []

    // For each element in the set
    for (i=0;i<col.length;i++){

      // Reset the height momentarily
      col[i].style.height = 'auto'

      // Measure the element's height
      group.push(col[i].scrollHeight)
    }

    // Once all elements have been measured, for each element in the set
    for (i=0;i<col.length;i++){

      //Set the height to the tallest height measured in the set
      col[i].style.height = Math.max.apply(Math,group)+'px'
    }

  }
}

// Run resizeCols function on window load and resize events
window.addEventListener('load', resizeCols)
window.addEventListener('resize', resizeCols)

And there's a demo of that here: http://codepen.io/tomhodgins/pen/pjmKNj

This plugin will measure the height of a group of elements and assign each of them the highest value.

Usage

To group elements together, assign each element a data-col attribute with the same value. This way, the plugin can calculate the heights of different groups of elements on the same page.

<div data-col=example1><div data-col=example1>
<div data-col=example2><div data-col=example2>

In this example both data-col=example1 elements will be matched in height, and both data-col-example2 will be matched to each other as well.

damn, i was really hoping EQCSS could be used to do this cleanly and simply.

i was thinking, if eval() could have access to an array of all the items that matched the element selector, just like it currently has access to $it it would be pretty easy to do in EQCSS

something like height: eval('maxHeight($all)')

then maxHeight could just be a small util function that accepts an array of elements as an argument

I think the problem with this is that no matter what you do, it's going to be very wasteful for performance to try to do from inside EQCSS because it's going to run once for each element that matches the selector, when you really only need to run it once for all elements with the same selector.

Suppose we have three columns to match in height, using the JS snippet above we can fill one array with the measurements of all three columns, then find the maximum value one time, then apply to all.

The way EQCSS processes code would lead to the JavaScript running once for each of those three columns, so if we had put the JavaScript in EQCSS we have to create some kind of a shared/global array that the JS for each element can run and submit its height to. This means our measuring code is actually running 3x more often than we need. We can also read the results from this global array or variable and use it for the height, but after it has measured all three columns there's no (simple) way to reset our array. This means it's easy to build a plugin that calculates a maximum, but hard to build it in a way that would let the columns ever decrease in size from the all-time tallest point.

Here's a bad example that is in EQCSS, but there's no way it going to perform better than the snippet I gave above:

<div>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</div>
<div>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</div>
<div>Lorem ipsum dolor sit amet.</div>

<style>
  * {
    box-sizing: border-box;
  }
  @element div {
    $this {
      float: left;
      padding: 1em;
      width: 33.33%;
      border: 1px solid black;
      min-height: auto;
      min-height: eval("
        style.minHeight = 'auto';
        if (tallest < offsetHeight) {
          tallest = offsetHeight;
        }
        style.minHeight = '';
        tallest + 'px';
      ");
    }
  }
  @element div {
    eval('tallest = 0')
  }
</style>

<script src=http://elementqueries.com/EQCSS.js></script>

So to walk you through what this is doing:

  • we have 3 elements on the page that want matching height
  • for each element we set its own style=" min-height: ; " attribute to auto (to allow shrinking)
  • our EQCSS code measures if the height of the scoped element is greater than the value of tallest
  • if it is, it updates tallest to the highest number observed
  • then it removes the min-height style attribute, allowing the value recorded in CSS to apply to our element
  • it applies the tallest value to each scoped element
  • after the height has been applied I added a second query, with a second eval("") to reset tallest = 0 for the next pass. Without this additional query, it won't work. And if we define <script>var tallest = 0</script> in JS it will grow but never shrink

It will work, but hopefully you can see why this sort of solution is more fragile than the first snippet, and can't possibly compete in terms of performance :)

There are some really sweet demos of what EQCSS can do with the eval('') function on Codepen to get you thinking. There is a lot of power there, but usually just JS being applied to one element as it processes, not as much JS that touches more than one element at a time.

Check out some of the demos here: http://codepen.io/search/pens?q=eqcss&limit=all&type=type-pens

@Rockethouse I think this is a cool PostCSS plugin idea actually, mind if I steal it? 😮

I don't think this should be a part of EQCSS, even if it could be done.
It simply doesn't seem to fit the overall idea of Element Queries.

@ZeeCoder I'd love to see what you come up with, but I can't see how this is something you could know in advance because it can change so much after the page has loaded. Wouldn't that rule out any kind of a dynamic layout?

Here's a basic demo with 2 columns where clicking a button updates the height of the column for example:

<div data-col=a>
  <button onclick="parentNode.innerHTML = '<img src=http://placehold.it/200x200 onload=resizeCols()>' +parentNode.innerHTML">Add Image</button>
</div>
<div data-col=a>
  <img src=http://placehold.it/200x200>
</div>
<style>
  * {
    box-sizing: border-box;
  }
  img {
    display: block;
    width: 100%;
    margin: 1em 0;
  }
  div {
    width: 50%;
    float: left;
    min-height: 100px;
    padding: 0 1em;
    border: 1px solid;
  }
</style>
<script>
  function resizeCols() {
    var cols = document.querySelectorAll('[data-col]')
    var encountered = []
    for (i=0;i<cols.length;i++){
      var attr = cols[i].getAttribute('data-col')
      if (encountered.indexOf(attr) == -1){
        encountered.push(attr)
      }
    }
    for (set=0;set<encountered.length;set++){
      var col = document.querySelectorAll('[data-col="'+encountered[set]+'"]')
      var group = []
      for (i=0;i<col.length;i++){
        col[i].style.height = 'auto'
        group.push(col[i].scrollHeight)
      }
      for (i=0;i<col.length;i++){
        col[i].style.height = Math.max.apply(Math,group)+'px'
      }
    }
  }
  window.addEventListener('load', resizeCols)
  window.addEventListener('resize', resizeCols)
</script>

Totally agree @tomhodgins , no way to do it unless you have a JS runtime.
Here's a draft of the idea I had for better understanding: https://github.com/ZeeCoder/postcss-equal-height

Thanks guys, @matthewjumpsoffbuildings works with me so he's taken all this on board.
@ZeeCoder That's totally fine. I've looked into PostCSS as well, and might need to revisit it one day.

Cheers again for the lengthy response @tomhodgins

Ok Im testing out your code @tomhodgins, one of the things I have noticed is that if Im using EQCSS to set the width of the columns (using an eval() to get a percentage based on column number), then it doesnt work properly.

Whats happening is on DOM ready im running EQCSS.apply() and then calling resizeCols() - which is the right order since we want the column widths to be assigned the distributed percentage and then match their heights after that. But it seems the EQCSS.apply() cannot be immediately followed with code that relies on the EQCSS properties being set correctly in the DOM.

I found if I added a small timeout of around 50ms before calling resizeCols() that worked, but that feels wrong, i assume this timeout value will differ browser to browser, computer to computer - is there a better way to deal with this kind of race condition with EQCSS?

on top of this, another issue i cant seem to work around is dealing with nested evals()

if im using the resizeCol() function, as well as an EQCSS eval() for the % width of the columns - AND i have some item inside the column that i want to use EQCSS eval() to position it absolutely relative to the height of the parent column - then I cant see how this would work? EQCSS needs to be called first to get the correct percentage width of the columns, but resizeCol() needs to be called first to set the correct height of each column for the child elements to be positioned correctly

doest that make sense?

I'm not able to follow, but the problems you're having with it don't sound like anything I've run into before. Do you have examples of this code online, or on Codepen so I could see what's going on?

ok imagine you have this HTML

  <div class="columns">
    <div class="column" data-col="1">Bla bla bla bla bla </div>
    <div class="column" data-col="1">bla</div>
    <div class="column" data-col="1">bla <span class="bottom"></span></div>
  </div>

so i implement your resizeCol() function so that all of the columns are the same height. so far so good

but now lets say i want to use EQCSS to make it so that the column widths are a percentage of the total number of columns. so in my CSS i use

@element '.column' {
	eq_this {
		width: eval('percentageWidth($it)'); 
		float: left;
	}
}

percentageWidth() is a JS function that works out the percentage based on the parents number of children

thats all fine and dandy - BUT when EQCSS is applied, changing the width of the columns, the text inside them is now doing its line breaks at different points, which of course changes the height of the columns, hence invalidating the resizeCols()

ok no problem - in my resize listener ill just call EQCSS.apply() first, which will set the column widths correctly, and then call resizeCols() to match all their heights.

except that doesnt work. calling resizeCols() immediately after EQCSS.apply() does not update the heights to the correct values, but setTimeout(resizeCols, 50) does work. therefore somethin about EQCSS.apply() is not synchronous or blocking or whatever you call it, but I have no idea what.

the next issue is even more tricky - notice the <span class="bottom"></span> in the last column div. what if i want to use EQCSS bottom: eval(someFunction($it)) to position that span vertically, based on the height of the column div its in?

the height of the column div is being affected by the resizeCols() function, so therefore EQCSS.apply() has to happen after we do resizeCols() right?

well no - since resizeCols() depends on the EQCSS.apply() to happen before it - so that the column width can be set first using EQCSS width: eval('percentageWidth($it)');

perhaps i could call EQCSS.apply() twice in the listener function for resize - before and after resizeCols()? but that seems pretty bad.

i hope this explanation makes a bit more sense of the circular issue.

Hey @matthewjumpsoffbuildings! Here's how I'd tackle what it sounds like you're trying to build:

  • uses your HTML, with a very long sample paragraph in one
  • some simple global CSS styles to build the demo
  • an @element query on the parent element containing our columns
  • a rule setting the width of each colum to calc(100% / eval('children.length'))
  • I'm not sure how you're wanting to position the .bottom element, but it should be possible

This means if our .columns element has 3 child elements inside, it will evaluate down to calc(100% / 3);, allowing all three to fill their container.

<div class=columns>
  <div class=column data-col=1>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</div>
  <div class=column data-col=1>bla</div>
  <div class=column data-col=1>bla <span class=bottom></span></div>
</div>

<style>
  * {
    box-sizing: border-box;
  }
  .columns {
    border: 1px solid red;
  }
  .columns:after {
    content: '';
    display: block;
    clear: both;
  }
  .column {
    position: relative;
    border: 1px solid lime;
  }
  .bottom {
    display: block;
    width: 20px;
    height: 20px;
    background: blue;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translateX(-50%) translateY(-50%);
  }
  @element .columns {
    $this .column {
      float: left;
      width: calc(100% / eval('children.length'));
    }
  }
</style>

<script>
  function resizeCols() {
    var cols = document.querySelectorAll('[data-col]')
    var encountered = []
    for (i=0;i<cols.length;i++){
      var attr = cols[i].getAttribute('data-col')
      if (encountered.indexOf(attr) == -1){
        encountered.push(attr)
      }
    }
    for (set=0;set<encountered.length;set++){
      var col = document.querySelectorAll('[data-col="'+encountered[set]+'"]')
      var group = []
      for (i=0;i<col.length;i++){
        col[i].style.height = 'auto'
        group.push(col[i].scrollHeight)
      }
      for (i=0;i<col.length;i++){
        col[i].style.height = Math.max.apply(Math,group)+'px'
      }
    }
  }
  window.addEventListener('load', resizeCols)
  window.addEventListener('resize', resizeCols)
</script>

<script src=http://elementqueries.com/EQCSS.js></script>

And if you're wanting to use EQCSS to position the .bottom element based on how the .column is showing up you can do stuff like this - which would center it inside its parent:

@element .bottom {
  $this {
    position: absolute;
    top: eval('(parentNode.clientHeight/2) - (offsetHeight/2)')px;
    left: eval('(parentNode.clientWidth/2) - (offsetWidth/2)')px
  }
}

Does this avoid the problems you were seeing before?

For other demos of similar child-counting styles, check out:

heres a pen using your code - https://codepen.io/matthew-prasinov/pen/GWYyWY

it doesnt seem to be working for me? Im pretty sure i copied it correctly and theres no errors in the console im seeing?

by the way im not trying to build anything specific here, im investigating the uses of EQCSS and trying to set up some kind of utility JS for common problems we confront when dealing with CSS.

the main issue here is not my specific example - its the question of race conditions.

  • if you have 2 items, one nested in the other
  • both parent and child with different element queries using eval() applied to them
  • the child elements eval() is somehow dependant on the applied result of the parents eval()

how does EQCSS handle this?

also if you have a setup where there are other non-EQCSS functions being run on resize - functions that any of the EQCSS element queries eval() may depend on the applied result to calculate correctly, how do you handle this?

i have to stress applied result because in many cases in order to perform the calculations accurately cross-browser (often to the pixel, which is important with a lot of the work we do), we will need to be able to have the browser apply the CSS result of the parent elements eval() to the DOM, before the eval() for the child element is run

As for the Codepen demo - the version of EQCSS that is loading is v1.2.1, but the current version is v1.5.1. If you update the version that is loaded in the Settings panel of that pen, under the JavaScript tab, the code should work!

For the rest, this is all still a little vague, I have to say "it depends" to a lot of these because the solution could be different depending on what you're needing to do.

Like before, if you can give me specific use cases, or specific requirements I can show you how I'd achieve them. I'll try my best to give you some general answers, but I'm afraid my answers may be too vague to help without a more specific example.

  • if you have 2 items, one nested in the other
  • both parent and child with different element queries using eval() applied to them
  • the child elements eval() is somehow dependant on the applied result of the parents eval()
    how does EQCSS handle this?

EQCSS is read top-to-bottom, left-to-right like vanilla CSS. This means any query following another one will run after it. EQCSS also thinks of each @element query as a separate scope.

If you wanted to be sure that a measurement using eval('') happened after another measurement using eval('') it should be enough to place the second eval('') after the first one. EQCSS won't arrive at it until the first one is applied.

If for some reason you have a dependency where two measurements in the same scope depend on the first measurement being applied, you could open a second scope for the same selectors after closing the first one.

Am I correct in thinking the problem you see is this:

@element div {
  $this {
    width: eval('innerWidth / 2')px;
    height: eval('offsetWidth / 2')px;
  }
}

So when EQCSS is processing that example, it evaluates the width and height based on the current dimensions on the page it's not seeing the width that's about to be changed when that query is finished processing and added to the page. All it should take to resolve this is splitting your dependent code into another query:

  @element div {
    $this {
      width: eval('innerWidth / 2')px;
    }
  }
  @element div {
    $this {
      height: eval('offsetWidth / 2')px;
    }
  }

In this example, by the time EQCSS is checking offsetWidth to evaluate what the height:; should be, all styles from the previous @element query (including our eval('') affecting the element's width) have been applied already. I believe this is what you're looking for (in general).

Also keep in mind that if you're talking specifically about width and height there are also element-percentage units which could help alleviate the need for splitting this. In this example, 100ew refers to 100% of the element's width. When we use 100ew instead of trying to eval('') the width, we can use it in the same query, so it really depends on what you're trying to do. This would function the same way:

@element div {
  $this {
    width: eval('innerWidth / 2')px;
    height: calc(100ew / 2);
  }
}

also if you have a setup where there are other non-EQCSS functions being run on resize - functions that any of the EQCSS element queries eval() may depend on the applied result to calculate correctly, how do you handle this?

One way you could do this is by running those function from your EQCSS code instead of relying on their own event listeners to trigger. Currently EQCSS will fire on DOMContentLoaded, as well as for a number of other events. In most cases the recalculation of styles is throttled so it won't recalculate more frequently than every 200ms. If you were wanting to keep other functions in step with EQCSS could you try something like this:

<div></div>

<style>
  @element ':root' {
    eval('resizeDIV()')
  }
  @element div {
    $this:before {
      content: 'eval("offsetWidth")px x eval("offsetHeight")px';
    }
  }
</style>

<script>
  function resizeDIV(){
    var div = document.querySelector('div')
    div.style.background = 'lime'
    div.style.width = innerWidth / 2 + 'px'
    div.style.height = innerHeight / 2 + 'px'
  }
</script>

<script src=http://elementqueries.com/EQCSS.js></script>

In this example, instead of adding event listeners to window to run the resizing function, we rely on EQCSS to run it.

Notice that if you switch the order the queries apply, it does make a difference. In the first example it's running resizeDIV() before displaying its results as :before content, but if you were to rearrange them like this - it's possible you could end up running resizeDIV after EQCSS was done updating the numbers, leading you to display out-of-date info:

@element div {
  $this:before {
    content: 'eval("offsetWidth")px x eval("offsetHeight")px';
  }
}
@element ':root' {
  eval('resizeDIV()')
}

So hopefully that points you in the right direction about how it might interact with other layout-controlling JS functions you might have running alongside EQCSS on your page.

I've written about doing the opposite thing as well - manually controlling EQCSS from JavaScript. Check out Understanding EQCSS Triggers for more information about how you can control EQCSS from JS.

ah ok the codepen is working now. thanks for clearing that up

i think the difference here is i am using EQCSS with npm and webpack - im using require() to
load EQCSS into my app.js, and then it seems it wasnt working out of the box so im manually calling EQCSS.apply() on doc ready and resize.

this is the function i have attached to ready/resize

function update(){
	// reapply EQCSS
	EQCSS.apply()

	// resize column heights
	resizeCols()
}

that doesnt work, but if i change it to setTimeout(resizeCols, 50), then it does

im also using other JS libraries via this method (jquery, barba), but i dont see how they should have any effect on this.

btw im including EQCSS in my app.js like this

const EQCSS = require('eqcss')

What happens if you rearrange the order of EQCSS.apply() and resizeCols() there, or call resizeCols() from EQCSS?

@element :root {
  eval('resizeCols()')
}

Is the problem (with using a module loader) that resizeCols() isn't reachable from EQCSS as it's running on the page?

I'm definitely a webpack beginner, but I've written about Using EQCSS as a JavaScript Module with Webpack with an example that I know should work, if that helps you verify how you've got it hooked up!

hmm i dont think youre understanding my setup.

EQCSS.apply() has to go before resizeCols() because it applies the element query to the columns to set their widths.

resizeCols() must be called after EQCSS.apply() has been run and fully applied/rendered to the DOM, because it needs to get the correct height for each column (which will change if the column widths are changed by the element query)

in my app.js, im loading in EQCSS and in a function that is assigned to listen to resize events, im calling

function update(){
	// reapply EQCSS - eg change width of columns based on the element query
	EQCSS.apply()

	// resize column heights - has to be last because the EQCSS width change also changes height
	resizeCols()
}

the problem is that when calling EQCSS.apply() manually because im using webpack, instead of relying on the 'normal' use case where you just include EQCSS from a CDN - it seems that EQCSS.apply() does NOT immediately update the DOM, rather you need to wait a few milliseconds before doing any other JS that depends on EQCSS.apply()'s effects actually appearing in the DOM

hmm ok i just discovered that the initial call on document ready isnt working without the delay, but the subsequent calls on resize do work correctly?

is there any chance that the first manually called EQCSS.apply() might do some things that take a few milliseconds to propagate to the DOM?

Alright its weirder than I thought - it turns out that simply adding a timeout to my document ready function fixes it - so it seems like if you are using webpack or some other module loader to include EQCSS manually and call EQCSS.apply(), you cant do it immediately on document ready?