Incomplete Todos
Incomplete Todos: 2
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: 2
Completed Todos: 1
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>
</>
);
};
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:
@query
decorator to query for the elements inside the component that uses the data-ref
attribute@provideContext
decorator to provide the context to the children components@onEvent
decorator to listen for the form submit event@contextSelector
decorator to select the todos based on the complete statusrenderTemplate
method to render the templates. Here we are using WithKita mixin to render the JSX templatesimport {
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:
@query
decorator to query for the checkbox element@reactiveProp
decorator to define the complete attribute@consumeContext
decorator to consume the context from the parent componentexport 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} />
))}
</>
);
};