woby-table
v1.0.4
Published
table ui component for voby
Downloads
10
Readme
voby-table
Voby table
install
Development
npm install
npm start
Example
https://github.com/wongchichong/woby-table
Usage
import { $, $$, render, useEffect, useMemo, type JSX, isObservable, ObservableMaybe, Observable } from "woby"
import { groupBy, orderBy, filter as ft, chain, sortBy, sumBy, isArray, omit, map } from "lodash-es"
import { Table, TableProps, useTable } from '../src/index'
import { tw } from 'woby-styled'
import { useClickAway, make } from 'use-woby'
import { Wheeler } from 'woby-wheeler'
import 'woby-wheeler/dist/output.css'
const ExpandMoreIcon = (props: JSX.SVGAttributes<SVGElement>) => <svg class="inline-block" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" {...props}><path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" /></svg>
const ExpandLessIcon = (props: JSX.SVGAttributes<SVGElement>) => <svg class="inline-block" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" {...props}><path d="m296-345-56-56 240-240 240 240-56 56-184-184-184 184Z" /></svg>
const SquareIcon = (props: JSX.SVGAttributes<SVGElement>) => <svg class="inline-block rotate-45 scale-50" xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" {...props}><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Z" /></svg>
const FilterIcon = (props: JSX.SVGAttributes<SVGElement>) => <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" {...props}><path d="M400-240v-80h160v80H400ZM240-440v-80h480v80H240ZM120-640v-80h720v80H120Z" /></svg>
const FilterOffIcon = (props: JSX.SVGAttributes<SVGElement>) => <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" {...props}><path d="M791-55 55-791l57-57 736 736-57 57ZM633-440l-80-80h167v80h-87ZM433-640l-80-80h487v80H433Zm-33 400v-80h160v80H400ZM240-440v-80h166v80H240ZM120-640v-80h86v80h-86Z" /></svg>
const ViewColumnIcon = (props: JSX.SVGAttributes<SVGElement>) => <svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000" {...props}>
<rect fill="none" height="24" width="24" />
<path d="M3,5v14h18V5H3z M8.33,17H5V7h3.33V17z M13.67,17h-3.33V7h3.33V17z M19,17h-3.33V7H19V17z" />
</svg>
const db = [
{ id: 1, name: "Alice", age: 8, country: "Canada", add: "Canada" },
{ id: 2, name: "Bob", age: 12, country: "USA", add: "USA" },
{ id: 3, name: "Charlie", age: 25, country: "UK", add: "UK" },
{ id: 4, name: "David", age: 32, country: "Australia", add: "Australia" },
{ id: 5, name: "Eve", age: 19, country: "Canada", add: "Canada" },
{ id: 6, name: "Frank", age: 45, country: "USA", add: "USA" },
{ id: 7, name: "Grace", age: 7, country: "UK", add: "UK" },
{ id: 8, name: "Hank", age: 28, country: "Canada", add: "Canada" },
{ id: 9, name: "Ivy", age: 14, country: "USA", add: "USA" },
{ id: 10, name: "Jack", age: 33, country: "Australia", add: "Australia" },
{ id: 11, name: "Karen", age: 22, country: "Canada", add: "Canada" },
{ id: 12, name: "Liam", age: 17, country: "USA", add: "USA" },
{ id: 13, name: "Mia", age: 31, country: "UK", add: "UK" },
{ id: 14, name: "Noah", age: 5, country: "Australia", add: "Australia" },
{ id: 15, name: "Olivia", age: 28, country: "Canada", add: "Canada" },
{ id: 16, name: "Paul", age: 9, country: "USA", add: "USA" },
{ id: 17, name: "Quinn", age: 37, country: "UK", add: "UK" },
{ id: 18, name: "Ryan", age: 16, country: "Australia", add: "Australia" },
{ id: 19, name: "Sophia", age: 11, country: "Canada", add: "Canada" },
{ id: 20, name: "Thomas", age: 29, country: "USA", add: "USA" },
{ id: 21, name: "Uma", age: 6, country: "UK", add: "UK" },
{ id: 22, name: "Vincent", age: 42, country: "Australia", add: "Australia" },
{ id: 23, name: "Wendy", age: 15, country: "Canada", add: "Canada" },
{ id: 24, name: "Xander", age: 20, country: "USA", add: "USA" },
{ id: 25, name: "Yasmine", age: 24, country: "UK", add: "UK" },
{ id: 26, name: "Zane", age: 36, country: "Australia", add: "Australia" },
{ id: 27, name: "Ava", age: 13, country: "Canada", add: "Canada" },
{ id: 28, name: "Ben", age: 38, country: "USA", add: "USA" },
{ id: 29, name: "Chloe", age: 23, country: "UK", add: "UK" },
{ id: 30, name: "Daniel", age: 18, country: "Australia", add: "Australia" },
{ id: 31, name: "Emily", age: 7, country: "Canada", add: "Canada" },
{ id: 32, name: "Finn", age: 30, country: "USA", add: "USA" },
{ id: 33, name: "Georgia", age: 10, country: "UK", add: "UK" },
{ id: 34, name: "Henry", age: 27, country: "Australia", add: "Australia" },
{ id: 35, name: "Isabella", age: 14, country: "Canada", add: "Canada" },
{ id: 36, name: "Jacob", age: 28, country: "USA", add: "USA" },
{ id: 37, name: "Katherine", age: 26, country: "UK", add: "UK" },
{ id: 38, name: "Liam", age: 33, country: "Australia", add: "Australia" },
{ id: 39, name: "Mia", age: 6, country: "Canada", add: "Canada" },
{ id: 40, name: "Noah", age: 35, country: "USA", add: "USA" },
{ id: 41, name: "Olivia", age: 9, country: "UK", add: "UK" },
{ id: 42, name: "Peter", age: 17, country: "Australia", add: "Australia" },
{ id: 43, name: "Quinn", age: 12, country: "Canada", add: "Canada" },
{ id: 44, name: "Ryan", age: 19, country: "USA", add: "USA" },
{ id: 45, name: "Sophia", age: 8, country: "UK", add: "UK" },
{ id: 46, name: "Thomas", age: 22, country: "Australia", add: "Australia" },
{ id: 47, name: "Uma", age: 5, country: "Canada", add: "Canada" },
{ id: 48, name: "Vincent", age: 24, country: "USA", add: "USA" },
{ id: 49, name: "Wendy", age: 29, country: "UK", add: "UK" },
{ id: 50, name: "Xander", age: 16, country: "Australia", add: "Australia" }
]
const groupedData = chain(db)
.groupBy((item) => [item.country])
// .mapValues((countryData) =>
// sortBy(countryData, 'name')
// )
.value()
const nestedGroupData = chain(db)
.groupBy("country")
.mapValues((countryData) =>
chain(countryData).groupBy((item) => item.name.charAt(0))
.mapValues((countryData) =>
sortBy(countryData, 'name')
)
.value()
)
.value()
type SortDir = 'asc' | 'desc' | ''
type ToObservable<T, V> = Record<keyof T, ObservableMaybe<V>>
type SortDirRecord<T> = Record<keyof T, Observable<SortDir>>
type IData<T> = {
data: ObservableMaybe<T[]>,
sortable?: Partial<ToObservable<T, boolean>>
sortdir?: Partial<SortDirRecord<T>>
filterable?: Partial<ToObservable<T, boolean>>
showColumns?: Partial<ToObservable<T, boolean>>
filter?: ObservableMaybe<string>
showInput?: ObservableMaybe<boolean>
showColummn?: ObservableMaybe<boolean>
}
const useRecordWheeler = <T, V>(d: Record<keyof T, ObservableMaybe<V>>) => {
const keys = Object.keys(d) as (keyof T)[]
const data = [$(keys.map((key) => ({ text: key, value: key, checked: d[key] })))]
const checked = keys.map(key => d[key])
return {
data, checked, value: [$()],
renderer: [r => r.text],
valuer: [r => r.value],
checkboxer: [r => r.checked],
checkbox: [$(true)],
noMask: true,
hideOnBackdrop: true,
rows: Math.min(6, keys.length),
}
}
const useData = <T,>(props: IData<T>) => {
const sortable: ToObservable<T, boolean> = {} as ToObservable<T, boolean>
const sortdir: SortDirRecord<T> = {} as SortDirRecord<T>
const filterable: ToObservable<T, boolean> = {} as ToObservable<T, boolean>
const showColumns: ToObservable<T, boolean> = {} as ToObservable<T, boolean>
Object.keys(props.data[0]).forEach((key/* : keyof T */) => {
sortable[key] = isObservable(props.sortable?.[key]) ? props.sortable?.[key] : $(props.sortable?.[key] ?? true)
sortdir[key] = isObservable(props.sortdir?.[key]) ? props.sortdir?.[key] : $<SortDir>($$(props.sortdir?.[key]) ?? '')
filterable[key] = isObservable(props.filterable?.[key]) ? props.filterable?.[key] : $(props.filterable?.[key] ?? true)
showColumns[key] = isObservable(props.showColumns?.[key]) ? props.showColumns?.[key] : $(props.showColumns?.[key] ?? true)
})
const sortorder: string[] = []
const { data, filter, showInput, showColummn } = make(Object.assign({ filter: null, showInput: false, showColummn: false }, props))
const sortClick = (col: string) => {
const sd = sortdir[col]
if ($$(sortable[col]))
if ($$(sd) === '') {
sd('asc')
if (sortorder.indexOf(col) == -1)
sortorder.push(col)
}
else if ($$(sd) === 'asc') {
sd('desc')
if (sortorder.indexOf(col) == -1)
sortorder.push(col)
}
else if ($$(sd) === 'desc') {
sd('')
const pos = sortorder.indexOf(col)
sortorder.splice(pos, 1)
}
const keyValueArray = Object.entries(sortdir).map(([key, value]) => ({ key, value }))
const sorts = keyValueArray.filter(k => $$(k.value) !== '')
let fb = ft(db, d => !$$(filter) ? true : Object.keys(d).filter(k => $$(filterable[k])).some(k => d[k]?.toString().toLocaleLowerCase().indexOf($$(filter).toLocaleLowerCase()) >= 0))
const hides = Object.keys(showColumns).filter(k => !$$(showColumns[k])).map(k => k)
if (hides.length)
fb = fb.map(r => omit(r, hides))
if (sorts.length > 0)
data(orderBy(fb, sortorder, sortorder.filter(r => hides.indexOf(r) >= 0).map(k => $$(sortdir[k])) as any) as any)
else
data(fb as any)
}
useEffect(() => {
$$(filter)
sortClick("")
})
return { data, filter, sortable, sortdir, filterable, showInput, showColummn, sortClick, showColumns, }
}
const MainTab = () => {
const inputRef = $<HTMLInputElement>()
const inputCont = $<HTMLInputElement>()
const { filter, showInput, data, sortable, sortdir, filterable, showColummn, sortClick, showColumns } =
useData({ data: db, sortable: { name: true, age: true, country: true }, filterable: { id: false, add: false } })
useClickAway(inputCont, () => showInput(false))
useEffect(() => $$(showInput) && $$(inputRef) && $$(inputRef).focus())
const fi = <FilterIcon class='cursor-pointer inline-block' onClick={() => { showInput(true); $$(filter) }} />
return <>
<ViewColumnIcon class='cursor-pointer inline-block' onClick={() => { showColummn(true) }} />
{() => $$(filter) && $$(filter).length > 0 ? <>{fi}<FilterOffIcon class='cursor-pointer inline-block' onClick={() => { showInput(false); filter(null) }} /></> : fi}
{
() => $$(showInput) ? <div ref={inputCont} class='inline-block'>
<input ref={inputRef} class='border' value={filter} onKeyup={e => { filter(e.target.value); console.log(e.target.value) }} onKeydown={e => { e.keyCode === 13 && (showInput(false), filter($$(changing))) }} />
<span class='bg-[#f8e3fa] border cursor-pointer' onClick={() => { showInput(false); filter(null) }}>✖</span>
<span class='bg-[#f7fae3] border cursor-pointer' onClick={() => { showInput(false) }}>✔</span></div> : null
}
<Wheeler {...useRecordWheeler(showColumns)} open={showColummn} toolbar />
<Table data={data} class='w-1/2'
Th={({ col }) => $$(showColumns[col]) ? <th onClick={() => sortClick(col)}
class={['text-[blue] border-[1px] border-solid border-[lightgray] uppercase',
() => $$(sortable[col]) ? 'cursor-pointer' : '',
// '[&>div]:hidden [&:hover>div]:inline-block'
]}>
{col}
<div class='float-right'>{() => $$(sortable[col]) ?
($$(sortdir[col]) === 'asc') ? <ExpandLessIcon /> : ($$(sortdir[col]) === 'desc') ? <ExpandMoreIcon /> : <SquareIcon /> : null}</div>
</th> : null}
Td={({ col, row, }) => {
const edit = $(false)
const inp = $<HTMLInputElement>()
const ref = $<HTMLTableCellElement>()
useEffect(() => $$(inp)?.focus())
return <td ref={ref} onClick={() => { edit(true); if (!isObservable(row[col])) row[col] = $(row[col]); useEffect(() => console.log($$(row[col]), data)) }}
class={['border-[1px] border-solid border-[lightgray] px-3 hover:bg-[#c4d7f5] w-[150px] p-0', col === 'age' ? 'text-right' : '']}>
{useMemo(() => $$(edit) ? <input style={{ width: () => $$(ref)?.offsetWidth }} class='px-2 m-0' ref={inp} onBlur={() => { edit(false); console.log('blur', $$(edit)) }} onChange={e => row[col](e.target.value)} value={row[col]}>{row[col]}</input> : <span class='px-2'>{row[col]}</span>)}
{/* <input style={{ width: () => $$(ref)?.offsetWidth }} class='p-0 m-0' ref={inp} onBlur={() => { edit(false); console.log('blur', $$(edit)) }} onChange={e => row[group](e.target.value)} value={row[group]}>{row[group]}</input> */}
</td>
}}
Tr={tw('tr')`hover:bg-[#ebf5d5]`}
/>
</>
}
const MainTabHook = () => {
const sortable = {
id: $(false),
name: $(true),
age: $(true),
country: $(true),
}
const sortdir: Record<string, Observable<SortDir>> = {
id: $<SortDir>(''),
name: $<SortDir>(''),
age: $<SortDir>(''),
country: $<SortDir>(''),
}
const filterable = {
name: $(true),
age: $(true),
country: $(true),
}
const sortorder: string[] = []
const data = $(db)
const filter = $<string>(null)
const showInput = $(false)
const showColummn = $(false)
const inputRef = $<HTMLInputElement>()
const inputCont = $<HTMLInputElement>()
const sortClick = (col: string) => {
const sd = sortdir[col]
if ($$(sortable[col]))
if ($$(sd) === '') {
sd('asc')
if (sortorder.indexOf(col) == -1)
sortorder.push(col)
}
else if ($$(sd) === 'asc') {
sd('desc')
if (sortorder.indexOf(col) == -1)
sortorder.push(col)
}
else if ($$(sd) === 'desc') {
sd('')
const pos = sortorder.indexOf(col)
sortorder.splice(pos, 1)
}
const keyValueArray = Object.entries(sortdir).map(([key, value]) => ({ key, value }))
const sorts = keyValueArray.filter(k => $$(k.value) !== '')
const fb = ft(db, d => !$$(filter) ? true : Object.keys(d).filter(k => $$(filterable[k])).some(k => d[k]?.toString().toLocaleLowerCase().indexOf($$(filter).toLocaleLowerCase()) >= 0))
if (sorts.length > 0)
data(orderBy(fb, sortorder, sortorder.map(k => $$(sortdir[k])) as any))
else
data(fb)
}
useClickAway(inputCont, () => showInput(false))
useEffect(() => $$(showInput) && $$(inputRef) && $$(inputRef).focus())
useEffect(() => {
$$(filter)
sortClick("")
})
const fi = <FilterIcon class='cursor-pointer inline-block' onClick={() => { showInput(true); $$(filter) }} />
const { Th, Td, Tr, table } = useTable({ data, class: 'w-1/2' })
Th(({ col, }) => <th onClick={() => sortClick(col)}
class={['text-[blue] border-[1px] border-solid border-[lightgray] uppercase',
() => $$(sortable[col]) ? 'cursor-pointer' : '',
// '[&>div]:hidden [&:hover>div]:inline-block'
]}>
{col}
<div class='float-right'>{() => $$(sortable[col]) ?
($$(sortdir[col]) === 'asc') ? <ExpandLessIcon /> : ($$(sortdir[col]) === 'desc') ? <ExpandMoreIcon /> : <SquareIcon /> : null}</div>
</th>)
Td(({ col, row, }) => {
const edit = $(false)
const inp = $<HTMLInputElement>()
const ref = $<HTMLTableCellElement>()
useEffect(() => $$(inp)?.focus())
return <td ref={ref} onClick={() => { edit(true); if (!isObservable(row[col])) row[col] = $(row[col]); useEffect(() => console.log($$(row[col]), data)) }}
class={['border-[1px] border-solid border-[lightgray] px-3 hover:bg-[#c4d7f5] w-[150px] p-0', col === 'age' ? 'text-right' : '']}>
{useMemo(() => $$(edit) ? <input style={{ width: () => $$(ref)?.offsetWidth }} class='px-2 m-0' ref={inp} onBlur={() => { edit(false); console.log('blur', $$(edit)) }} onChange={e => row[col](e.target.value)} value={row[col]}>{row[col]}</input> : <span class='px-2'>{row[col]}</span>)}
{/* <input style={{ width: () => $$(ref)?.offsetWidth }} class='p-0 m-0' ref={inp} onBlur={() => { edit(false); console.log('blur', $$(edit)) }} onChange={e => row[group](e.target.value)} value={row[group]}>{row[group]}</input> */}
</td>
})
Tr(tw('tr')`hover:bg-[#ebf5d5]`)
return <>
{() => $$(filter) && $$(filter).length > 0 ? <>{fi}<FilterOffIcon class='cursor-pointer inline-block' onClick={() => { showInput(false); filter(null) }} /></> : fi}
{
() => $$(showInput) ? <div ref={inputCont} class='inline-block'>
<input ref={inputRef} class='border' value={filter} onKeyup={e => { filter(e.target.value); console.log(e.target.value) }} onKeydown={e => { e.keyCode === 13 && (showInput(false), filter($$(changing))) }} />
<span class='bg-[#f8e3fa] border cursor-pointer' onClick={() => { showInput(false); filter(null) }}>✖</span>
<span class='bg-[#f7fae3] border cursor-pointer' onClick={() => { showInput(false) }}>✔</span></div> : null
}
<ViewColumnIcon class='cursor-pointer inline-block' onClick={() => { showColummn(true) }} />
{() => $$(showColummn) ? <></> : <></>}
{table}
</>
}
const App = (): JSX.Element => {
return <>
<MainTab />
< br />
{/* <MainTabHook />
< br /> */}
<Table data={groupedData} collapsed class='w-1/2'
Th={({ col, group }) => <th class='text-[blue] border-[1px] border-solid border-[lightgray]'>{col}</th>}
Tr={tw('tr')`hover:bg-[#ebf5d5]`}
Nr={tw('tr')`hover:bg-[#f5d6f0]`}
Gr={tw('tr')`hover:font-bold cursor-pointer`}
Gt={(props) => <Table {...props} class={'w-full'} />}
Gd={({ group, groups, collapse, data }) => { console.log('groups', groups); return <td class='border-[1px] border-solid border-[lightgray]' colSpan={groups.length}><span class={group.length > 1 ? '' : 'pl-2'}>{() => $$(collapse) ? <ExpandMoreIcon /> : <ExpandLessIcon />} {group} {isArray(data) ? <>[{data.length}] Avg: {(sumBy(data, 'age') / data.length).toFixed(0)}</> : ''}</span></td> }}
preprocessor={(d, k) => {
// console.log('preprocess', k)
if (k && k.length === 1 && k[0] === 'USA')
return Array.isArray(d) ? chain(d).groupBy((item) => item.name.charAt(0))
.mapValues((countryData) =>
sortBy(countryData, 'name')
)
.value() : d
return d
}}
/>
<br />
<Table data={nestedGroupData} collapsed class='w-1/2'
Th={({ col, }) => <th class='text-[blue] border-[1px] border-solid border-[lightgray]'>{col}</th>}
Tr={tw('tr')`hover:bg-[#ebf5d5]`}
Nr={tw('tr')`hover:bg-[#f5d6f0]`}
Gr={tw('tr')`hover:font-bold cursor-pointer`}
preprocessor={(d, k) => (console.log('preprocess', k), d)}
/>
</>
}
render(<App />, document.body)
API
License
rc-table is released under the MIT license.