How to make an a responsive table grid with alphabetical ordered columns

After much Googling throughout the day and night this weekend I found a way to create an alphabetic table grid and insert it into the DOM. I was thrilled to get it working with this Twiddle, but I’m running into trouble getting it to work in my app.

Does anyone know how to var array = [dynamic array] in an Ember Helper?

The path you’re headed down is going to be hard to get right. For example

  • there’s a bug that causes the previous render to not be removed when running again (when the window resizes, for example).
  • it’s leaking a global event listener
  • the helper throws an exception when its module is evaluated (it’s trying to return a non-existent function)
  • it works by accident of timing and wouldn’t work in a real app if you navigated away from it and came back

You will fight more bugs if you try to efficiently re-render when the array changes. Many of the problems you’re trying to solve are ones that Glimmer is already built to solve for you.

Here I rewrote the Twiddle to use an alternative implementation. Future readers are warned that the twiddle is using a somewhat old Ember version, and below I’m translating into the more current style. The highlights are:

{{#each rows as |row|}}
  <div style="display: flex">
    {{#each row as |item|}}
      <div style="min-width: 100px">{{item}}</div>
    {{/each}}
  </div>
{{/each}}
import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  rows: computed('names.[]', function() {
    let names = this.names;
    let columns = Math.floor(window.innerWidth / 100);
    let itemsPerColumn = Math.ceil(names.length / columns);
    
    let rows = [];
    for (let rowNumber = 0; rowNumber < itemsPerColumn; rowNumber++) {
      let row = [];
      for (let i = rowNumber; i < names.length; i+= itemsPerColumn) {
        row.push(names[i]);
      }
      rows.push(row);
    }
    return rows;
  }),
  handleResize() {
    this.notifyPropertyChange('rows');
  },
  didInsertElement() {
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  },
  willDestroyElement() {
    window.removeEventListener('resize', this.handleResize);
  }
});

1 Like

Outstanding and infinitely more elegant than my hacky solution for alphabetical table grids in Ember (I always try to SEO-up my comments here for the future guy like me trying to figure all this out :blush:).

Interestingly I just discovered the resizing bug and was afraid what it was going to do to my week. Greatly appreciate you taking the time.

Would I be able to this.store.getAll on the component similar to the push answer here to get the dynamic array working?

Oops. Overlooked the computed property for this.name. Wow, can’t wait to plug this in in the morning. Thank you!

Here come the errors.

Out of the gate it throws:

Uncaught TypeError: Cannot read property 'length' of undefined.

So I switch things up and add this.get.findAll.

import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  rows: computed('terms.[]', function() {
    let params = this.get.findAll('term', { letter: params.letter });
    let columns = Math.floor(window.innerWidth / 100);
    let itemsPerColumn = Math.ceil(params.length / columns);

    let rows = [];
    for (let rowNumber = 0; rowNumber < itemsPerColumn; rowNumber++) {
      let row = [];
      for (let i = rowNumber; i < params.length; i+= itemsPerColumn) {
        row.push(params[i]);
      }
      rows.push(row);
    }
    return rows;
  }),
  handleResize() {
    this.notifyPropertyChange('rows');
  },
  didInsertElement() {
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  },
  willDestroyElement() {
    window.removeEventListener('resize', this.handleResize);
  }
});

It says:

`Uncaught ReferenceError: Cannot access 'params' before initialization`

Being fairly certain this has something to do with the component lifecycle, I tinker around with various forms of init, which all send me back to:

Uncaught TypeError: Cannot read property 'length' of undefined.

Where have I gone wrong?

(here’s the latest repo in case it helps)

You’re using params.letter before let params.

Apologies if I seem a little thick… I just finally figured out what all those j and i things meant this weekend. The joys of being self-taught. :face_with_monocle:

I knew this wouldn’t work, but not sure how to separate the two.

export default Component.extend({
  rows: computed('terms.[]', function() {
    let params = this.terms
    this.get.findAll('term', { letter: params.letter });
    let columns = Math.floor(window.innerWidth / 100);
    let itemsPerColumn = Math.ceil(params.length / columns);

    let rows = [];
    for (let rowNumber = 0; rowNumber < itemsPerColumn; rowNumber++) {
      let row = [];
      for (let i = rowNumber; i < params.length; i+= itemsPerColumn) {
        row.push(params[i]);
      }
      rows.push(row);
    }
    return rows;
  }),
  handleResize() {
    this.notifyPropertyChange('rows');
  },
  didInsertElement() {
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  },
  willDestroyElement() {
    window.removeEventListener('resize', this.handleResize);
  }
});

And.

Uncaught TypeError: Cannot read property 'letter' of undefined

Of course.

Was able to get the store working with:

// alpha-grid/component.js

import Component from '@ember/component';
import { computed } from '@ember/object';
import { inject as service } from '@ember/service';

export default Component.extend({
  router: service(),

  rows: computed('terms.[]', function() {
    let params = this.router.currentRoute.params
    let terms = this.terms;
    console.log(this.terms)
    let columns = Math.floor(window.innerWidth / 100);
    let itemsPerColumn = Math.ceil(terms.length / columns);

    let rows = [];
    for (let rowNumber = 0; rowNumber < itemsPerColumn; rowNumber++) {
      let row = [];
      for (let i = rowNumber; i < terms.length; i+= itemsPerColumn) {
        row.push(terms[i]);
      }
      rows.push(row);
    }
    return rows;
  }),
  handleResize() {
    this.notifyPropertyChange('rows');
  },
  didInsertElement() {
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  },
  willDestroyElement() {
    window.removeEventListener('resize', this.handleResize);
  }
});

And:

// term/letter/template.hbs

{{alpha-grid terms=model}}

However, while I can see the terms in console, I can’t see them on the page. Feels like the last step is a change here, but I can’t find the right combination.

{{!-- alpha-grid/template.hbs }}

{{#each rows as |row|}}
	<div style="display: flex">
		{{#each row as |item|}}
			<div style="min-width: 100px">{{item}}</div>
		{{/each}}
	</div>
{{/each}}

Am I wrong?

MASSIVE shout-out to @jenweber for helping me debug this throughout the day!

The problem was hiding in plain site at:

row.push(terms[i]);

As she explained , arrays of models have their own accessors. Therefore, we swapped that out for:

row.push(terms.objectAt(i));

And just like that, my 1-year old problem was SOLVED!

So the final code looked like:

// alpha-grid/component.js

import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({

  rows: computed('terms.[]', function() {
    let terms = this.terms;
    let columns = Math.floor(window.innerWidth / 100);
    let itemsPerColumn = Math.ceil(terms.length / columns);
    let rows = [];
    for (let rowNumber = 0; rowNumber < itemsPerColumn; rowNumber++) {
      let row = [];
      for (let i = rowNumber; i < terms.length; i+= itemsPerColumn) {
        row.push(terms.objectAt(i));
      }
      rows.push(row);
    }
    return rows;
  }),
  handleResize() {
    this.notifyPropertyChange('rows');
  },
  didInsertElement() {
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  },
  willDestroyElement() {
    window.removeEventListener('resize', this.handleResize);
  }
});
{{!-- alpha-grid/template.hbs (includes TailwindCSS) }}

<div class="flex flex-wrap">
	{{#each rows as |row|}}
		<div class="w-full sm:w-1/2 md:w-1/3 lg:w-1/4 xl:w-1/4 xxl:w-1/4 txl:w-1/4 txl:w-1/4 fxl:w-1/4">
			{{#each row as |item|}}
				<div>{{item.term}}</div>
			{{/each}}
		</div>
	{{/each}}
</div>
{{!-- term/letter }}

{{alpha-grid terms=model}}

{{outlet}}

Thank you all!

3 Likes

Hi @ef4 , thought I solved this problem with TailwindCSS, but turns out I wasn’t looking close enough and the terms were no longer alphabetized. So I rolled it back to flex/min-width and this is the result.

After wrestling with it through the long weekend, I concluded this comes from window.innerWidth (or $(window).width() in the Twiddle, along with the subsequent window.addEventListener and window.addEventListener.

Would you recommend using clientWidth instead, and, if so, how? Found another answer of yours on the subject, but don’t know if the same workflow applies here. Also found ember-in-viewport and it seems like it could provide a way to make this work.

Is there a good way to bind the table/grid to the width of an element instead of the window?

You can definitely measure the width of any element, but you can’t easily get an event telling you when a particular element changed size. That said, it’s probably enough to continue relying on window resize events (since the only thing likely to change the size of your element is a change in the size of the window).

So you can keep window.addEventListener('resize', ...) but replace window.innerWidth with this.element.getBoundingClientRect().width.

1 Like

Much better, but still a little out of bounds. Is there a way to set some kind of rowMax to keep it under 5 columns?

Don’t know if this will work once we have thousands of words on the page, but for now was able to make it work with:

let columns = Math.floor((this.element.getBoundingClientRect().width / 100) / 1.5 );

In my original example the columns were fixed at 100px wide, which is why there’s a divide-by-100 in that code. If you pick a different size for the columns, change it to match. There shouldn’t be a need for a fudge factor like the extra 1.5.

If things still aren’t fitting correctly, double-check that the element you’re measuring has the geometry you expect (by giving it a border or something visible).

1 Like

Ahhhh, ok. I see what you did there.

Yes, the element was set wrong and being measured wrong. Fixed it, then stretched the min-width to 175px, set a max-width at 175px to force breaks in the long terms, and added 8px of padding.

Works!

Thank you!!

Sorry to bother you so much today, @ef4. One more question on this.

I’ve got to break the terms up separately based on first letter. I tried using ember-composable-helpers, but unless I’m doing it wrong using group-by forces the terms into horizontal alphabetization, instead of the desired vertical result.

Went with ember-truth-helpers to do an “if equals” conditional comparison like so:

<h3>M</h3>
{{#each rows as |row|}}
	<div class="alpha-grid">
		{{#each row as |item|}}
			{{#if (eq item.letter "m")}}
				<div class="alpha-item"><a class="text-blue-light text-sm no-underline" href="{{href-to "terms.letter.term_id" item.letter item.id}}">{{item.term}}</a></div>
			{{/if}}
		{{/each}}
	</div>
{{/each}}

It solves the left-to-right alphabetization problem while creating another issue. Here’s a screenshot showing the situation.

The first 3 terms do not eq item.letter "m" and aren’t shown. However, they are replaced by the last 3 rows moving left and pushing the true first “M” term down… which throws everything off.

Is there a way to prevent this behavior or group-by without breaking the flex?

Assuming you have an {{alpha-grid}} component that works the way you want, don’t mess with it. Instead, call it multiple times, with different batches of words for each letter. You can do the grouping by letter in Javascript in a new, outer component that calls alpha-grid.

If you actually want the empty letters to appear (as in your screenshot), you’ll need to iterate through the whole alphabet.

// this will return a list structured like:
//
//  [ 
//    {letter: "a", names: [] }, 
//    { letter: "b", names: ["banana", "byte"] },
//    ... and so on
//  ]
letterGroups: computed(`names.[]`, function() {
  let groups = [];

  // "a" is ascii code 97. "z" is 122.
  for (let letterIndex = 97; letterIndex < 123; letterIndex++) {
    let letter = String.fromCharCode(letterIndex);
    groups.push({
      letter,
      names: this.names.filter(name => name[0].toLowerCase() === letter))
    });
  }
  return groups;
})

Then in your template:

{{#each letterGroups as |group|}}
  <h2>{{group.letter}}</h2>
  {{alpha-grid @names=group.names}}
{{/each}}
1 Like

This is perfect. If I’m following you correctly, this is what I did.

  1. Added a new component called letterGroups at /app/pods/components/letterGroups/component.js.
  2. Replaced name with term throughout.
  3. Added the template code ({{#each letterGroups...}}) to /app/pods/disciplines/template.hbs.

When I do this, it throws:

Parse error on line 5:
... {{alpha-grid @terms=group.terms}}{{/ea
-----------------------^
Expecting 'CLOSE_RAW_BLOCK', 'CLOSE', 'CLOSE_UNESCAPED', 'OPEN_SEXPR', 'CLOSE_SEXPR', 'ID', 'OPEN_BLOCK_PARAMS', 'STRIN...

However, when I remove the @ nothing happens. I’m running Ember 3.12, so should have all the latest decorators. Did I miss a step?

(repo with the latest push)

Oh, I’m mixing up my new and old syntax. You don’t need the @ for curly invocation. So {{alpha-grid terms=group.terms}}.

Page was still blank after dropping @. Have another attribute in JSON called “group” so wondered if that was causing an issue.

Changed everything to letterBatches, no luck there. The alpha-grid for this route is:

{{alpha-grid terms=model.terms}}

Have tried every combination of model.batches.terms, batches.model.terms, etc. I can think of and can’t find the right combination. Now have:

// /./letterBatches/component.js

import Component from '@ember/component';
import { computed } from "@ember/object";

export default Component.extend({
  // this will return a list structured like:
//
//  [
//    {letter: "a", terms: [] },
//    { letter: "b", terms: ["banana", "byte"] },
//    ... and so on
//  ]
letterBatches: computed(`terms.[]`, function() {
  let batches = [];

  // "a" is ascii code 97. "z" is 122.
  for (let letterIndex = 97; letterIndex < 123; letterIndex++) {
    let letter = String.fromCharCode(letterIndex);
    batches.push({
      letter,
      terms: this.terms.filter(term => term[0].toLowerCase() === letter)
    });
  }
  return batches;
}),
});

With this in the template.

{{!-- /disciplines/template.hbs --}}

{{#each letterBatches as |batches|}}
  <h2>{{batches.letter}}</h2>
  {{alpha-grid terms=batches.terms}}
{{/each}}