diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml index 98d7088..047f085 100644 --- a/.github/workflows/app.yml +++ b/.github/workflows/app.yml @@ -31,12 +31,12 @@ jobs: with: fetch-depth: 0 - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v3.1.1 + uses: gittools/actions/gitversion/setup@v3.0.3 with: versionSpec: "5.x" - name: Determine Version id: gitversion - uses: gittools/actions/gitversion/execute@v3.1.1 + uses: gittools/actions/gitversion/execute@v3.0.3 with: useConfigFile: true diff --git a/app.config.ts b/app.config.ts index 01b146e..29550a6 100644 --- a/app.config.ts +++ b/app.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ html: { cspNonce: 'KAAS_IS_AWESOME', }, + // css: { + // postcss: { + // }, + // }, plugins: [ solidSvg() // VitePWA({ diff --git a/bun.lockb b/bun.lockb index 9b87d97..fd43379 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/bunfig.toml b/bunfig.toml index 67811e5..08c06e2 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,6 @@ [test] coverage = true +coverageSkipTestFiles = true coverageReporter = ['text', 'lcov'] coverageDir = './.coverage' preload = "./test.config.ts" diff --git a/examples/emmer/en.json b/examples/emmer/en-GB.json similarity index 100% rename from examples/emmer/en.json rename to examples/emmer/en-GB.json diff --git a/examples/emmer/namespace/en.json b/examples/emmer/namespace/en-GB.json similarity index 100% rename from examples/emmer/namespace/en.json rename to examples/emmer/namespace/en-GB.json diff --git a/examples/emmer/nl.json b/examples/emmer/nl-NL.json similarity index 100% rename from examples/emmer/nl.json rename to examples/emmer/nl-NL.json diff --git a/package.json b/package.json index 6f0ea11..192e6ce 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "calque", "dependencies": { "@solidjs/meta": "^0.29.4", - "@solidjs/router": "^0.15.1", + "@solidjs/router": "^0.15.2", "@solidjs/start": "^1.0.10", "dexie": "^4.0.10", "iterator-helpers-polyfill": "^3.0.1", "sitemap": "^8.0.0", "solid-icons": "^1.1.0", "solid-js": "^1.9.3", - "ts-pattern": "^5.5.0", + "ts-pattern": "^5.6.0", "vinxi": "^0.4.3" }, "engines": { diff --git a/src/app.css b/src/app.css index d8311b9..32f54b5 100644 --- a/src/app.css +++ b/src/app.css @@ -22,18 +22,19 @@ --surface-600: oklch(from var(--surface-500) calc(l + .025) c h); --surface-700: oklch(from var(--surface-600) calc(l + .025) c h); - --text-1: light-dark(oklch(from var(--primary-500) .2 .02 h), oklch(from var(--primary-500) .9 .02 h)); - --text-2: oklch(from var(--text-1) calc(l + .1) c h); - --info: light-dark(oklch(.71 .17 249), oklch(.71 .17 249)); --fail: light-dark(oklch(.64 .21 25.3), oklch(.64 .21 25.3)); --warn: light-dark(oklch(.82 .18 78.9), oklch(.82 .18 78.9)); --succ: light-dark(oklch(.86 .28 150), oklch(.86 .28 150)); - --radii-s: .125em; - --radii-m: .25em; - --radii-l: .5em; - --radii-xl: 1em; + --text-1: light-dark(oklch(from var(--primary-500) .2 .02 h), oklch(from var(--primary-500) .9 .02 h)); + --text-2: oklch(from var(--text-1) calc(l + .1) c h); + + --text-lighter: 100; + --text-light: 300; + --text-normal: 500; + --text-bold: 700; + --text-bolder: 900; --text-s: .8rem; --text-m: 1rem; @@ -41,6 +42,12 @@ --text-xl: 1.6rem; --text-xxl: 2rem; + --radii-s: .125em; + --radii-m: .25em; + --radii-l: .5em; + --radii-xl: 1em; + + --padding-xs: .125em; --padding-s: .25em; --padding-m: .5em; --padding-l: .75em; @@ -144,6 +151,36 @@ code { border-radius: var(--radii-m); } +ins { + background-color: oklch(from var(--succ) l c h / .1); + color: oklch(from var(--succ) .1 .2 h); +} + +del { + background-color: oklch(from var(--fail) l c h / .1); + color: oklch(from var(--fail) .1 .2 h); +} + +kbd { + background-color: var(--surface-600); + border-radius: var(--radii-m); + border: 1px solid var(--surface-500); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.2), + 0 2px 0 0 rgba(255, 255, 255, 0.7) inset; + color: var(--text-2); + display: inline-block; + font-size: var(--text-s); + font-weight: var(--text-bold); + line-height: 1; + padding: var(--padding-xs) var(--padding-s); + white-space: nowrap; +} + +samp { + display: inline-block; +} + @property --hue { syntax: ''; inherits: false; diff --git a/src/components/colorschemepicker.module.css b/src/components/colorschemepicker.module.css index b586f43..af89340 100644 --- a/src/components/colorschemepicker.module.css +++ b/src/components/colorschemepicker.module.css @@ -8,6 +8,7 @@ padding: var(--padding-s); & select { + flex: 1 1 auto; border: none; background-color: inherit; border-radius: var(--radii-m); diff --git a/src/components/filetree.tsx b/src/components/filetree.tsx index 34ec976..9515cd1 100644 --- a/src/components/filetree.tsx +++ b/src/components/filetree.tsx @@ -71,14 +71,14 @@ export const Tree: Component<{ entries: Entry[], children: readonly [(folder: Ac const _Tree: Component<{ entries: Entry[], children: readonly [(folder: Accessor) => JSX.Element, (file: Accessor) => JSX.Element] }> = (props) => { const context = useContext(TreeContext); - return { + return { entry => <> { folder => } { - file => context?.open(file().meta)}> {props.children[1](file)} + file => context?.open(file().meta)}> {props.children[1](file)} } } diff --git a/src/components/grid/grid.module.css b/src/components/grid/grid.module.css new file mode 100644 index 0000000..e69de29 diff --git a/src/components/grid/grid.tsx b/src/components/grid/grid.tsx new file mode 100644 index 0000000..ad3f036 --- /dev/null +++ b/src/components/grid/grid.tsx @@ -0,0 +1,124 @@ +import { Accessor, createContext, createEffect, createMemo, createSignal, JSX, useContext } from "solid-js"; +import { Mutation } from "~/utilities"; +import { SelectionMode, Table, Column as TableColumn, TableApi, DataSet, CellRenderer as TableCellRenderer } from "~/components/table"; +import css from './grid.module.css'; + +export interface CellRenderer, K extends keyof T> { + (cell: Parameters>[0] & { mutate: (next: T[K]) => any }): JSX.Element; +} + +export interface Column> extends Omit, 'renderer'> { + renderer?: CellRenderer; +} + +export interface GridApi> extends TableApi { + readonly mutations: Accessor; + remove(keys: number[]): void; + insert(row: T, at?: number): void; + addColumn(column: keyof T): void; +} + +interface GridContextType> { + readonly mutations: Accessor; + readonly selection: TableApi['selection']; + mutate(row: number, column: K, value: T[K]): void; + remove(rows: number[]): void; + insert(row: T, at?: number): void; + addColumn(column: keyof T, value: T[keyof T]): void; +} + +const GridContext = createContext>(); + +const useGrid = () => useContext(GridContext)!; + +type GridProps> = { class?: string, groupBy?: keyof T, columns: Column[], rows: DataSet, api?: (api: GridApi) => any }; + +export function Grid>(props: GridProps) { + const [table, setTable] = createSignal>(); + + const rows = createMemo(() => props.rows); + const columns = createMemo(() => props.columns as TableColumn[]); + const mutations = createMemo(() => rows().mutations()); + + const ctx: GridContextType = { + mutations, + selection: createMemo(() => table()?.selection() ?? []), + + mutate(row: number, column: K, value: T[K]) { + rows().mutate(row, column, value); + }, + + remove(indices: number[]) { + rows().remove(indices); + table()?.clear(); + }, + + insert(row: T, at?: number) { + rows().insert(row, at); + }, + + addColumn(column: keyof T, value: T[keyof T]): void { + // setState('rows', { from: 0, to: state.rows.length - 1 }, column as any, value); + }, + }; + + const cellRenderers = createMemo(() => Object.fromEntries( + props.columns + .filter(c => c.renderer !== undefined) + .map(c => { + const Editor: CellRenderer = ({ row, column, value }) => { + const mutate = (next: T[keyof T]) => { + ctx.mutate(row, column, next); + }; + + return c.renderer!({ row, column, value, mutate }); + }; + + return [c.id, Editor] as const; + }) + ) as any); + + return + + + { + cellRenderers() + }
+
; +}; + +function Api>(props: { api: undefined | ((api: GridApi) => any), table?: TableApi }) { + const gridContext = useGrid(); + + const api = createMemo | undefined>(() => { + const table = props.table; + + if (!table) { + return; + } + + return { + ...table, + mutations: gridContext.mutations, + remove(rows: number[]) { + gridContext.remove(rows); + }, + insert(row: T, at?: number) { + gridContext.insert(row, at); + }, + addColumn(column: keyof T): void { + // gridContext.addColumn(column, value); + }, + }; + }); + + createEffect(() => { + const value = api(); + + if (value) { + props.api?.(value); + } + }); + + return null; +}; \ No newline at end of file diff --git a/src/components/grid/index.tsx b/src/components/grid/index.tsx new file mode 100644 index 0000000..1783f2c --- /dev/null +++ b/src/components/grid/index.tsx @@ -0,0 +1,4 @@ + +export type { DataSetRowNode, DataSetGroupNode, DataSetNode, SelectionMode, SortingFunction, SortOptions, GroupingFunction, GroupOptions } from '../table'; +export type { GridApi, Column, CellRenderer as CellEditor } from './grid'; +export { Grid } from './grid'; \ No newline at end of file diff --git a/src/components/prompt.tsx b/src/components/prompt.tsx index 943c738..9cf04e2 100644 --- a/src/components/prompt.tsx +++ b/src/components/prompt.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, createUniqueId, JSX, onMount, ParentComponent, Show } from "solid-js"; +import { createEffect, createSignal, JSX, ParentComponent, Show } from "solid-js"; import css from './prompt.module.css'; export interface PromptApi { @@ -72,4 +72,7 @@ export const Prompt: ParentComponent<{ api: (api: PromptApi) => any, title?: str ; -}; \ No newline at end of file +}; + +let idCounter = 0; +const createUniqueId = () => `prompt-${idCounter++}`; \ No newline at end of file diff --git a/src/components/table/dataset.spec.ts b/src/components/table/dataset.spec.ts new file mode 100644 index 0000000..e2fade5 --- /dev/null +++ b/src/components/table/dataset.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "bun:test"; +import { createDataSet } from "./dataset"; + +interface DataEntry { + id: string; + name: string; + amount: number; +}; +const defaultData: DataEntry[] = [ + { id: '1', name: 'a first name', amount: 30 }, + { id: '2', name: 'a second name', amount: 20 }, + { id: '3', name: 'a third name', amount: 10 }, +]; + +describe('dataset', () => { + describe('createDataset', () => { + it('can create an instance', async () => { + // Arrange + + // Act + const actual = createDataSet(defaultData); + + // Assert + expect(actual).toMatchObject({ data: defaultData }) + }); + + it('can sort by a property', async () => { + // Arrange + + // Act + const actual = createDataSet(defaultData, { sort: { by: 'amount', reversed: false } }); + + // Assert + expect(actual.nodes()).toEqual([ + expect.objectContaining({ key: 2 }), + expect.objectContaining({ key: 1 }), + expect.objectContaining({ key: 0 }), + ]) + }); + + it('can group by a property', async () => { + // Arrange + + // Act + const actual = createDataSet(defaultData, { group: { by: 'name' } }); + + // Assert + expect(actual).toEqual(expect.objectContaining({ data: defaultData })) + }); + + describe('mutate', () => { + it('mutates the value', async () => { + // Arrange + const dataset = createDataSet(defaultData); + + // Act + dataset.mutate(0, 'amount', 100); + + // Assert + expect(dataset.value[0]!.amount).toBe(100); + }); + }); + + describe('mutateEach', () => { + it('mutates all the entries', async () => { + // Arrange + const dataset = createDataSet(defaultData); + + // Act + dataset.mutateEach(entry => ({ ...entry, amount: entry.amount + 5 })); + + // Assert + expect(dataset.value).toEqual([ + expect.objectContaining({ amount: 35 }), + expect.objectContaining({ amount: 25 }), + expect.objectContaining({ amount: 15 }), + ]); + }); + }); + + describe('remove', () => { + it('removes the 2nd entry', async () => { + // Arrange + const dataset = createDataSet(defaultData); + + // Act + dataset.remove([1]); + + // Assert + expect(dataset.value[1]).toBeUndefined(); + }); + }); + + describe('insert', () => { + it('adds an entry to the dataset', async () => { + // Arrange + const dataset = createDataSet(defaultData); + + // Act + dataset.insert({ id: '4', name: 'name', amount: 100 }); + + // Assert + expect(dataset.value[3]).toEqual({ id: '4', name: 'name', amount: 100 }); + }); + }); + + describe('sort', () => { + it('can set the sorting', async () => { + // Arrange + const dataset = createDataSet(defaultData); + + // Act + dataset.sort({ by: 'id', reversed: true }); + + // Assert + expect(dataset.sorting).toEqual({ by: 'id', reversed: true }); + }); + }); + + describe('group', () => { + it('can set the grouping', async () => { + // Arrange + const dataset = createDataSet(defaultData); + + // Act + dataset.group({ by: 'id' }); + + // Assert + expect(dataset.grouping).toEqual({ by: 'id' }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/components/table/dataset.ts b/src/components/table/dataset.ts new file mode 100644 index 0000000..86082b6 --- /dev/null +++ b/src/components/table/dataset.ts @@ -0,0 +1,155 @@ +import { Accessor, createEffect, createMemo } from "solid-js"; +import { createStore, NotWrappable, produce, StoreSetter, unwrap } from "solid-js/store"; +import { CustomPartial } from "solid-js/store/types/store.js"; +import { deepCopy, deepDiff, Mutation } from "~/utilities"; + + +export type DataSetRowNode = { kind: 'row', key: K, value: T } +export type DataSetGroupNode = { kind: 'group', key: K, groupedBy: keyof T, nodes: DataSetNode[] }; +export type DataSetNode = DataSetRowNode | DataSetGroupNode; + +export interface SortingFunction { + (a: T, b: T): -1 | 0 | 1; +} +export interface SortOptions> { + by: keyof T; + reversed: boolean; + with?: SortingFunction; +} + +export interface GroupingFunction { + (nodes: DataSetRowNode[]): DataSetNode[]; +} +export interface GroupOptions> { + by: keyof T; + with?: GroupingFunction; +} +interface DataSetState> { + value: (T | undefined)[]; + snapshot: (T | undefined)[]; + sorting?: SortOptions; + grouping?: GroupOptions; +} + +export type Setter = + | T + | CustomPartial + | ((prevState: T) => T | CustomPartial); + +export interface DataSet> { + data: T[]; + nodes: Accessor[]>; + mutations: Accessor; + readonly value: (T | undefined)[]; + readonly sorting: SortOptions | undefined; + readonly grouping: GroupOptions | undefined; + + mutate(index: number, prop: K, value: T[K]): void; + mutateEach(setter: (value: T) => T): void; + remove(indices: number[]): void; + insert(item: T, at?: number): void; + + sort(options: Setter | undefined>): DataSet; + group(options: Setter | undefined>): DataSet; +} + +const defaultComparer = (a: T, b: T) => a < b ? -1 : a > b ? 1 : 0; +function defaultGroupingFunction(groupBy: keyof T): GroupingFunction { + return (nodes: DataSetRowNode[]): DataSetNode[] => Object.entries(Object.groupBy(nodes, r => r.value[groupBy] as PropertyKey)) + .map(([key, nodes]) => ({ kind: 'group', key, groupedBy: groupBy, nodes: nodes! } as DataSetGroupNode)); +} + +export const createDataSet = >(data: T[], initialOptions?: { sort?: SortOptions, group?: GroupOptions }): DataSet => { + const [state, setState] = createStore>({ + value: deepCopy(data), + snapshot: data, + sorting: initialOptions?.sort, + grouping: initialOptions?.group, + }); + + const nodes = createMemo(() => { + const sorting = state.sorting; + const grouping = state.grouping; + + let value: DataSetNode[] = state.value + .map | undefined>((value, key) => value === undefined ? undefined : ({ kind: 'row', key, value })) + .filter(node => node !== undefined); + + if (sorting) { + const comparer = sorting.with ?? defaultComparer; + + value = value.filter(entry => entry.kind === 'row').toSorted((a, b) => comparer(a.value[sorting.by], b.value[sorting.by])); + + if (sorting.reversed) { + value.reverse(); + } + } + + if (grouping) { + const implementation = grouping.with ?? defaultGroupingFunction(grouping.by); + + value = implementation(value as DataSetRowNode[]); + } + + return value as DataSetNode[]; + }); + + const mutations = createMemo(() => { + // enumerate all values to make sure the memo is recalculated on any change + Object.values(state.value).map(entry => Object.values(entry ?? {})); + + return deepDiff(state.snapshot, state.value).toArray(); + }); + + const sorting = createMemo(() => state.sorting); + const grouping = createMemo(() => state.grouping); + + const set: DataSet = { + data, + nodes, + get value() { + return state.value; + }, + mutations, + get sorting() { + return state.sorting; + }, + get grouping() { + return state.grouping; + }, + + mutate(index, prop, value) { + setState('value', index, prop as any, value); + }, + + mutateEach(setter) { + setState('value', value => value.map(i => i === undefined ? undefined : setter(i))); + }, + + remove(indices) { + setState('value', value => value.map((item, i) => indices.includes(i) ? undefined : item)); + }, + + insert(item, at) { + if (at === undefined) { + setState('value', state.value.length, item); + } else { + + } + }, + + sort(options) { + setState('sorting', options); + + return set; + }, + + group(options) { + setState('grouping', options) + + return set; + }, + }; + + return set +}; \ No newline at end of file diff --git a/src/components/table/index.tsx b/src/components/table/index.tsx new file mode 100644 index 0000000..b4aabe5 --- /dev/null +++ b/src/components/table/index.tsx @@ -0,0 +1,5 @@ + +export type { Column, TableApi, CellRenderer, CellRenderers } from './table'; +export type { DataSet, DataSetGroupNode, DataSetRowNode, DataSetNode, SortingFunction, SortOptions, GroupingFunction, GroupOptions } from './dataset'; +export { SelectionMode, Table } from './table'; +export { createDataSet } from './dataset'; \ No newline at end of file diff --git a/src/components/table/table.module.css b/src/components/table/table.module.css new file mode 100644 index 0000000..16b2019 --- /dev/null +++ b/src/components/table/table.module.css @@ -0,0 +1,241 @@ +@property --depth { + syntax: ""; + inherits: true; + initial-value: 0; +} + +.table { + --shadow-color: oklch(0 0 0 / .05); + --shadow: var(--shadow-color) 0 0 2em; + + position: relative; + display: block grid; + grid-template-columns: repeat(var(--columns), minmax(max-content, auto)); + align-content: start; + block-size: 100%; + padding-inline: 1px; + margin-inline: -1px; + overflow: auto; + background-color: inherit; + isolation: isolate; + + & .cell { + display: block grid; + align-items: center; + padding: var(--padding-m); + border: 1px solid transparent; + border-radius: var(--radii-m); + background: inherit; + white-space: nowrap; + } + + & :is(.cell:first-child, .checkbox + .cell) { + position: sticky; + inset-inline-start: 1px; + padding-inline-start: calc(var(--depth, 0) * (1em + var(--padding-s)) + var(--padding-m)); + z-index: 1; + + &::after { + content: ''; + position: absolute; + inset-inline-start: 100%; + inset-block-start: -2px; + display: block; + inline-size: 2em; + block-size: calc(3px + 100%); + animation: column-scroll-shadow linear both; + animation-timeline: scroll(inline); + animation-range: 0 2em; + pointer-events: none; + } + } + + & .checkbox { + display: grid; + place-items: center; + position: sticky; + inset-inline-start: 1px; + background: inherit; + padding: var(--padding-m); + z-index: 1; + } + + & .caption { + position: sticky; + inset-inline-start: 0; + } + + & :is(.header, .main, .footer) { + grid-column: 1 / -1; + display: block grid; + grid-template-columns: subgrid; + background-color: inherit; + } + + & .row { + --alpha: 0; + grid-column: 1 / -1; + display: block grid; + grid-template-columns: subgrid; + border: 1px solid transparent; + background-color: inherit; + background-image: linear-gradient(0deg, oklch(from var(--info) l c h / var(--alpha)), oklch(from var(--info) l c h / var(--alpha))); + + &:has(> .checkbox > :checked) { + --alpha: .1; + border-color: var(--info); + + & span { + font-variation-settings: 'GRAD' 1000; + } + + & + :has(> .checkbox > :checked) { + border-block-start-color: transparent; + } + + &:has(+ .row > .checkbox > :checked) { + border-block-end-color: transparent; + } + } + + &:hover { + --alpha: .2 !important; + } + } + + & .header { + position: sticky; + inset-block-start: 0; + border-block-end: 1px solid var(--surface-300); + z-index: 2; + animation: header-scroll-shadow linear both; + animation-timeline: scroll(); + animation-range: 0 2em; + font-weight: var(--text-bold); + + & > tr { + all: inherit; + display: contents; + + & > .cell { + grid-auto-flow: column; + justify-content: space-between; + + & > svg { + transition: opacity .15s ease-in-out; + } + + &:not(.sorted):not(:hover) > svg { + opacity: 0; + } + } + } + } + + & .main { + background-color: inherit; + } + + & .footer { + position: sticky; + inset-block-end: 0; + border-block-start: 1px solid var(--surface-300); + z-index: 2; + animation: header-scroll-shadow linear both reverse; + animation-timeline: scroll(); + animation-range: calc(100% - 2em) 100%; + font-weight: var(--text-bold); + } + + & .group { + display: contents; + background-color: inherit; + + & > td { + display: contents; + background-color: inherit; + + & > table { + grid-column: 1 / -1; + grid-template-columns: subgrid; + background-color: inherit; + overflow: visible; + + & > .header { + border-block-end-color: transparent; + animation: none; + + & .cell { + justify-content: start; + column-gap: var(--padding-s); + + & > label { + --state: 0; + display: contents; + cursor: pointer; + + & input[type="checkbox"] { + display: none; + } + + & > svg { + rotate: calc(var(--state) * -.25turn); + transition: rotate .3s ease-in-out; + inline-size: 1em; + aspect-ratio: 1; + opacity: 1 !important; + } + + &:has(input:not(:checked)) { + --state: 1; + } + } + } + } + + & > .main { + block-size: calc-size(auto, size); + transition: block-size .3s ease-in-out; + overflow: clip; + } + + &:has(> .header input:not(:checked)) > .main { + block-size: 0; + } + } + } + } + + &.selectable { + grid-template-columns: 2em repeat(var(--columns), minmax(max-content, auto)); + + & :is(.cell:first-child, .checkbox + .cell) { + inset-inline-start: 2em; + } + + & details > summary { + inset-inline-start: 2em; + grid-column: 2; + } + } +} + +@keyframes header-scroll-shadow { + from { + box-shadow: none; + } + + to { + box-shadow: var(--shadow); + } +} + +@keyframes column-scroll-shadow { + from { + background: linear-gradient(90deg, transparent, transparent); + } + + to { + background: linear-gradient(90deg, var(--shadow-color), transparent); + } +} \ No newline at end of file diff --git a/src/components/table/table.spec.tsx b/src/components/table/table.spec.tsx new file mode 100644 index 0000000..24fe041 --- /dev/null +++ b/src/components/table/table.spec.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'bun:test'; +import { render } from "@solidjs/testing-library" +import { Table } from './table'; +import { createDataSet } from './dataset'; + +type TableItem = {}; + +// describe('', () => { +// it('should render', async () => { +// const dataset = createDataSet([]); +// const result = render(() =>
); + +// expect(true).toBe(true); +// }); +// }); \ No newline at end of file diff --git a/src/components/table/table.tsx b/src/components/table/table.tsx new file mode 100644 index 0000000..4a35881 --- /dev/null +++ b/src/components/table/table.tsx @@ -0,0 +1,274 @@ +import { Accessor, createContext, createEffect, createMemo, createSignal, For, JSX, Match, Show, Switch, useContext } from "solid-js"; +import { selectable, SelectionItem, SelectionProvider, useSelection } from "~/features/selectable"; +import { DataSetRowNode, DataSetNode, DataSet } from './dataset'; +import { FaSolidAngleDown, FaSolidSort, FaSolidSortDown, FaSolidSortUp } from "solid-icons/fa"; +import css from './table.module.css'; + +selectable; + +export type CellRenderer, K extends keyof T> = (cell: { row: number, column: K, value: T[K] }) => JSX.Element; +export type CellRenderers> = { [K in keyof T]?: CellRenderer }; + +export interface Column> { + id: keyof T, + label: string, + sortable?: boolean, + group?: string, + renderer?: CellRenderer, + readonly groupBy?: (rows: DataSetRowNode[]) => DataSetNode[], +}; + +export interface TableApi> { + readonly selection: Accessor[]>; + readonly rows: Accessor>; + readonly columns: Accessor[]>; + selectAll(): void; + clear(): void; +} + +interface TableContextType> { + readonly rows: Accessor>, + readonly columns: Accessor[]>, + readonly selection: Accessor[]>, + readonly selectionMode: Accessor, + readonly cellRenderers: Accessor>, +} + +const TableContext = createContext>(); + +const useTable = >() => useContext(TableContext)! as TableContextType + +export enum SelectionMode { + None, + Single, + Multiple +} +type TableProps> = { + class?: string, + summary?: string, + rows: DataSet, + columns: Column[], + selectionMode?: SelectionMode, + children?: CellRenderers, + api?: (api: TableApi) => any, +}; + +export function Table>(props: TableProps) { + const [selection, setSelection] = createSignal[]>([]); + + const rows = createMemo(() => props.rows); + const columns = createMemo[]>(() => props.columns ?? []); + const selectionMode = createMemo(() => props.selectionMode ?? SelectionMode.None); + const cellRenderers = createMemo>(() => props.children ?? {}); + + const context: TableContextType = { + rows, + columns, + selection, + selectionMode, + cellRenderers, + }; + + return + + + + + + ; +}; + +type InnerTableProps> = { class?: string, summary?: string, rows: DataSet }; + +function InnerTable>(props: InnerTableProps) { + const table = useTable(); + + const selectable = createMemo(() => table.selectionMode() !== SelectionMode.None); + const columnCount = createMemo(() => table.columns().length); + + return
+ {/* 0 ? props.summary : undefined}>{ + summary => { + return ; + } + } */} + + + + + + []}>{ + node => + } + + + {/* + + + + + + */} +
{summary()}
FOOTER
+}; + +function Api>(props: { api: undefined | ((api: TableApi) => any) }) { + const table = useTable(); + const selectionContext = useSelection(); + + const api: TableApi = { + selection: selectionContext.selection, + rows: table.rows, + columns: table.columns, + selectAll() { + selectionContext.selectAll(); + }, + clear() { + selectionContext.clear(); + }, + }; + + createEffect(() => { + props.api?.(api); + }); + + return null; +}; + +function Groups(props: {}) { + const table = useTable(); + + const groups = createMemo(() => { + return new Set(table.columns().map(c => c.group).filter(g => g !== undefined)).values().toArray(); + }); + + return { + group => + } +} + +function Head(props: {}) { + const table = useTable(); + 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()} + /> + + + + { + ({ id, label, sortable }) => { + const sort = createMemo(() => table.rows().sorting); + const by = String(id); + + const onPointerDown = (e: PointerEvent) => { + if (sortable !== true) { + return; + } + + table.rows().sort(current => { + if (current?.by !== by) { + return { by, reversed: false }; + } + + if (current.reversed === true) { + return undefined; + } + + return { by, reversed: true }; + }); + }; + + return + {label} + + + + + + + ; + } + } + + ; +}; + +function Node>(props: { node: DataSetNode, depth: number, groupedBy?: keyof T }) { + return + { + row => + } + + { + group => + } + ; +} + +function Row>(props: { key: K, value: T, depth: number, groupedBy?: keyof T }) { + const table = useTable(); + const context = useSelection(); + const columns = table.columns; + + const isSelected = context.isSelected(props.key); + + return + + + context.select([props.key])} on:pointerdown={e => e.stopPropagation()} /> + + + + { + ({ id }) => { + const content = table.cellRenderers()[id]?.({ row: props.key as number, column: id, value: props.value[id] }) ?? props.value[id]; + + // return <>{content}; + return {content}; + } + } + ; +}; + +function Group>(props: { key: K, groupedBy: keyof T, nodes: DataSetNode[], depth: number }) { + const table = useTable(); + + return + + + + + + + + { + node => + } + +
+ +
+ + ; +}; + +declare module "solid-js" { + namespace JSX { + interface HTMLAttributes { + indeterminate?: boolean | undefined; + } + } +} \ No newline at end of file diff --git a/src/components/tabs.module.css b/src/components/tabs.module.css index 396b463..7c97ab8 100644 --- a/src/components/tabs.module.css +++ b/src/components/tabs.module.css @@ -61,44 +61,9 @@ } .tab { - position: absolute; - grid-area: 2 / 1 / span 1 / span 1; - inline-size: 100%; - block-size: 100%; - - &:not(.active) { - display: none; - } - - & > summary { - grid-row: 1 / 1; - - padding: var(--padding-s) var(--padding-m); - - &::marker { - content: none; - } - } - - &::details-content { - grid-area: 2 / 1 / span 1 / span var(--tab-count); - display: none; - grid: 100% / 100%; - inline-size: 100%; - block-size: 100%; - - overflow: auto; - } - - &[open] { - & > summary { - background-color: var(--surface-600); - } - - &::details-content { - display: grid; - } - } + display: contents; + background-color: var(--surface-600); + color: var(--text-1); } } diff --git a/src/components/tabs.tsx b/src/components/tabs.tsx index fe1e09c..3237249 100644 --- a/src/components/tabs.tsx +++ b/src/components/tabs.tsx @@ -95,19 +95,12 @@ export const Tab: ParentComponent<{ id: string, label: string, closable?: boolea const context = useTabs(); const resolved = children(() => props.children); const isActive = context.isActive(props.id); - const [ref, setRef] = createSignal(); - - // const isActive = context.register(props.id, props.label, { - // closable: props.closable ?? false, - // ref: ref, - // }); return
{resolved()} diff --git a/src/features/command/contextMenu.tsx b/src/features/command/contextMenu.tsx index ec38ac1..5b8aff0 100644 --- a/src/features/command/contextMenu.tsx +++ b/src/features/command/contextMenu.tsx @@ -1,4 +1,4 @@ -import { Accessor, Component, createContext, createEffect, createMemo, createSignal, createUniqueId, For, JSX, ParentComponent, splitProps, useContext } from "solid-js"; +import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, JSX, ParentComponent, splitProps, useContext } from "solid-js"; import { CommandType } from "./index"; import css from "./contextMenu.module.css"; @@ -62,11 +62,11 @@ const Menu: Component<{ children: (command: CommandType) => JSX.Element }> = (pr command(); }; - return
    + return { command =>
  • {props.children(command)}
  • }
    -
; + ; }; const Handle: ParentComponent> = (props) => { @@ -75,7 +75,7 @@ const Handle: ParentComponent> = (props) => { const context = useContext(ContextMenu)!; const [handle, setHandle] = createSignal(); - return { + return { e.preventDefault(); context.show(handle()!); @@ -84,4 +84,7 @@ const Handle: ParentComponent> = (props) => { }}>{local.children}; }; +let handleCounter = 0; +const createUniqueId = () => `handle-${handleCounter++}` + export const Context = { Root, Menu, Handle }; \ No newline at end of file diff --git a/src/features/command/index.tsx b/src/features/command/index.tsx index fae37c0..137de5b 100644 --- a/src/features/command/index.tsx +++ b/src/features/command/index.tsx @@ -1,9 +1,9 @@ -import { Accessor, children, Component, createContext, createEffect, createMemo, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js'; +import { Accessor, children, Component, createContext, createEffect, createMemo, For, JSX, ParentComponent, ParentProps, Show, useContext } from 'solid-js'; interface CommandContextType { - set(commands: CommandType[]): void; - addContextualArguments(command: CommandType, target: EventTarget, args: Accessor): void; - execute(command: CommandType, event: Event): void; + set(commands: CommandType[]): void; + addContextualArguments any = any>(command: CommandType, target: EventTarget, args: Accessor>): void; + execute any = any>(command: CommandType, event: Event): void; } const CommandContext = createContext(); @@ -13,16 +13,16 @@ export const useCommands = () => useContext(CommandContext); const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { // const commands = () => props.commands ?? []; const contextualArguments = new Map>>(); - const commands = new Set>(); + const commands = new Set>(); const context = { - set(c: CommandType[]): void { + set(c: CommandType[]): void { for (const command of c) { commands.add(command); } }, - addContextualArguments(command: CommandType, target: EventTarget, args: Accessor): void { + addContextualArguments any = any>(command: CommandType, target: EventTarget, args: Accessor>): void { if (contextualArguments.has(command) === false) { contextualArguments.set(command, new WeakMap()); } @@ -30,8 +30,8 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { contextualArguments.get(command)?.set(target, args); }, - execute(command: CommandType, event: Event): boolean | undefined { - const args = ((): T => { + execute any = any>(command: CommandType, event: Event): boolean | undefined { + const args = ((): Parameters => { const contexts = contextualArguments.get(command); @@ -45,7 +45,8 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { return [] as any; } - const args = contexts.get(element)! as Accessor; + const args = contexts.get(element)! as Accessor>; + return args(); })(); @@ -84,9 +85,9 @@ const Root: ParentComponent<{ commands: CommandType[] }> = (props) => { ; }; -const Add: Component<{ command: CommandType } | { commands: CommandType[] }> = (props) => { +const Add: Component<{ command: CommandType } | { commands: CommandType[] }> = (props) => { const context = useCommands(); - const commands = createMemo[]>(() => props.commands ?? [props.command]); + const commands = createMemo[]>(() => props.commands ?? [props.command]); createEffect(() => { context?.set(commands()); @@ -95,7 +96,7 @@ const Add: Component<{ command: CommandType } | { commands: CommandType(props: ParentProps<{ for: CommandType, with: T }>): JSX.Element => { +const Context = any = any>(props: ParentProps<{ for: CommandType, with: Parameters }>): JSX.Element => { const resolved = children(() => props.children); const context = useCommands(); const args = createMemo(() => props.with); @@ -114,19 +115,27 @@ const Context = (props: ParentProps<{ for: CommandType< }; const Handle: Component<{ command: CommandType }> = (props) => { - return <> + return {props.command.label} { shortcut => { - const shift = shortcut().modifier & Modifier.Shift ? 'Shft+' : ''; - const ctrl = shortcut().modifier & Modifier.Control ? 'Ctrl+' : ''; - const meta = shortcut().modifier & Modifier.Meta ? 'Meta+' : ''; - const alt = shortcut().modifier & Modifier.Alt ? 'Alt+' : ''; + const modifier = shortcut().modifier; + const modifierMap: Record = { + [Modifier.Shift]: 'Shft', + [Modifier.Control]: 'Ctrl', + [Modifier.Meta]: 'Meta', + [Modifier.Alt]: 'Alt', + }; - return {ctrl}{shift}{meta}{alt}{shortcut().key}; + return <>  + typeof m === 'number').filter(m => modifier & m)}>{ + (m) => <>{modifierMap[m]}+ + } + {shortcut().key} + ; } } - ; + ; }; export const Command = { Root, Handle, Add, Context }; @@ -139,17 +148,19 @@ export enum Modifier { Alt = 1 << 3, } -export interface CommandType { - (...args: TArgs): any; +export interface CommandType any = any> { + (...args: Parameters): Promise>; label: string; shortcut?: { key: string; modifier: Modifier; }; + withLabel(label: string): CommandType; + with(this: (this: ThisParameterType, ...args: [...A, ...B]) => ReturnType, ...args: A): CommandType<(...args: B) => ReturnType>; } -export const createCommand = (label: string, command: (...args: TArgs) => any, shortcut?: CommandType['shortcut']): CommandType => { - return Object.defineProperties(command as CommandType, { +export const createCommand = any>(label: string, command: T, shortcut?: CommandType['shortcut']): CommandType => { + return Object.defineProperties(((...args: Parameters) => command(...args)) as any, { label: { value: label, configurable: false, @@ -159,18 +170,24 @@ export const createCommand = (label: string, command: value: shortcut ? { key: shortcut.key.toLowerCase(), modifier: shortcut.modifier } : undefined, configurable: false, writable: false, + }, + withLabel: { + value(label: string) { + return createCommand(label, command, shortcut); + }, + configurable: false, + writable: false, + }, + with: { + value(this: (this: ThisParameterType, ...args: [...A, ...B]) => ReturnType, ...args: A): CommandType<(...args: B) => ReturnType> { + return createCommand(label, command.bind(undefined, ...args), shortcut); + }, + configurable: false, + writable: false, } }); }; -export const noop = Object.defineProperties(createCommand('noop', () => { }), { - withLabel: { - value(label: string) { - return createCommand(label, () => { }); - }, - configurable: false, - writable: false, - }, -}) as CommandType & { withLabel(label: string): CommandType }; +export const noop = createCommand('noop', () => { }); export { Context } from './contextMenu'; \ No newline at end of file diff --git a/src/features/file/grid.module.css b/src/features/file/grid.module.css index 05c99e3..67bf54e 100644 --- a/src/features/file/grid.module.css +++ b/src/features/file/grid.module.css @@ -1,128 +1,19 @@ -.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; +.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); - & input[type="checkbox"] { - margin: .1em; + &:has(::spelling-error, ::grammar-error) { + border-color: var(--fail); } - & 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; - } + & ::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/features/file/grid.tsx b/src/features/file/grid.tsx index 7baf4f0..a6e5e0c 100644 --- a/src/features/file/grid.tsx +++ b/src/features/file/grid.tsx @@ -1,229 +1,74 @@ -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 "../selectable"; -import { debounce, deepCopy, deepDiff, Mutation } from "~/utilities"; -import css from './grid.module.css'; - -selectable // prevents removal of import - -interface Leaf extends Record { } -export interface Entry extends Record { } - -type Rows = Map>; -type SelectionItem = { key: string, value: Accessor>, element: WeakRef }; - -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(prop: string): void; -} +import { Accessor, Component, createEffect, createMemo, createSignal } from "solid-js"; +import { debounce, Mutation } from "~/utilities"; +import { Column, GridApi as GridCompApi, Grid as GridComp } from "~/components/grid"; +import { createDataSet, DataSetNode, DataSetRowNode } from "~/components/table"; +import { SelectionItem } from "../selectable"; +import css from "./grid.module.css" +export type Entry = { key: string } & { [lang: string]: string }; export interface GridApi { - readonly selection: Accessor>>; - readonly rows: Accessor>>; readonly mutations: Accessor; - selectAll(): void; - clear(): void; - remove(keys: string[]): void; - insert(prop: string): void; + readonly selection: Accessor[]>; + remove(indices: number[]): void; + addKey(key: string): void; + addLocale(locale: string): void; +}; + +const groupBy = (rows: DataSetRowNode[]) => { + type R = DataSetRowNode & { _key: string }; + + const group = (nodes: R[]): DataSetNode[] => Object + .entries(Object.groupBy(nodes, r => r._key.split('.').at(0)!) as Record) + .map(([key, nodes]) => nodes.at(0)?._key === key + ? nodes[0] + : ({ kind: 'group', key, groupedBy: 'key', nodes: group(nodes.map(n => ({ ...n, _key: n._key.slice(key.length + 1) }))) }) + ); + + return group(rows.map(r => ({ ...r, _key: r.value.key }))) as any; } -const GridContext = createContext(); +export function Grid(props: { class?: string, rows: Entry[], locales: string[], api?: (api: GridApi) => any }) { + const rows = createMemo(() => createDataSet(props.rows, { group: { by: 'key', with: groupBy } })); + const locales = createMemo(() => props.locales); + const columns = createMemo[]>(() => [ + { + id: 'key', + label: 'Key', + renderer: ({ value }) => value.split('.').at(-1), + }, + ...locales().map>(lang => ({ + id: lang, + label: lang, + renderer: ({ row, column, value, mutate }) => { + const entry = rows().value[row]!; -const isLeaf = (entry: Entry | Leaf): entry is Leaf => Object.values(entry).some(v => typeof v === 'string'); -const useGrid = () => useContext(GridContext)!; + return