v 0.2.0

Radiant Todo App


The RadiantTodoApp component is a simple todo app that uses the ContextProvider to share the data between components.

In this example we are using WithKita mixin to use JSX in our components.

The features of this todo app are:

Also if the list of the features seems not to be so big, there is a lot of concepts that are being used in this example.

Let's check it together.

Incomplete Todos

Incomplete Todos: 2

Completed Todos

Completed Todos: 1

Jsx Markup

This is the jsx markup of the RadiantTodoApp component.

In this component we have a form to add a new todo, two sections to show the todos and completed todos, and a script tag to hydrate the context.

The hydrate attribute is used to merge the data from the server with the initial value of the context, please read the provideContext documentation to understand how it works.

Since hydrate will merge the data with the initial value, you can pass only the properties that you want to update.

const TodoPanel = ({
  title,
  count,
  children,
  ref,
}: { title: string; count: number; children: JSX.Element; ref: string }) => {
  return (
    <article class="todo__panel">
      <h2 safe>{title}</h2>
      <p class="todo__count">
        {title as 'safe'}: <span data-ref={`count-${ref}`}>{count}</span>
      </p>
      <div class="todo__list" data-ref={`list-${ref}`}>
        {children}
      </div>
    </article>
  );
};

const TodoForm = () => {
  return (
    <form>
      <div class="form-group">
        <label for="new-todo">Add Todo</label>
        <input id="new-todo" name="todo" />
      </div>
      <button type="submit">Add</button>
    </form>
  );
};

export const RadiantTodoApp = async () => {
  const data = await getData();
  const incompleteTodos = data.todos.filter((todo) => !todo.complete);
  const completedTodos = data.todos.filter((todo) => todo.complete);
  return (
    <>
      <radiant-todo-app class="todo">
        <section class="todo__board">
          <TodoPanel title="Incomplete Todos" count={incompleteTodos.length} ref="incomplete">
            {incompleteTodos.length > 0 ? <TodoList todos={incompleteTodos} /> : <NoTodosMessage />}
          </TodoPanel>
          <TodoPanel title="Completed Todos" count={completedTodos.length} ref="complete">
            {completedTodos.length > 0 ? <TodoList todos={completedTodos} /> : <NoCompletedTodosMessage />}
          </TodoPanel>
        </section>
        <TodoForm />
        <script type="json" data-hydration>
          {stringifyTyped<Partial<TodoContext>, string>({ todos: data.todos })}
        </script>
      </radiant-todo-app>
    </>
  );
};

Typescript

In this first snippet we have the radiant-todo-app component that is responsible for managing the todos.

The features used in this component are:

import {
  type ContextProvider,
  RadiantElement,
  WithKita,
  consumeContext,
  contextSelector,
  createContext,
  customElement,
  onEvent,
  provideContext,
  query,
  reactiveProp,
} from '@ecopages/radiant';

import { NoCompletedTodosMessage, NoTodosMessage, TodoList } from './radiant-todo.templates';

export type Todo = {
  id: string;
  text: string;
  complete: boolean;
};

export type TodoContext = {
  todos: Todo[];
  logger: Logger;
};

export const todoContext = createContext<TodoContext>(Symbol('todo-context'));

class Logger {
  log(message: string) {
    console.log('%cLOGGER', 'background: #222; color: #bada55', message);
  }
}

@customElement('radiant-todo-app')
export class RadiantTodoApp extends WithKita(RadiantElement) {
  @query({ ref: 'list-complete' }) listComplete!: HTMLElement;
  @query({ ref: 'list-incomplete' }) listIncomplete!: HTMLElement;
  @query({ ref: 'count-complete' }) countComplete!: HTMLElement;
  @query({ ref: 'count-incomplete' }) countIncomplete!: HTMLElement;

  @provideContext<typeof todoContext>({
    context: todoContext,
    initialValue: { todos: [], logger: new Logger() },
    hydrate: Object,
  })
  provider!: ContextProvider<typeof todoContext>;

  @onEvent({ selector: 'form', type: 'submit' })
  submitTodo(event: FormDataEvent) {
    event.preventDefault();
    const form = event.target as HTMLFormElement;
    const formData = new FormData(form);
    const todo = formData.get('todo');

    if (todo) {
      const prevTodos = this.provider.getContext().todos;
      const todos = [...prevTodos, { id: Date.now().toString(), text: todo.toString(), complete: false }];
      this.provider.setContext({ todos });
      form.reset();
    }
  }

  @contextSelector({
    context: todoContext,
    select: ({ todos }) => ({
      todosCompleted: todos.filter((todo) => todo.complete),
      todosIncomplete: todos.filter((todo) => !todo.complete),
    }),
  })
  onTodosUpdated({ todosCompleted, todosIncomplete }: Record<string, TodoContext['todos']>) {
    const todosMapping = [
      { todos: todosCompleted, list: this.listComplete, noTodosMessage: <NoTodosMessage /> },
      { todos: todosIncomplete, list: this.listIncomplete, noTodosMessage: <NoCompletedTodosMessage /> },
    ];

    for (const { todos, list, noTodosMessage } of todosMapping) {
      if (todos.length === 0) {
        this.renderTemplate({
          target: list,
          template: noTodosMessage,
        });
      } else {
        this.renderTemplate({
          target: list,
          template: <TodoList todos={todos} />,
        });
      }
    }

    this.countComplete.textContent = todosCompleted.length.toString();
    this.countIncomplete.textContent = todosIncomplete.length.toString();
  }
}

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'radiant-todo-app': HtmlTag;
    }
  }
}

In this second snippet we have the radiant-todo-item component that is responsible for rendering the todo item.

The features used in this component are:

export type RadiantTodoProps = {
    complete?: boolean;
};

@customElement('radiant-todo-item')
export class RadiantTodoItem extends WithKita(RadiantElement) {
  @query({ selector: 'input[type="checkbox"]' }) checkbox!: HTMLInputElement;
  @query({ selector: 'button' }) removeButton!: HTMLButtonElement;
  @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare complete: boolean;
  @consumeContext(todoContext) context!: ContextProvider<typeof todoContext>;

  override connectedCallback(): void {
    super.connectedCallback();
    this.complete = this.checkbox.checked;
  }

  @onEvent({ selector: 'input[type="checkbox"]', type: 'change' })
  toggleComplete(event: Event) {
    const checkbox = event.target as HTMLInputElement;
    const todo = this.context.getContext().todos.find((t) => t.id === this.id);
    if (!todo) return;

    this.complete = checkbox.checked;

    this.context.setContext({
      todos: this.context.getContext().todos.map((t) => (t.id === this.id ? { ...t, complete: checkbox.checked } : t)),
    });

    const logger = this.context.getContext().logger;
    logger.log(`Todo ${this.id} is now ${checkbox.checked ? 'complete' : 'incomplete'}`);
  }

  @onEvent({ ref: 'remove-todo', type: 'click' })
  removeTodo() {
    this.context.setContext({
      todos: this.context.getContext().todos.filter((t) => t.id !== this.id),
    });

    const logger = this.context.getContext().logger;
    logger.log(`Todo ${this.id} removed`);
  }
}

declare global {
    namespace JSX {
      interface IntrinsicElements {
        'radiant-todo-item': HtmlTag & RadiantTodoProps;
      }
    }
}

Finally, in this last snippet we have the NoTodosMessage, NoCompletedTodosMessage, TodoItem and TodoList components.

Theses components are responsible for rendering the messages when there are no todos to show, the todo item and the todo list.

They are used both in the initial markup and in the custom elements.

import type { Todo } from './radiant-todo-app.script';

export const NoTodosMessage = () => {
    return <div>No todos to show</div>;
};

export const NoCompletedTodosMessage = () => {
    return <div>No completed todos to show</div>;
};

export const TodoItem = ({ id, complete, text }: Todo) => {
    return (
      <radiant-todo-item complete={complete} class="todo__item" id={id}>
        {text as 'safe'}
        <span>
          <input type="checkbox" checked={complete} />
        </span>
        <button type="button" class="todo__item-remove">
          <svg
            width="20"
            height="20"
            aria-hidden="true"
            focusable="false"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            class="lucide lucide-x"
          >
            <path d="M18 6 6 18" />
            <path d="m6 6 12 12" />
          </svg>
        </button>
      </radiant-todo-item>
    );
};

export const TodoList = ({ todos }: { todos: Todo[] }) => {
    return (
      <>
        {todos.map((todo) => (
          <TodoItem {...todo} />
        ))}
      </>
    );
};