From 17e49c23d879bee4ebf2286ee506ada9b5b4cf7a Mon Sep 17 00:00:00 2001 From: Chris Kruining Date: Tue, 3 Dec 2024 16:01:14 +0100 Subject: [PATCH] started splitting of into components --- src/components/grid.module.css | 128 +++++++++ src/components/grid.tsx | 280 ++++++++++++++++++++ src/components/table.module.css | 108 ++++++++ src/components/table.tsx | 114 ++++++++ src/routes/(editor)/edit.tsx | 4 - src/routes/(editor)/experimental.css | 27 -- src/routes/(editor)/experimental.module.css | 26 ++ src/routes/(editor)/experimental.tsx | 157 +++-------- 8 files changed, 686 insertions(+), 158 deletions(-) create mode 100644 src/components/grid.module.css create mode 100644 src/components/grid.tsx create mode 100644 src/components/table.module.css create mode 100644 src/components/table.tsx delete mode 100644 src/routes/(editor)/experimental.css create mode 100644 src/routes/(editor)/experimental.module.css diff --git a/src/components/grid.module.css b/src/components/grid.module.css new file mode 100644 index 0000000..05c99e3 --- /dev/null +++ b/src/components/grid.module.css @@ -0,0 +1,128 @@ +.table { + position: relative; + display: grid; + grid-template-columns: 2em minmax(10em, max-content) repeat(var(--columns), auto); + align-content: start; + padding-inline: 1px; + margin-inline: -1px; + + block-size: 100%; + overflow: clip auto; + + background-color: var(--surface-600); + + & input[type="checkbox"] { + margin: .1em; + } + + & textarea { + resize: vertical; + min-block-size: max(2em, 100%); + max-block-size: 50em; + + background-color: var(--surface-600); + color: var(--text-1); + border-color: var(--text-2); + border-radius: var(--radii-s); + + &:has(::spelling-error, ::grammar-error) { + border-color: var(--fail); + } + + & ::spelling-error { + outline: 1px solid var(--fail); + text-decoration: yellow underline; + } + } + + & .cell { + display: grid; + padding: .5em; + border: 1px solid transparent; + border-radius: var(--radii-m); + + &:has(textarea:focus) { + border-color: var(--info); + } + + & > span { + align-self: center; + } + } + + & :is(.header, .main, .footer) { + grid-column: span calc(2 + var(--columns)); + display: grid; + grid-template-columns: subgrid; + } + + & .header { + position: sticky; + inset-block-start: 0; + background-color: var(--surface-600); + border-block-end: 1px solid var(--surface-300); + } + + & .row { + --bg: var(--text); + --alpha: 0; + grid-column: span calc(2 + var(--columns)); + display: grid; + grid-template-columns: subgrid; + border: 1px solid transparent; + background-color: color(from var(--bg) srgb r g b / var(--alpha)); + + &:has(> .cell > :checked) { + --bg: var(--info); + --alpha: .1; + border-color: var(--bg); + + & span { + font-variation-settings: 'GRAD' 1000; + } + + & + :has(> .cell> :checked) { + border-block-start-color: transparent; + } + + &:has(+ .row > .cell > :checked) { + border-block-end-color: transparent; + } + } + + &:hover { + --alpha: .2 !important; + } + } + + & details { + display: contents; + + &::details-content { + grid-column: span calc(2 + var(--columns)); + display: grid; + grid-template-columns: subgrid; + } + + &:not([open])::details-content { + display: none; + } + + & > summary { + grid-column: 2 / span calc(1 + var(--columns)); + padding: .5em; + padding-inline-start: calc(var(--depth) * 1em + .5em); + + } + + & > .row > .cell > span { + padding-inline-start: calc(var(--depth) * 1em); + } + } +} + +@property --depth { + syntax: ""; + inherits: false; + initial-value: 0; +} \ No newline at end of file diff --git a/src/components/grid.tsx b/src/components/grid.tsx new file mode 100644 index 0000000..c0e0c2a --- /dev/null +++ b/src/components/grid.tsx @@ -0,0 +1,280 @@ +import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, ParentComponent, Show, useContext } from "solid-js"; +import { createStore, produce, unwrap } from "solid-js/store"; +import { SelectionProvider, useSelection, selectable } from "../features/selectable"; +import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities"; +import css from './grid.module.css'; + +selectable // prevents removal of import + +type Rows = Map>; +type SelectionItem = { key: string, value: Accessor>, element: WeakRef }; + +type Insertion = { kind: 'row', key: string } | { kind: 'column', value: string }; + +export interface GridContextType { + readonly rows: Accessor>>; + readonly mutations: Accessor; + readonly selection: Accessor; + mutate(prop: string, lang: string, value: string): void; + remove(props: string[]): void; + insert(insertion: Insertion): void; +} + +export interface GridApi { + readonly selection: Accessor>>; + readonly rows: Accessor>>; + readonly mutations: Accessor; + selectAll(): void; + clear(): void; + remove(keys: string[]): void; + insert(insertion: Insertion): void; +} + +const GridContext = createContext(); + +const useGrid = () => useContext(GridContext)!; + +export const Grid: Component<{ class?: string, columns: string[], rows: Rows, api?: (api: GridApi) => any }> = (props) => { + const [selection, setSelection] = createSignal([]); + const [state, setState] = createStore<{ rows: Record>, columns: string[], snapshot: Rows, numberOfRows: number }>({ + rows: {}, + columns: [], + snapshot: new Map, + numberOfRows: 0, + }); + + const mutations = createMemo(() => { + // enumerate all values to make sure the memo is recalculated on any change + Object.values(state.rows).map(entry => Object.values(entry)); + + return deepDiff(state.snapshot, state.rows).toArray(); + }); + const rows = createMemo(() => Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, unwrap(row)] as const))); + const columns = createMemo(() => state.columns); + + createEffect(() => { + setState('rows', Object.fromEntries(deepCopy(props.rows).entries())); + setState('snapshot', props.rows); + }); + + createEffect(() => { + setState('columns', [...props.columns]); + }); + + createEffect(() => { + setState('numberOfRows', Object.keys(state.rows).length); + }); + + const ctx: GridContextType = { + rows, + mutations, + selection, + + mutate(prop: string, lang: string, value: string) { + setState('rows', prop, lang, value); + }, + + remove(props: string[]) { + setState('rows', produce(rows => { + for (const prop of props) { + delete rows[prop]; + } + + return rows; + })); + }, + + insert(prop: string) { + setState('rows', produce(rows => { + rows[prop] = Object.fromEntries(state.columns.slice(1).map(lang => [lang, ''])); + + return rows + })) + }, + + addColumn(name: string): void { + setState(produce(state => { + state.columns.push(name); + state.rows = Object.fromEntries(Object.entries(state.rows).map(([key, row]) => [key, { ...row, [name]: '' }])); + + return state; + })) + }, + }; + + return + + + + <_Grid class={props.class} columns={columns()} rows={rows()} /> + + ; +}; + +const _Grid: Component<{ class?: string, columns: string[], rows: Record> }> = (props) => { + const columnCount = createMemo(() => props.columns.length - 1); + const root = createMemo(() => Object.entries(props.rows) + .reduce((aggregate, [key, value]) => { + let obj: any = aggregate; + const parts = key.split('.'); + + for (const [i, part] of parts.entries()) { + if (Object.hasOwn(obj, part) === false) { + obj[part] = {}; + } + + if (i === (parts.length - 1)) { + obj[part] = value; + } + else { + obj = obj[part]; + } + } + + return aggregate; + }, {})); + + return
+ + +
+ +
+
+}; + +const Api: Component<{ api: undefined | ((api: GridApi) => any) }> = (props) => { + const gridContext = useGrid(); + const selectionContext = useSelection<{ key: string, value: Accessor>, element: WeakRef }>(); + + const api: GridApi = { + selection: createMemo(() => { + const selection = selectionContext.selection(); + + return Object.fromEntries(selection.map(({ key, value }) => [key, value()] as const)); + }), + rows: gridContext.rows, + mutations: gridContext.mutations, + selectAll() { + selectionContext.selectAll(); + }, + clear() { + selectionContext.clear(); + }, + remove(props: string[]) { + gridContext.remove(props); + }, + insert(prop: string) { + gridContext.insert(prop); + }, + + addColumn(name: string): void { + gridContext.addColumn(name); + }, + }; + + createEffect(() => { + props.api?.(api); + }); + + return null; +}; + +const Head: Component<{ headers: string[] }> = (props) => { + const context = useSelection(); + + return
+
+ 0 && context.selection().length === context.length()} + indeterminate={context.selection().length !== 0 && context.selection().length !== context.length()} + on:input={(e: InputEvent) => e.target.checked ? context.selectAll() : context.clear()} + /> +
+ + { + header => {header} + } +
; +}; + +const Row: Component<{ entry: Entry, path?: string[] }> = (props) => { + const grid = useGrid(); + + return { + ([key, value]) => { + const values = Object.entries(value); + const path = [...(props.path ?? []), key]; + const k = path.join('.'); + const context = useSelection(); + + const isSelected = context.isSelected(k); + + return }> +
+
+ context.select([k])} on:pointerdown={e => e.stopPropagation()} /> +
+ +
+ {key} +
+ + { + ([lang, value]) =>
+