Skip to content

Instantly share code, notes, and snippets.

@ndugger
Last active May 12, 2021 03:30
Show Gist options
  • Save ndugger/858460b66ddf325119b6768f34a77531 to your computer and use it in GitHub Desktop.
Save ndugger/858460b66ddf325119b6768f34a77531 to your computer and use it in GitHub Desktop.
Example Todo App
import { $, Component } from 'yuzu/dom'
import { nanoid } from 'nanoid'
import { Icon } from './Icon'
import { Task } from './Task'
/**
* Example task application
*/
export class App extends Component {
/**
* Add a task with a custom color
*/
public addTask(data = this.#input.value, color = this.#color.value) {
const id = 'task-' + nanoid().replace(/[^A-Za-z0-9]/g, '')
const value = this.#input.value
const checked = false
if (!data) {
return ''
}
this.tasks.push({
checked,
color,
data,
id
})
/**
* Reset input if using its value
*/
if (value === data) {
this.#input.value = ''
}
this.update()
return id
}
/**
* Mark a task as completed
*/
public completeTask(id: string) {
const task = this.tasks.find(task => task.id === id)
if (task) {
task.checked = !task.checked
this.update()
}
return task
}
public removeTask(id: string) {
const task = this.tasks.find(task => task.id === id)
if (task) {
this.tasks.splice(this.tasks.indexOf(task), 1)
this.update()
}
return task
}
/**
* Internal #color input element
*/
get #color() {
return this.queryShadowSelector('#color') as HTMLInputElement
}
/**
* Internal #text input element
*/
get #input() {
return this.queryShadowSelector('#input') as HTMLInputElement
}
/**
* Internal collection of tasks
*/
protected tasks = [] as Task.Model[]
/**
* Handle "add" button click
*/
protected handleAddClick(_event: PointerEvent) {
const value = this.#input.value
if (value) {
this.addTask(value)
}
}
/**
* Handle text input submit
*/
protected handleInputKeyDown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const value = this.#input.value
if (value && key === 'enter') {
this.addTask(value)
}
}
/**
* Handle task click
*/
protected handleTaskClick(event: PointerEvent) {
this.completeTask((event.target as Task).id)
}
/**
* Handle task close
*/
protected handleTaskClose(task: Task.Model) {
this.removeTask(task.id)
}
/**
* Render app with dyanimic list of tasks
*/
protected render() {
const title = 'Task App'
const placeholder = 'Write a new task'
const info = 'There are no tasks...'
return [
<HTMLElement is='header'>
<HTMLHeadingElement is='h1'>
<Text data={ title }/>
</HTMLHeadingElement>
<HTMLDivElement id='controls'>
<HTMLButtonElement>
<HTMLDivElement id='picker'>
<HTMLInputElement defaultValue='#009dff' id='color' type='color'/>
</HTMLDivElement>
</HTMLButtonElement>
<HTMLInputElement autofocus id='input' maxLength={ 16 } onkeydown={ e => this.handleInputKeyDown(e as KeyboardEvent) } placeholder={ placeholder } type='text'/>
<HTMLButtonElement onclick={ e => this.handleAddClick(e as PointerEvent) }>
<Icon glyph='menu-add-line'/>
</HTMLButtonElement>
</HTMLDivElement>
</HTMLElement>,
<HTMLElement is='section'>
{ this.tasks.length === 0 ? (
<HTMLHeadingElement is='h2'>
<Text data={ info }/>
</HTMLHeadingElement>
) : (
<Task.List>
{ this.tasks.map(task => (
<Task onclick={ e => this.handleTaskClick(e as PointerEvent) } onclose={ () => this.handleTaskClose(task) } { ...task }/>
)) }
</Task.List>
) }
</HTMLElement>
]
}
/**
* Paint app with TaskApp's static style sheet
*/
protected paint() {
return [ App.CSS ]
}
}
export namespace App {
/**
* Static style sheet
*/
export const CSS = new Component.StyleSheet(css => {
css.selectHost(css => {
css.write(`
background: #e2e6ec;
border-radius: 40px;
box-sizing: border-box;
box-shadow: inset 0 0 24px rgb(255 255 255 / 50%);
display: flex;
flex-direction: column;
height: calc(100vh - 16px);
max-width: 400px;
overflow: hidden;
user-select: none;
width: calc(100vw - 16px);
`)
})
css.select('header', css => {
css.write(`
background: #e2e6ec;
filter: drop-shadow(0 16px 16px #e2e6ec);
flex-shrink: 1;
padding: 16px 16px 0;
position: relative;
z-index: 1;
`)
})
css.select('section', css => {
css.write(`
display: flex;
flex-basis: 100%;
flex-direction: column;
flex-grow: 1;
flex-shrink: 1;
overflow: auto;
padding: 0 16px 16px;
`)
})
css.select('h1', css => {
css.write(`
font-size: 24px;
margin: 16px 16px 24px;
`)
})
css.select('h2', css => {
css.write(`
align-items: center;
display: flex;
filter: drop-shadow(0 3px 6px rgba(0 0 15 / 40%));
flex-grow: 1;
font-size: 20px;
justify-content: center;
margin: 32px 0 16px;
opacity: 0.4;
`)
})
css.selectId('controls', css => {
css.write(`
display: flex;
flex-shrink: 1;
height: 48px;
justify-content: center;
width: 100%;
`)
})
css.selectId('picker', css => {
css.write(`
align-items: center;
border-radius: 100%;
display: inline-flex;
height: 32px;
justify-content: center;
overflow: hidden;
width: 32px;
`)
})
css.selectClass(HTMLInputElement, css => {
css.write(`
background: #fff;
border: none;
border-radius: 48px;
filter: drop-shadow(0 12px 32px rgba(0 0 15 / 25%));
flex-grow: 1;
font-size: 16px;
margin: 0 8px;
outline: none;
padding: 0 24px;
transition: filter 200ms;
width: 0;
`)
css.selectFocus(css => {
css.write(`
filter:
drop-shadow(0 12px 32px rgba(0 0 15 / 25%))
drop-shadow(0 4px 8px rgba(0 0 15 / 15%));
`)
})
css.select('[ type = color ]', css => {
css.write(`
flex-shrink: 0;
height: 200%;
margin: 0;
padding: 0;
width: 200px;
`)
})
})
css.selectClass(HTMLButtonElement, css => {
css.write(`
align-items: center;
background: #fff;
border: none;
border-radius: 100%;
display: flex;
filter: drop-shadow(0 12px 32px rgba(0 0 15 / 25%));
flex-shrink: 0;
justify-content: center;
outline: none;
transition: filter 200ms;
width: 48px;
`)
css.selectHover(css => {
css.write(`
filter:
drop-shadow(0 12px 32px rgba(0 0 15 / 25%))
drop-shadow(0 4px 8px rgba(0 0 15 / 15%));
`)
})
})
css.selectClass(Icon, css => {
css.write(`
font-size: 25px;
line-height: 48px;
`)
})
css.selectClass(HTMLUListElement, css => {
css.write(`
margin: 0;
padding: 0;
`)
})
})
}
import { $, CSS, Component } from 'yuzu/dom'
/**
* Icon which uses the "Renix Icons" font
*/
export class Icon extends Component {
public color = ''
public glyph = 'home-line'
public size = 24
/**
* Render a link to the remix icons font, and the provided glyph
*/
public render() {
return [
<HTMLLinkElement href={ Icon.SRC } rel='stylesheet'/>,
<HTMLElement is='i' className={ `ri-${ this.glyph }` }/>
]
}
/**
* Paint with Icon's static stylesheet and some dynamic CSS in order to set the color
*/
public paint() {
return [
Icon.CSS ,
CSS.serialize(css => {
css.select('i', css => {
css.write(`
color: ${ this.color || 'rgb(0 0 0)' };
`)
})
})
]
}
}
export namespace Icon {
/**
* "Remix Icons" font address
*/
export const SRC = 'https://cdn.jsdelivr.net/npm/[email protected]/fonts/remixicon.css'
/**
* Static style sheet
*/
export const CSS = new Component.StyleSheet(css => {
css.selectHost(css => {
css.write(`
display: contents;
`)
})
})
}
import { $, CSS, Component } from 'yuzu/dom'
import { DateTime, Duration } from 'luxon'
import { Icon } from './Icon'
/**
* List-based task component
*/
export class Task extends Component implements Task.Model {
public checked = false
public color = 'rgb(0 0 0)'
public data = ''
public date = new Date()
public onclose = () => {}
/**
* Render a list item with a check icon, the task's data, a timestamp, and a close icon
*/
protected render() {
const when = DateTime.local()
.minus(Duration.fromMillis(1000))
.plus(DateTime.fromJSDate(this.date).diffNow())
.toRelative() ?? ''
return [
<HTMLLIElement>
<Icon color={ this.color } glyph={ this.checked ? 'checkbox-circle-line' : 'checkbox-blank-circle-line' } />
<HTMLSpanElement>
<Text data={ this.data }/>
</HTMLSpanElement>
<HTMLSpanElement id='when'>
<Text data={ when }/>
<Icon glyph='close-line' id='close' onclick={ this.onclose } size={ 12 }/>
</HTMLSpanElement>
</HTMLLIElement>
]
}
/**
* Paint with Task's static stylesheet and some dynamic CSS in order to show a "checked" state
*/
protected paint() {
return [
Task.CSS,
CSS.serialize(css => {
css.selectClass(HTMLSpanElement, css => {
css.write(`
color: ${ this.color };
font-weight: ${ this.checked ? 'lighter' : 'bold' };
opacity: ${ this.checked ? 0.6 : 1 };
text-decoration: ${ this.checked ? 'line-through' : 'none' };
`)
})
})
]
}
}
export namespace Task {
/**
* Optional alias for `HTMLUListElement` in order to abstract the implementation
*/
export const List = HTMLUListElement
export type List = typeof HTMLUListElement
/**
* Model which represents atask
*/
export interface Model {
checked?: boolean
color?: string
data: string
id: string
}
/**
* Static style sheet
*/
export const CSS = new Component.StyleSheet(css => {
css.write(`
@keyframes appear {
from {
opacity: 0;
top: -16px;
}
to {
opacity: 1;
top: 0;
}
}
`)
css.selectHost(css => {
css.write(`
animation: appear 500ms forwards;
background: white;
border-radius: 8px;
display: block;
margin-top: 16px;
padding: 16px 16px;
position: relative;
transition: filter 200ms;
`)
})
css.selectHostIs([ css.selectFirstOfType() ], css => {
css.write(`
margin-top: 32px;
`)
})
css.selectHostIs([ css.selectHover() ], css => {
css.write(`
filter:
drop-shadow(0 12px 32px rgba(0 0 15 / 16.66%))
drop-shadow(0 4px 8px rgba(0 0 15 / 6.66%));
`)
})
css.selectClass(HTMLLIElement, css => {
css.write(`
align-items: center;
display: flex;
list-style: none;
`)
})
css.selectClass(Icon, css => {
css.write(`
font-size: 31px;
line-height: 17px;
`)
})
css.selectClass(HTMLSpanElement, css => {
css.write(`
display: inline-block;
margin-left: 8px;
text-transform: capitalize;
`)
})
css.selectId('when', css => {
css.write(`
align-items: center;
color: rgb(0 0 0);
display: inline-flex;
font-size: 0.7em;
flex-grow: 1;
justify-content: flex-end;
text-decoration: none;
`)
})
css.selectId('close', css => {
css.write(`
display: inline-block;
margin-left: 8px;
opacity: 0.33;
position: relative;
top: 2px;
transition: opacity 200ms;
`)
css.selectHover(css => {
css.write(`
opacity: 1;
`)
})
})
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment