0

I have a color buttons which the user can select only one color. If the button is click, the button that was clicked will have class 'color__active'. I'm trying to get the <button> with a dataset['color'] that contains CSS class 'color__activebut it returnsundefined`.

HTML

<div class="flex items-center space-x-4">
  <button
    class="product__color color__active flex h-5 w-5 items-center justify-center rounded-full text-white"
    style="background-color: #000"
    data-color="#000"
  >
    <svg class="h-4 w-4 fill-current">
      <use xlink:href="http://localhost:1234/icons.fc114240.svg?1649745862008#icon-check"></use>
    </svg>
  </button>

  <button
    class="product__color  flex h-5 w-5 items-center justify-center rounded-full text-white"
    style="background-color: #ff0000"
    data-color="#ff0000"
  ></button>
</div>;

JavaScript

let color = Array.from(document.querySelectorAll('.product__color')).forEach(c => {
  return c.classList.contains('color__active') && c.dataset.color;
});
Dai
  • 141,631
  • 28
  • 261
  • 374
J.Wujeck
  • 280
  • 4
  • 16
  • 1
    You can't use `return` inside a `.forEach` method. (And _no-one_ should be using `.forEach` today anyway) – Dai Apr 12 '22 at 06:49
  • What should happen if multiple buttons have the CSS class `product__color` applied? – Dai Apr 12 '22 at 06:50
  • 1
    [What does `return` keyword mean inside `forEach` function?](https://stackoverflow.com/q/34653612) – VLAZ Apr 12 '22 at 06:55

2 Answers2

3
  • Array.prototype.forEach does not do what you think it does.

    • It invokes the inner function for every element in the array.
    • Crucially (in your case), it does not support returning values, nor finding elements in an array.
  • Also, in general: don't use Array.prototype.forEach.

    • It isn't intended to be used as a pure function (unlike Array.protoype.map or Array.prototype.filter) which makes it harder to reason about (e.g. what happens if you modify the array while inside the forEach callback?).
    • Also, it's impossible to control iteration inside the body, and careless use of it often inadvertently captures state in its closure which is a frequent source of bugs.
      • Array.prototype.forEach exists only because earlier versions of JavaScript didn't have a simple way to iterate through general collection-types.
    • Instead, use the modern for( of ) statement instead.
  • Also, your logic in your .forEach inner func can be moved to the selector:

    • c.classList.contains('color__active') is the same as using .color__active in the selector.
    • The check for a non-falsy (i.e. non-empty) c.dataset.color can be added to the CSS selector by using the "has non-empty attribute" selector: [data-color]:not([data-color=""i])
      • Surprisingly CSS doesn't have an "attribute-value not-equal-to X" selector, but you can use :not([attribute-name="value"]) as a logically-equivalent, if more verbose, alternative.
    • And you should add the button element type to the selector too:
    • So the final selector is: button.product__color.color__active[data-color]:not([data-color=""i])
  • Then just use document.querySelector to grab the first element (rather than all elements) that match the selector instead of using querySelectorAll.

    • Though it would be a good idea to check if there are multiple elements matching the selector and then throw new Error(), just to be safe.

So change your code to just this:

const firstColorButtonOrNull = document.querySelector( 'button.product__color.color__active[data-color]:not([data-color=""i])' ) ?? null;
const color = firstColorButtonOrNull?.dataset['color'] ?? null;
  • The ?? null at the end of each line is to coalesce undefined to null which is often easier to process in JS.
  • The ?. is the safe chaining operator (also known as the Elvis operator or the safe navigation operator in other languages).
    • So if firstColorButtonOrNull is null or undefined, then the expression evaluates to undefined instead of crashing.
      • The second ?? null at the end is so that color will be null instead of undefined.

...which can then be simplified down to a single-liner:

const firstColorOrNull = document.querySelector( 'button.product__color.color__active[data-color]:not([data-color=""i])' )?.dataset['color'] ?? null;

Consider using defensive programming practices:

This alternative implementation below (getFirstActiveColorOrNull) ensures there's only ever 0 or 1 elements with .product__color otherwise it throws an error. This approach is an example of defensive programming, and should be used if you care about correctness and self-documenting code (as it demonstrates intent: as with your current code it's unclear to anyone else if product__color is only meant to be used at-most once or not).

function getFirstActiveColorOrNull() {

  const selector = 'button.product__color.color__active[data-color]:not([data-color=""i])';

  function getFirstOrNull( elements, projection ) {
    if( colorButtons.length == 0 ) {
      return null;
    }
    else if( colorButtons.length == 1 ) {
      return projection( colorButtons[0] );
    }
    else { // Implicit: colorButtons.length > 1
      throw new Error( "Encountered multiple elements matching selector \"" + selector + "\". This should never happen." );
    }
  }
  
  const colorButtons = document.querySelectorAll( selector );
  return getFirstOrNull( colorButtons, el => el.dataset.color );
}

//

function makeMeActive( button ) {
    
    const otherActiveElements = document.querySelectorAll( '.color__active' );
    for( const el of otherActiveElements ) el.classList.remove( 'color__active' );
    
    button.classList.add( 'color__active' );
}
.color__active {
    outline: 5px solid blue;
}
<div class="flex items-center space-x-4">
  <button
    class="product__color color__active flex h-5 w-5 items-center justify-center rounded-full text-white"
    style="background-color: #000; color: white;"
    data-color="#000"
    onclick="makeMeActive(this)"
  >
   Black
  </button>

  <button
    class="product__color  flex h-5 w-5 items-center justify-center rounded-full text-white"
    style="background-color: #ff0000"
    data-color="#ff0000"
    onclick="makeMeActive(this)"
  >Red</button>
</div>

<hr />

<p>Click a button to make it "active": it will be given a blue border.</p>

<button onclick="console.log( getFirstActiveColorOrNull() )">Click me to print active color to console</button>
Dai
  • 141,631
  • 28
  • 261
  • 374
  • Thanks for this! What does 'i' do in this line [data-color=""i]? – J.Wujeck Apr 12 '22 at 07:23
  • 2
    [The `i` means _case-insensitive_ match](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors). It's not necessary when comparing to an empty attribute value `=""` but I always use it just-in-case I change it to a non-empty value. You can use `s` for a case-sensitive match (the default is case-sensitive). Also, the `i` and `s` options only apply to ASCII-range characters (i.e. `A-Za-z`). – Dai Apr 12 '22 at 07:25
  • Should I use for of loop instead of forEach? – J.Wujeck Apr 12 '22 at 07:29
  • 1
    @J.Wujeck You don't need _any_ loops if you're only interested in a single element. Please read my answer thoroughly. – Dai Apr 12 '22 at 07:32
2

You can get the color using this querySelector:

let color = document.querySelector(".product__color.color__active")?.dataset
  ?.color;
mgm793
  • 1,968
  • 15
  • 23