@greendrake/ui-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,191 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import { TabBar, type TabDef } from '@greendrake/ui'
4
+
5
+ const props = withDefaults(
6
+ defineProps<{
7
+ initialWidth?: number
8
+ minWidth?: number
9
+ fullWidthUntilTab?: boolean
10
+ }>(),
11
+ {
12
+ initialWidth: 150,
13
+ minWidth: 100,
14
+ fullWidthUntilTab: false
15
+ }
16
+ )
17
+
18
+ const emit = defineEmits<{
19
+ 'tab-open': [tab: TabDef]
20
+ 'tab-close': [tab: TabDef]
21
+ 'tab-activate': [tab: TabDef]
22
+ }>()
23
+
24
+ const tabs = ref<TabDef[]>([])
25
+ const activeTabId = ref<string | null>(null)
26
+ const masterWidth = ref(props.initialWidth)
27
+
28
+ const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) ?? null)
29
+
30
+ const hasTabs = computed(() => tabs.value.length > 0)
31
+
32
+ const masterStyle = computed(() => {
33
+ if (props.fullWidthUntilTab && !hasTabs.value) {
34
+ return { flex: '1 1 0', width: 0 }
35
+ }
36
+ return { width: masterWidth.value + 'px' }
37
+ })
38
+
39
+ function openTab(id: string, label?: string): void {
40
+ let tab = tabs.value.find(t => t.id === id)
41
+ if (!tab) {
42
+ tab = { id, label: label ?? id }
43
+ tabs.value.push(tab)
44
+ emit('tab-open', tab)
45
+ }
46
+ activeTabId.value = id
47
+ emit('tab-activate', tab)
48
+ }
49
+
50
+ function closeTab(id: string): void {
51
+ const index = tabs.value.findIndex(t => t.id === id)
52
+ if (index === -1) return
53
+
54
+ const tab = tabs.value[index]
55
+ tabs.value.splice(index, 1)
56
+ emit('tab-close', tab)
57
+
58
+ if (activeTabId.value === id) {
59
+ activeTabId.value = tabs.value[Math.min(index, tabs.value.length - 1)]?.id ?? null
60
+ if (activeTabId.value) {
61
+ emit('tab-activate', tabs.value.find(t => t.id === activeTabId.value)!)
62
+ }
63
+ }
64
+ }
65
+
66
+ function activateTab(id: string): void {
67
+ const tab = tabs.value.find(t => t.id === id)
68
+ if (tab) {
69
+ activeTabId.value = id
70
+ emit('tab-activate', tab)
71
+ }
72
+ }
73
+
74
+ function updateTab(id: string, newId?: string, newLabel?: string): void {
75
+ const tab = tabs.value.find(t => t.id === id)
76
+ if (!tab) return
77
+ if (newLabel !== undefined) tab.label = newLabel
78
+ if (newId !== undefined && newId !== id) {
79
+ tab.id = newId
80
+ if (activeTabId.value === id) activeTabId.value = newId
81
+ }
82
+ }
83
+
84
+ function onResizeStart(e: MouseEvent): void {
85
+ const startX = e.clientX
86
+ const startWidth = masterWidth.value
87
+
88
+ function onMouseMove(e: MouseEvent): void {
89
+ masterWidth.value = Math.max(props.minWidth, startWidth + e.clientX - startX)
90
+ }
91
+
92
+ function onMouseUp(): void {
93
+ document.removeEventListener('mousemove', onMouseMove)
94
+ document.removeEventListener('mouseup', onMouseUp)
95
+ }
96
+
97
+ document.addEventListener('mousemove', onMouseMove)
98
+ document.addEventListener('mouseup', onMouseUp)
99
+ }
100
+
101
+ defineExpose({
102
+ openTab,
103
+ closeTab,
104
+ activateTab,
105
+ updateTab,
106
+ tabs,
107
+ activeTab,
108
+ activeTabId
109
+ })
110
+ </script>
111
+ <template>
112
+ <div class="MasterDetail">
113
+ <div class="MasterDetail__master" :style="masterStyle">
114
+ <slot name="master" :open-tab="openTab"></slot>
115
+ </div>
116
+ <template v-if="hasTabs || !fullWidthUntilTab">
117
+ <div class="MasterDetail__resize" @mousedown="onResizeStart"></div>
118
+ <div class="MasterDetail__detail">
119
+ <TabBar :tabs="tabs" :model-value="activeTabId" closable @update:model-value="activateTab" @close="closeTab" />
120
+ <div class="MasterDetail__content">
121
+ <template v-for="tab in tabs" :key="tab.id">
122
+ <div v-show="tab.id === activeTabId" class="MasterDetail__pane">
123
+ <slot name="detail" :tab="tab" :is-active="tab.id === activeTabId"></slot>
124
+ </div>
125
+ </template>
126
+ </div>
127
+ </div>
128
+ </template>
129
+ </div>
130
+ </template>
131
+ <style lang="scss">
132
+ .MasterDetail {
133
+ display: flex;
134
+ min-width: 0;
135
+ // Flex item in a column parent: without this the automatic min-height (auto)
136
+ // keeps it as tall as its content, so it overgrows the viewport and the
137
+ // master's overflow:auto has nothing to scroll within.
138
+ min-height: 0;
139
+
140
+ &__master {
141
+ flex-shrink: 0;
142
+ min-width: v-bind('minWidth + "px"');
143
+ overflow: auto;
144
+ display: flex;
145
+ flex-direction: column;
146
+
147
+ > * {
148
+ flex-grow: 1;
149
+ min-height: 0;
150
+ }
151
+ }
152
+
153
+ &__resize {
154
+ width: 4px;
155
+ flex-shrink: 0;
156
+ cursor: col-resize;
157
+ background-color: var(--border-color-light);
158
+ &:hover {
159
+ background-color: var(--accent-color);
160
+ }
161
+ }
162
+
163
+ &__detail {
164
+ flex-grow: 1;
165
+ min-width: 0;
166
+ min-height: 0;
167
+ overflow: hidden;
168
+ display: flex;
169
+ flex-direction: column;
170
+ }
171
+
172
+ &__content {
173
+ flex-grow: 1;
174
+ min-height: 0;
175
+ min-width: 0;
176
+ display: flex;
177
+ }
178
+
179
+ &__pane {
180
+ flex-grow: 1;
181
+ display: flex;
182
+ min-height: 0;
183
+ min-width: 0;
184
+
185
+ > * {
186
+ flex-grow: 1;
187
+ min-width: 0;
188
+ }
189
+ }
190
+ }
191
+ </style>
@@ -0,0 +1,2 @@
1
+ /** Human-readable message from a caught value, for local (non-toast) error display. */
2
+ export const errorMessage = (e: unknown): string => (e instanceof Error ? e.message : String(e))
@@ -0,0 +1,25 @@
1
+ // Wire timestamps drift between carrying an explicit zone ('Z' or ±hh:mm) and
2
+ // omitting it. JavaScript parses a zoneless date-time string as LOCAL time, so
3
+ // a missing zone is normalized to UTC before parsing. Output is always
4
+ // 'YYYY-MM-DD HH:MM:SSZ' — the value IS UTC and the suffix says so.
5
+ const HAS_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i
6
+
7
+ export const fmtDate = (v: unknown): string => {
8
+ if (v == null || v === '') return ''
9
+ let d: Date
10
+ if (typeof v === 'string') {
11
+ const iso = v.replace(' ', 'T')
12
+ d = new Date(iso.includes('T') && !HAS_ZONE.test(iso) ? iso + 'Z' : iso)
13
+ } else if (typeof v === 'number' || v instanceof Date) {
14
+ d = new Date(v)
15
+ } else {
16
+ throw new TypeError(`fmtDate: unsupported value of type ${typeof v}`)
17
+ }
18
+ return d.toISOString().replace('T', ' ').slice(0, 19) + 'Z'
19
+ }
20
+
21
+ /** Table-cell boolean: a tick for true, blank for false. */
22
+ export const fmtBool = (v: unknown): string => (v ? '✓' : '')
23
+
24
+ /** Prose boolean for detail views. */
25
+ export const fmtYesNo = (v: unknown): string => (v ? 'yes' : 'no')
@@ -0,0 +1,31 @@
1
+ import type { Ref } from 'vue'
2
+ import type { LoaderParams, LoaderResponse } from './InfiniteScrollTable.types'
3
+ import type { SortState } from './useSortState'
4
+
5
+ // The standard admin list-method argument shape (q, limit, offset, sort_by,
6
+ // sort_dir) — the wire counterpart of LoaderParams + SortState.
7
+ export type PageArgs = {
8
+ q?: string
9
+ offset?: number
10
+ limit?: number
11
+ sort_by?: string
12
+ sort_dir?: string
13
+ }
14
+
15
+ // Adapts InfiniteScrollTable's LoaderParams to the admin list-method argument
16
+ // shape. Passing `sort` includes the search box's `q` and the sort filters —
17
+ // exactly the methods with server-side search/sort support; the rest get the
18
+ // bare offset/limit window. Extra filter fields ride the `fetch` closure, and
19
+ // so does what a background read costs — the flag is handed on verbatim:
20
+ // listLoader((args, background) => api.call('admin.thread.list', [{ ...args, type }], backgroundCall(background)))
21
+ export const listLoader =
22
+ <T>(fetch: (args: PageArgs, background: boolean) => Promise<LoaderResponse<T>>, sort?: SortState) =>
23
+ (params: LoaderParams, _loading: Ref<boolean>, background: boolean): Promise<LoaderResponse<T>> =>
24
+ fetch(
25
+ {
26
+ offset: params.offset,
27
+ limit: params.limit,
28
+ ...(sort ? { q: typeof params.q === 'string' ? params.q : undefined, ...sort.sortFilters.value } : {})
29
+ },
30
+ background
31
+ )
@@ -0,0 +1,21 @@
1
+ import type { Ref } from 'vue'
2
+
3
+ // A SearchField filtering an InfiniteScrollTable should react swiftly when the
4
+ // table can satisfy the change from its in-memory set (no API call), but coalesce
5
+ // keystrokes when the change triggers a remote fetch. These are the two delays.
6
+ const LOCAL_MS = 250
7
+ const REMOTE_MS = 600
8
+
9
+ // The slice of an InfiniteScrollTable's exposed surface this helper needs: its
10
+ // own answer to whether a prospective text filter resolves locally.
11
+ export interface LocalFilterable {
12
+ willFilterLocally(value: string | undefined): boolean
13
+ }
14
+
15
+ // Builds SearchField's per-keystroke `debounce` resolver from the table's template
16
+ // ref. Before the table mounts (ref still empty), and whenever the table reports it
17
+ // can't serve the change locally, it yields the longer remote delay.
18
+ export const localFilterDebounce =
19
+ <T extends LocalFilterable>(table: Ref<T | undefined>) =>
20
+ (value: string | undefined): number =>
21
+ table.value?.willFilterLocally(value) ? LOCAL_MS : REMOTE_MS
package/src/main.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { default as InfiniteScrollTable } from './InfiniteScrollTable.vue'
2
+ export type { ColumnDefinition, LoaderParams, LoaderResponse, LoaderFunction, Filters, RowKey, DropTarget, RowPosition, VisibleRange, InfiniteScrollTableExposed } from './InfiniteScrollTable.types'
3
+ export { localFilterDebounce, type LocalFilterable } from './localFilterDebounce'
4
+ export { default as MasterDetail } from './MasterDetail.vue'
5
+ export { default as CrudPanel } from './CrudPanel.vue'
6
+ export type { CrudPanelExposed } from './CrudPanel.vue'
7
+ export { default as useRecordEditor, type RecordEditor, type RecordEditorOptions } from './useRecordEditor'
8
+ export { useSortState, type SortState, type SortDir, type ColumnOptions } from './useSortState'
9
+ export { listLoader, type PageArgs } from './listLoader'
10
+ export { fmtDate, fmtBool, fmtYesNo } from './formatters'
11
+ export { useDetailLoader, type DetailLoader } from './useDetailLoader'
12
+ export { useAsyncAction, type AsyncAction } from './useAsyncAction'
13
+ export { errorMessage } from './errorMessage'
14
+ export { default as DescriptionList } from './DescriptionList.vue'
15
+ export type { DescriptionItem } from './DescriptionList.vue'
@@ -0,0 +1,47 @@
1
+ import { ref, type Ref } from 'vue'
2
+ import { errorMessage } from './errorMessage'
3
+
4
+ export interface AsyncAction<Args extends unknown[]> {
5
+ /** True while the action runs; `run` is re-entry-guarded on it. */
6
+ acting: Ref<boolean>
7
+ error: Ref<string | null>
8
+ /** Success notice — the string the action resolved to, if any. */
9
+ notice: Ref<string | null>
10
+ run: (...args: Args) => Promise<void>
11
+ }
12
+
13
+ /**
14
+ * In-flight/error/notice bookkeeping around an async action — the repeated
15
+ * action-button skeleton. `run` ignores re-entry while acting, clears
16
+ * error/notice, and captures a rejection's message locally (no global
17
+ * handling), so pass a silent api call inside `fn`. When `fn` resolves to a
18
+ * string, that string becomes the success notice. Buttons that share one
19
+ * in-flight state parameterise `fn` and pass the variant to `run`.
20
+ */
21
+ export const useAsyncAction = <Args extends unknown[]>(fn: (...args: Args) => Promise<string | void>): AsyncAction<Args> => {
22
+ const acting = ref(false)
23
+ const error = ref<string | null>(null)
24
+ const notice = ref<string | null>(null)
25
+
26
+ const run = async (...args: Args): Promise<void> => {
27
+ if (acting.value) return
28
+ acting.value = true
29
+ error.value = null
30
+ notice.value = null
31
+ try {
32
+ const result = await fn(...args)
33
+ if (typeof result === 'string') notice.value = result
34
+ } catch (e) {
35
+ error.value = errorMessage(e)
36
+ } finally {
37
+ acting.value = false
38
+ }
39
+ }
40
+
41
+ return {
42
+ acting,
43
+ error,
44
+ notice,
45
+ run
46
+ }
47
+ }
@@ -0,0 +1,57 @@
1
+ import { ref, watch, toValue, type Ref, type WatchSource } from 'vue'
2
+ import { errorMessage } from './errorMessage'
3
+
4
+ export interface DetailLoader<T> {
5
+ data: Ref<T | null>
6
+ loading: Ref<boolean>
7
+ error: Ref<string | null>
8
+ reload: () => Promise<void>
9
+ // Patch the loaded value in place (a no-op before the first load). Lets a
10
+ // caller fold a known local change — an appended item, a status flip — into
11
+ // the pane without a full re-fetch, while the loader stays the sole writer.
12
+ mutate: (fn: (cur: T) => void) => void
13
+ }
14
+
15
+ /**
16
+ * The detail-pane fetch skeleton: load on mount, re-load whenever the id
17
+ * changes, expose data/loading/error. Error handling stays local — the
18
+ * fetcher's rejection message lands in the `error` ref for in-pane display,
19
+ * never in a global toast — so pass a silent api call as the fetcher.
20
+ * Responses that arrive after the id has moved on are discarded.
21
+ */
22
+ export const useDetailLoader = <T, Id>(id: WatchSource<Id>, fetcher: (id: Id) => Promise<T>): DetailLoader<T> => {
23
+ const data = ref(null) as Ref<T | null>
24
+ const loading = ref(false)
25
+ const error = ref<string | null>(null)
26
+ let seq = 0
27
+
28
+ const reload = async (): Promise<void> => {
29
+ const token = ++seq
30
+ loading.value = true
31
+ error.value = null
32
+ try {
33
+ const result = await fetcher(toValue(id))
34
+ if (token !== seq) return
35
+ data.value = result
36
+ } catch (e) {
37
+ if (token !== seq) return
38
+ error.value = errorMessage(e)
39
+ } finally {
40
+ if (token === seq) loading.value = false
41
+ }
42
+ }
43
+
44
+ const mutate = (fn: (cur: T) => void): void => {
45
+ if (data.value !== null) fn(data.value)
46
+ }
47
+
48
+ watch(id, reload, { immediate: true })
49
+
50
+ return {
51
+ data,
52
+ loading,
53
+ error,
54
+ reload,
55
+ mutate
56
+ }
57
+ }
@@ -0,0 +1,148 @@
1
+ import { reactive, computed, ref, type Ref, type ComputedRef } from 'vue'
2
+
3
+ /**
4
+ * Record-edit pattern for CRUD detail editors.
5
+ *
6
+ * Convention (matches what CrudPanel and patchRow expect):
7
+ *
8
+ * 1. The detail editor receives the full record it's editing (loaded by the
9
+ * caller from a list row, an API call, etc.).
10
+ * 2. It keeps two parallel objects:
11
+ * - `data` — the canonical state acknowledged by the backend.
12
+ * - `workData` — a reactive working copy bound to inputs.
13
+ * 3. On save, only fields that differ between `workData` and `data` are
14
+ * sent. Sending the entire record on every save is wasteful and risks
15
+ * clobbering server-side changes to fields the user didn't touch.
16
+ * 4. The `save(patch)` callback returns the FULL updated record. The
17
+ * backend may modify fields beyond what the user changed (timestamps,
18
+ * derived fields, server-side defaults); the editor must acknowledge
19
+ * those by overwriting its local state with the response. This makes
20
+ * the next diff correct and surfaces server changes in the UI.
21
+ * 5. The caller wires `('saved', key, record)` → `CrudPanel.applySaved(key,
22
+ * record, tab.id)` to update the row in place — no list re-fetch.
23
+ *
24
+ * Field shapes supported: strings, numbers, booleans, arrays, plain objects.
25
+ * Equality is by canonical JSON — sufficient for typical CRUD payloads.
26
+ *
27
+ * What this composable does NOT do:
28
+ * - HTTP. The caller supplies `save(patch)`.
29
+ * - Validation. Caller-side concern; gate `submit()` behind your own checks.
30
+ * - File uploads, multi-step save flows. Build those around `changes` /
31
+ * `data` / `workData` and call `applyResponse(updated)` manually instead
32
+ * of `submit()` for those cases.
33
+ */
34
+ export interface RecordEditor<T extends Record<keyof T & string, unknown>> {
35
+ /** Canonical state acknowledged by the backend. Mutates after each save. */
36
+ data: T
37
+ /** Reactive working copy bound to inputs. Edit this freely. */
38
+ workData: T
39
+ /** Diff of workData vs data, or null when nothing has changed. */
40
+ changes: ComputedRef<Partial<T> | null>
41
+ /** True when there are unsaved changes. */
42
+ dirty: ComputedRef<boolean>
43
+ /** True while a `submit()` call is in flight. */
44
+ saving: Ref<boolean>
45
+ /**
46
+ * Send the current diff via `save(patch)`, then assign the response onto
47
+ * both `data` and `workData`. No-op (returns null) when not dirty.
48
+ */
49
+ submit: () => Promise<T | null>
50
+ /** Discard local edits, reverting workData to data. */
51
+ reset: () => void
52
+ /**
53
+ * Manually acknowledge a server-side update (useful when the save flow
54
+ * is custom — e.g. file uploads — and `submit()` isn't called).
55
+ */
56
+ applyResponse: (updated: T) => void
57
+ }
58
+
59
+ export interface RecordEditorOptions<T extends Record<keyof T & string, unknown>> {
60
+ /** Initial record snapshot. Deep-cloned into both data and workData. */
61
+ initial: T
62
+ /** Sends the diff to the backend; must resolve to the full updated record. */
63
+ save: (patch: Partial<T>) => Promise<T>
64
+ /**
65
+ * Restrict which keys are considered editable / diffed. Defaults to the
66
+ * keys present on `initial`. Useful when the record contains read-only
67
+ * fields (e.g. id, created_at) that should never be sent in a patch.
68
+ */
69
+ fields?: readonly (keyof T)[]
70
+ /**
71
+ * Canonicalise a record before it enters the editor — applied to `initial`
72
+ * and to every save response. Use it to collapse irrelevant wire
73
+ * differences (`undefined` vs `null`, absent vs empty string) so the diff
74
+ * doesn't oscillate on them.
75
+ */
76
+ normalise?: (record: T) => T
77
+ }
78
+
79
+ export default function useRecordEditor<T extends Record<keyof T & string, unknown>>(options: RecordEditorOptions<T>): RecordEditor<T> {
80
+ const fields = (options.fields ?? Object.keys(options.initial)) as (keyof T)[]
81
+ const normalise = options.normalise ?? ((record: T): T => record)
82
+ const pick = (src: T): T => {
83
+ const out = {} as T
84
+ for (const k of fields) out[k] = src[k]
85
+ return out
86
+ }
87
+
88
+ // JSON clone — matches the diff equality function below and handles Vue
89
+ // proxies (structuredClone chokes on them) without an extra `toRaw` step.
90
+ const clone = <V>(v: V): V => JSON.parse(JSON.stringify(v)) as V
91
+ const initial = normalise(clone(options.initial))
92
+ const data = reactive(initial) as T
93
+ const workData = reactive(clone(pick(initial))) as T
94
+ const saving = ref(false)
95
+
96
+ const changes = computed<Partial<T> | null>(() => {
97
+ const diff: Partial<T> = {}
98
+ let any = false
99
+ for (const k of fields) {
100
+ if (!equal(workData[k], data[k])) {
101
+ diff[k] = workData[k]
102
+ any = true
103
+ }
104
+ }
105
+ return any ? diff : null
106
+ })
107
+
108
+ const dirty = computed(() => changes.value !== null)
109
+
110
+ function applyResponse(updated: T): void {
111
+ const normalised = normalise(updated)
112
+ Object.assign(data, normalised)
113
+ Object.assign(workData, pick(normalised))
114
+ }
115
+
116
+ async function submit(): Promise<T | null> {
117
+ const patch = changes.value
118
+ if (!patch) return null
119
+ saving.value = true
120
+ try {
121
+ const updated = await options.save(patch)
122
+ applyResponse(updated)
123
+ return updated
124
+ } finally {
125
+ saving.value = false
126
+ }
127
+ }
128
+
129
+ function reset(): void {
130
+ Object.assign(workData, pick(data))
131
+ }
132
+
133
+ return {
134
+ data,
135
+ workData,
136
+ changes,
137
+ dirty,
138
+ saving,
139
+ submit,
140
+ reset,
141
+ applyResponse
142
+ }
143
+ }
144
+
145
+ function equal(a: unknown, b: unknown): boolean {
146
+ if (a === b) return true
147
+ return JSON.stringify(a) === JSON.stringify(b)
148
+ }
@@ -0,0 +1,67 @@
1
+ import { ref, computed, type Ref, type ComputedRef } from 'vue'
2
+ import type { ColumnDefinition } from './InfiniteScrollTable.types'
3
+
4
+ export type SortDir = 'asc' | 'desc'
5
+
6
+ export type ColumnOptions<T = Record<string, unknown>> = Omit<ColumnDefinition<T>, 'field' | 'label' | 'sortable' | 'sortIcon' | 'onHeaderClick'> & {
7
+ /**
8
+ * Sort field sent to the backend when it differs from `field`
9
+ * (e.g. field 'asking_price' sorting by 'price'); `false` makes the
10
+ * column unsortable. Defaults to `field`.
11
+ */
12
+ sortKey?: string | false
13
+ }
14
+
15
+ export interface SortState {
16
+ sortBy: Ref<string>
17
+ sortDir: Ref<SortDir>
18
+ /** Same field flips direction; a new field takes over with its default direction. */
19
+ toggleSort: (field: string) => void
20
+ sortIcon: (field: string) => string | undefined
21
+ /** `{ sort_by, sort_dir }` in the wire shape InfiniteScrollTable filters / LoaderParams carry. */
22
+ sortFilters: ComputedRef<{ sort_by: string; sort_dir: SortDir }>
23
+ /**
24
+ * ColumnDefinition factory wired to this sort state (header click toggles,
25
+ * icon reflects). Build the columns array inside a `computed` so the icons
26
+ * track state changes.
27
+ */
28
+ column: <T = Record<string, unknown>>(field: string, label: string, options?: ColumnOptions<T>) => ColumnDefinition<T>
29
+ }
30
+
31
+ export const useSortState = (initialBy: string, initialDir: SortDir = 'desc', defaultDir: SortDir | ((field: string) => SortDir) = 'asc'): SortState => {
32
+ const sortBy = ref(initialBy)
33
+ const sortDir = ref<SortDir>(initialDir)
34
+
35
+ const toggleSort = (field: string): void => {
36
+ if (sortBy.value === field) {
37
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
38
+ } else {
39
+ sortBy.value = field
40
+ sortDir.value = typeof defaultDir === 'function' ? defaultDir(field) : defaultDir
41
+ }
42
+ }
43
+
44
+ const sortIcon = (field: string): string | undefined => (sortBy.value === field ? (sortDir.value === 'asc' ? '▲' : '▼') : undefined)
45
+
46
+ const sortFilters = computed(() => ({ sort_by: sortBy.value, sort_dir: sortDir.value }))
47
+
48
+ const column = <T = Record<string, unknown>>(field: string, label: string, options: ColumnOptions<T> = {}): ColumnDefinition<T> => {
49
+ const { sortKey = field, ...rest } = options
50
+ return {
51
+ field,
52
+ label,
53
+ sortable: sortKey !== false,
54
+ ...(sortKey === false ? {} : { onHeaderClick: () => toggleSort(sortKey), sortIcon: sortIcon(sortKey) }),
55
+ ...rest
56
+ }
57
+ }
58
+
59
+ return {
60
+ sortBy,
61
+ sortDir,
62
+ toggleSort,
63
+ sortIcon,
64
+ sortFilters,
65
+ column
66
+ }
67
+ }