Building an Awesome Todo List App

Thank you for your reply! :slight_smile: @senocular If I use the concat here the following error occurs :thinking: I used “toString” here to get rid of the error, this is very confusing :smiley:

@kirupa @senocular Could this happen because of the style of my app? I´m practicing a single page application with two other “pages” and I am using react-router-dom :thinking:

Edit: I figured it out ! Thank you for your help :slight_smile: @kirupa @senocular

Now how would you add the possibility to rename tasks on the list and move tasks up or down like drag and drop etc. ? :thinking:

A web component version of a todo list that… renders as it’s own source code!

<script>
/**
 * Base class for todo elements containing shared functionality
 * between both the <todo-list> and <todo-task> elements.
 */
class TodoElement extends HTMLElement {

  /**
   * Generates an HTML string representation of the current
   * element's HTML tag.
   * @param closed - When true generates a closing tag.
   */
  tag(closed = false) {
    const name = this.tagName.toLowerCase()
    return `&lt;${closed ? "/" : ""}${name}${closed ? "" : this.attrs()}&gt;`
  }

  /**
   * Provides the attributes string included in tag()-generated
   * tag HTML. This will be empty by default. Individual
   * components can define their own values by overriding
   * this method.
   */
  attrs() {
    return ""
  }

  /**
   * Adds styles and sets up listeners for add buttons. Add buttons
   * exist both in the todo-list and individual todo-task elements.
   */
  setupAdd() {
    const addBtn = this.shadowRoot.getElementById("add-btn")
    addBtn.addEventListener("click", () => this.onAddClick())
    const style = document.createElement("style")
    
    // Using a ::before pseudo element for the add button text
    // keeps it from being copied when the user selects all the
    // text in the component.
    style.textContent = `
    #add-btn::before{content:"+";color:green;}
    #add-btn{opacity:0.333;}
    #add-btn:hover,#add-btn:focus{opacity:1;}
    `
    this.shadowRoot.appendChild(style)
  }

  /**
   * Add button click handler. When clicked, add buttons generate an
   * event handled by todo-list.
   */
  onAddClick() {
    this.dispatchEvent(new Event("todo-add", {
      bubbles: true
    }))
  }
}

/**
 * Component definition for <todo-list> elements. A todo-list can
 * contain any number of <todo-task> elements that together make up
 * a todo list.
 */
customElements.define("todo-list", class extends TodoElement {

  /**
   * Constructor called when creating new <todo-list> elements.
   */
  constructor() {
    super()
    const shadow = this.attachShadow({ mode: "open" })
    shadow.innerHTML = `
    <style>
    *{font-family:monospace;}
    </style>
    
    <article>
      <div id="open">${this.tag()}</div>
      <slot></slot>
      <div><button id="add-btn"></button></div>
      <div id="close">${this.tag(true)}</div>
    </article>
    `
    this.setupAdd()
    this.addEventListener("todo-add", event => this.onAdd(event))
  }

  /**
   * Add event handler. Adds a new task in a position based on
   * which button triggered the event.
   */
  onAdd({ target }) {
  
    // If the event came from this todo-list element, it came
    // from the list's own add button positioned below the
    // todo-task items so it should be added at the end of
    // the task list (null). Otherwise the button is from a
    // todo-task which means adding before that task.
    const before = target === this ? null : target
    const task = document.createElement("todo-task")
    this.insertBefore(task, before)
    task.done = false
    task.innerText = "TODO"
    
    // When we add a new todo-task we also select the task
    // message. To ensure the selection takes effect, we delay
    // it a single render frame.
    requestAnimationFrame(() => task.selectMessage())
  }
})

/**
 * Component definition for <todo-task> elements. A todo-task
 * represents a single task item within a <todo-list> element.
 * A todo-task can be given a message and be marked as being
 * done (true) or not (false).
 */
customElements.define("todo-task", class extends TodoElement {

  /**
   * Done property mirroring the done attribute. The value
   * can be either the boolean values true or false, though
   * the attribute uses the strings "true" and "false".
   */
  get done() {
    return this.getAttribute("done") === "true"
  }
  set done(value) {
    // Converting the value to boolean first (!!) ensures
    // the String conversion gives us either "true" or "false".
    this.setAttribute("done", String(!!value))
  }

  /**
   * Constructor called when creating new <todo-task> elements.
   */
  constructor() {
    super()
    const shadow = this.attachShadow({ mode: "open" })
    
    // Using a ::before pseudo element for the remove button text
    // keeps it from being copied when the user selects all the
    // text in the component.
    shadow.innerHTML = `
    <style>
    *{font-family:monospace;}
    #remove-btn::before{content:"-";color:red;}
    #remove-btn{opacity:0.333;}
    #remove-btn:hover,#remove-btn:focus{opacity:1;}
    #done-btn{
      background-color:transparent;
      border-color:transparent;
    }
    #done-btn:hover,#done-btn:focus{
      background-color:revert;
      border-color:revert;
    }
    </style>
    
    <div>
    	<button id="add-btn"></button>
    </div>
    <section id="tag"
      ><button id="remove-btn"></button
      > <span id="open">${this.tag()}</span
      ><span id="message" contenteditable><slot></slot></span
      ><span id="close">${this.tag(true)}</span
    ></section>
    `

    this.message = shadow.getElementById("message")
    this.doneBtn = shadow.getElementById("done-btn")
    const removeBtn = shadow.getElementById("remove-btn")
    this.setupAdd()

    // Child content won't always be accessible immediately,
    // but we can get notified with a slotchange event. On a
    // slotchange we know the children have been added and
    // are accessible so we can pull them out and update the
    // rendered text.
    shadow.addEventListener("slotchange", () => this.fetchContent())
    this.doneBtn.addEventListener("click", () => this.onDoneToggle())
    
    // While the done button inherently updates the internal
    // attribute value, we will manually need to copy over
    // changes to the editable rendered text back to the content
    // text of the component since what is editable is part of
    // this component and not the original child content. This
    // ensure anyone checking the contents of the component will
    // find the same content as seen in the rendered text.
    this.message.addEventListener("blur", () => this.onMessageUpdate())
    removeBtn.addEventListener("click", () => this.remove())
  }

  /**
   * The todo-task attributes include a done attribute whose value
   * can be true or false. A button is inserted to allow users
   * to toggle this value when clicked.
   * @override
   */
  attrs() {
    return ` done="<button id="done-btn"></button>"`
  }

  /**
   * Pulls attribute values from the source HTML into the
   * rendered text.
   */
  fetchAttr() {
    this.doneBtn.textContent = String(this.done)
  }

  /**
   * Done attribute toggle event handler. When activated, the done
   * attribute button toggles the done attribute between true and
   * false.
   */
  onDoneToggle() {
    // This updates the source attribute.
    this.done = !this.done
    // This updates the rendered text from that attribute.
    this.fetchAttr()
  }

  /**
   * Pulls child text from the source HTML into the
   * rendered text.
   */
  fetchContent() {
    this.message.textContent = this.textContent
  }

  /**
   * Selects the todo message text to make it easier for users
   * to edit a todo message when a task is first added.
   */
  selectMessage() {
    const range = document.createRange()
    range.setStart(this.message, 0)
    range.setEnd(this.message, 1)
    const sel = window.getSelection()
    sel.removeAllRanges()
    sel.addRange(range)
    this.message.focus()
  }
  
  /**
   * Message update event handler. This updates the source HTML
   * with changes made to the rendered text.
   */
  onMessageUpdate() {
    this.textContent = this.message.textContent
  }

  /**
   * When connected we pull the attribute values to show in
   * the rendered text. Note that the message text is not
   * pulled here since it may not yet be available. That is
   * instead handled by a slotchange event.
   */
  connectedCallback() {
    this.fetchAttr()
  }
})
</script>
<todo-list></todo-list>

See it live on JSFiddle.

P.S. @kirupa looks like there’s a few retroactive badges to hand out with this one too :upside_down_face: .

2 Likes