@greendrake/ui-data 0.5.0 → 0.7.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.
- package/README.md +25 -4
- package/package.json +7 -4
- package/src/CrudPanel.vue +28 -13
- package/src/DetailPane.vue +19 -0
- package/src/InfiniteScrollTable.vue +10 -56
- package/src/InlineMarkup.vue +30 -0
- package/src/MasterDetail.vue +12 -77
- package/src/NoteActions.vue +79 -0
- package/src/formatters.ts +84 -11
- package/src/inlineMarkup.ts +56 -0
- package/src/main.ts +5 -1
package/README.md
CHANGED
|
@@ -158,7 +158,7 @@ const table = ref<InfiniteScrollTableExposed<User>>()
|
|
|
158
158
|
|
|
159
159
|
## MasterDetail
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
A `SplitPane` from `@greendrake/ui`: the resizable master pane on the left, tabbed detail panes on the right (a `TabBar` with closable tabs). Props: `initialWidth` and `minWidth` (the master's; `SplitPane`'s defaults, 150 and 100), `fullWidthUntilTab` (default `false`; the master spans the whole width until a tab opens). Emits `tab-open`, `tab-close`, `tab-activate`, each with the `TabDef`. Slots: `master` receives `{ openTab }`; `detail` receives `{ tab, isActive }` and is rendered once per open tab, inactive panes kept alive with `v-show`. Exposes `openTab(id, label?)` (opens or activates), `closeTab(id)`, `activateTab(id)`, `updateTab(id, newId?, newLabel?)`, `tabs`, `activeTab`, `activeTabId`.
|
|
162
162
|
|
|
163
163
|
## CrudPanel
|
|
164
164
|
|
|
@@ -174,10 +174,11 @@ Master/detail CRUD shell: a controls bar (search field, optional Create button,
|
|
|
174
174
|
| `initialWidth`, `minWidth`, `fullWidthUntilTab` | forwarded to `MasterDetail` | 200, 120, `true` |
|
|
175
175
|
| `showRange` | render the `start-end/total` readout | `true` |
|
|
176
176
|
| `localSearchFields` | `string[] \| false \| null`; `null` keeps the table's default | `null` |
|
|
177
|
+
| `rowLabel` | `(record: T) => string`, a row's detail tab label; `null` labels it with the row key | `null` |
|
|
177
178
|
| `creatable` | Create button, opens a `new:<n>` tab | `false` |
|
|
178
179
|
| `newTabLabel` | label of that tab | `messages.newTab` |
|
|
179
180
|
| `onDelete` | `(keys: Key[]) => Promise<unknown>`; enables the Delete button for the selection | `null` |
|
|
180
|
-
| `deleteConfirmMessage` | when set, deletion asks first in a `Modal` | `null` |
|
|
181
|
+
| `deleteConfirmMessage` | `string \| ((keys: Key[]) => Promise<string>)`; when set, deletion asks first in a `Modal` with this text, or the text the function composes for the selected keys | `null` |
|
|
181
182
|
| `keyExists` | `(key: Key) => boolean`, backs the `isDupe` slot prop | `null` |
|
|
182
183
|
|
|
183
184
|
The search field's debounce is `localFilterDebounce` over the table: swift when the change resolves in memory, longer when it triggers a fetch. A single column hides the table header.
|
|
@@ -186,7 +187,7 @@ Slots: `controls-left` (after the search field and Create), `controls-middle` (`
|
|
|
186
187
|
|
|
187
188
|
Exposed (`CrudPanelExposed<T>`; type the ref as `ref<CrudPanelExposed<Row>>()`): `openTab`, `closeTab`, `updateTab`, `removeRows(keys)` (also clears the selection), `addRow`, `patchRow`, `getRow`, `refresh`, `applySaved(key, record, tabId?)`, and readonly `selected`, `range`, `deleting`.
|
|
188
189
|
|
|
189
|
-
`applySaved` is the post-save hook of the record-edit convention below: it patches the row in place when the key exists, otherwise adds the row and, given the originating `new:` tab's id, renames that tab to the persisted key so the `record` slot prop resolves and the next save updates instead of creates.
|
|
190
|
+
`applySaved` is the post-save hook of the record-edit convention below: it patches the row in place when the key exists, otherwise adds the row and, given the originating `new:` tab's id, renames that tab to the persisted key so the `record` slot prop resolves and the next save updates instead of creates. With a `rowLabel`, the record's tab is relabelled from the saved record.
|
|
190
191
|
|
|
191
192
|
```vue
|
|
192
193
|
<script setup lang="ts">
|
|
@@ -315,6 +316,10 @@ interface SortState {
|
|
|
315
316
|
|
|
316
317
|
`useDetailLoader<T, Id>(id: WatchSource<Id>, fetcher: (id: Id) => Promise<T>): DetailLoader<T>` — the detail-pane fetch skeleton: loads immediately, reloads whenever `id` changes, discards responses that arrive after the id moved on. Returns `{ data: Ref<T | null>, loading, error: Ref<string | null>, reload(), mutate(fn: (cur: T) => void) }`. A rejection's message lands in `error` for in-pane display, so pass a fetcher that does not report errors globally itself. `mutate` folds a known local change into the loaded value without a re-fetch.
|
|
317
318
|
|
|
319
|
+
### DetailPane
|
|
320
|
+
|
|
321
|
+
`<DetailPane :loader="detail" v-slot="{ data }">` — a detail pane's record as `detail` (a `useDetailLoader` result) loads: the loading line, the load's error, or the default slot given the loaded record as `data`.
|
|
322
|
+
|
|
318
323
|
### useAsyncAction
|
|
319
324
|
|
|
320
325
|
`useAsyncAction<Args extends unknown[]>(fn: (...args: Args) => Promise<string | void>): AsyncAction<Args>` — in-flight/error/notice bookkeeping for an action button. Returns `{ acting, error, notice, run(...args) }`: `run` ignores re-entry while acting, clears `error`/`notice`, captures a rejection's message in `error`, and stores a string result as the success `notice`. Buttons sharing one in-flight state parameterise `fn` and pass the variant to `run`.
|
|
@@ -325,10 +330,26 @@ interface SortState {
|
|
|
325
330
|
|
|
326
331
|
### Formatters
|
|
327
332
|
|
|
328
|
-
|
|
333
|
+
Beside `fmtBool` and `fmtYesNo`, which read any value as a boolean, the formatters render `null`, `undefined` and `''` as `''`, and throw on a value of a type they do not format. `Intl` output is in the browser's locale.
|
|
334
|
+
|
|
335
|
+
- `parseInstant(v: unknown): Date` — an instant from an ISO/`'YYYY-MM-DD HH:MM:SS'` string (a zoneless string is taken as UTC), a number or a `Date`.
|
|
336
|
+
- `fmtDate(v: unknown): string` — `'YYYY-MM-DD HH:MM:SSZ'`: the instant (`parseInstant`) in UTC.
|
|
337
|
+
- `fmtDateTime(style: DateStyle)` — a formatter of instants (`parseInstant`) at an `Intl` date and time style (`'full' | 'long' | 'medium' | 'short'`), in the browser's time zone; one formatter per style.
|
|
338
|
+
- `fmtDay(style: DateStyle)` — a formatter of calendar dates (`'YYYY-MM-DD'`) at an `Intl` date style; the day never shifts with the time zone. One formatter per style.
|
|
339
|
+
- `fmtNumber(v: unknown): string` — a number or numeric string in the locale's notation, never rounded; a string formats exactly, digit for digit.
|
|
340
|
+
- `fmtBytes(v: unknown): string` — a byte count in binary units (B, KiB, MiB, GiB, TiB, PiB, EiB): the largest unit the amount reads under 1024 in once rounded to a tenth, at most one fraction digit.
|
|
341
|
+
- `fmtMarkupText(v: unknown): string` — markup as the one line of text it reads: `inlineMarkup`'s text, whitespace collapsed.
|
|
329
342
|
- `fmtBool(v: unknown): string` — `'✓'` or `''`, for table cells.
|
|
330
343
|
- `fmtYesNo(v: unknown): string` — `'yes'` / `'no'`, for detail prose.
|
|
331
344
|
|
|
345
|
+
### inlineMarkup, InlineMarkup
|
|
346
|
+
|
|
347
|
+
`inlineMarkup(markup: string): DocumentFragment` rebuilds markup as one line of inline content, safe to render whatever its source: text, and the inline formatting elements `b`, `strong`, `i`, `em`, `u`, `s`, `del`, `ins`, `sub`, `sup`, `code`, `mark`, `small`, recreated without attributes. Other elements are unwrapped; block-level ones and `<br>` part runs of text with one space; scripts, styles, templates, `noscript`, embedded and media content are dropped with everything inside, as are comments. It parses with `DOMParser`, whose document is inert. The `InlineMarkup` component (prop `markup: string`) renders the fragment on one line, cut with an ellipsis — a table cell's `component` for rich text.
|
|
348
|
+
|
|
349
|
+
### NoteActions
|
|
350
|
+
|
|
351
|
+
An operator's decisions on a record, recorded with a note. Props: `actions: NoteAction[]` and `placeholder: string`. `NoteAction` is `{ label, fn(note) => Promise, note?: 'optional' | 'required', confirm? }`: a button per action; the note field shows when any action takes a note, one requiring it stays disabled until there is one, and one with `confirm` asks in a modal first. The actions share one in-flight guard and an error line; `done` is emitted once one succeeded, so the parent re-reads. `suspensionActions(suspended, suspend, unsuspend)` is the pair a suspendable record offers — Suspend on a required reason, or Unsuspend.
|
|
352
|
+
|
|
332
353
|
### DescriptionList
|
|
333
354
|
|
|
334
355
|
Label/value grid for detail panes. Prop `items: (DescriptionItem | false | null | undefined)[]` — falsy entries are skipped, so conditional rows inline as `cond && { … }`. `DescriptionItem` is `{ label: string; value: string | number | null | undefined; code?: boolean }`; `code` renders the value in `<code>`. Values arrive display-ready.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@greendrake/ui-data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Data-heavy dashboard components: virtualized InfiniteScrollTable, CrudPanel/MasterDetail, record editing and the admin-CRUD kit",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
@@ -33,14 +33,17 @@
|
|
|
33
33
|
},
|
|
34
34
|
"prettier": "@greendrake/dev-config/prettier",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@greendrake/scss-kit": "0.
|
|
37
|
-
"@greendrake/ui": "0.
|
|
36
|
+
"@greendrake/scss-kit": "0.7.0",
|
|
37
|
+
"@greendrake/ui": "0.7.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"vue": "^3.5.34"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@greendrake/dev-config": "0.
|
|
43
|
+
"@greendrake/dev-config": "0.7.0",
|
|
44
|
+
"@types/bun": "^1.3.5",
|
|
45
|
+
"@types/jsdom": "28.0.3",
|
|
46
|
+
"jsdom": "28.1.0",
|
|
44
47
|
"typescript": "6.0.3",
|
|
45
48
|
"vue": "^3.5.34",
|
|
46
49
|
"vue-tsc": "^2.2.0"
|
package/src/CrudPanel.vue
CHANGED
|
@@ -73,11 +73,14 @@ const props = withDefaults(
|
|
|
73
73
|
showRange?: boolean
|
|
74
74
|
// null = table default; false = always filter remotely (see table prop).
|
|
75
75
|
localSearchFields?: string[] | false | null
|
|
76
|
+
// The detail tab label of a row; null labels a tab with the row key.
|
|
77
|
+
rowLabel?: ((record: T) => string) | null
|
|
76
78
|
// --- Create / Delete affordances (all opt-in) ---
|
|
77
79
|
creatable?: boolean
|
|
78
80
|
newTabLabel?: string
|
|
79
81
|
onDelete?: ((keys: Key[]) => Promise<unknown>) | null
|
|
80
|
-
|
|
82
|
+
// The confirm's text; a function composes it for the keys about to go.
|
|
83
|
+
deleteConfirmMessage?: string | ((keys: Key[]) => Promise<string>) | null
|
|
81
84
|
keyExists?: ((key: Key) => boolean) | null
|
|
82
85
|
}>(),
|
|
83
86
|
{
|
|
@@ -93,7 +96,8 @@ const props = withDefaults(
|
|
|
93
96
|
newTabLabel: undefined,
|
|
94
97
|
onDelete: null,
|
|
95
98
|
deleteConfirmMessage: null,
|
|
96
|
-
keyExists: null
|
|
99
|
+
keyExists: null,
|
|
100
|
+
rowLabel: null
|
|
97
101
|
}
|
|
98
102
|
)
|
|
99
103
|
|
|
@@ -112,7 +116,12 @@ const newTabLabelText = computed(() => props.newTabLabel ?? messages.newTab)
|
|
|
112
116
|
function onRowActivate(record: T): void {
|
|
113
117
|
if (!slots.detail) return
|
|
114
118
|
const key = String(rowKeyOf(record, props.rowKey))
|
|
115
|
-
masterDetailRef.value!.openTab(key, key)
|
|
119
|
+
masterDetailRef.value!.openTab(key, labelOf(record, key))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A row's detail tab label: its rowLabel, else its key.
|
|
123
|
+
function labelOf(record: T, key: string): string {
|
|
124
|
+
return props.rowLabel ? props.rowLabel(record) : key
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
const searchQuery = ref<string | undefined>('')
|
|
@@ -134,7 +143,8 @@ function onRangeChanged(range: VisibleRange): void {
|
|
|
134
143
|
|
|
135
144
|
let newTabSeq = 0
|
|
136
145
|
const deleting = ref(false)
|
|
137
|
-
|
|
146
|
+
// The open confirm's text; null while no confirm shows.
|
|
147
|
+
const deleteConfirm = ref<string | null>(null)
|
|
138
148
|
|
|
139
149
|
const canDelete = computed(() => !!props.onDelete && (tableRef.value?.selected?.size ?? 0) > 0)
|
|
140
150
|
|
|
@@ -142,12 +152,13 @@ function onCreate(): void {
|
|
|
142
152
|
masterDetailRef.value!.openTab(`new:${++newTabSeq}`, newTabLabelText.value)
|
|
143
153
|
}
|
|
144
154
|
|
|
145
|
-
function onRequestDelete(): void {
|
|
155
|
+
async function onRequestDelete(): Promise<void> {
|
|
146
156
|
if (!canDelete.value || deleting.value) return
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
} else {
|
|
157
|
+
const message = props.deleteConfirmMessage
|
|
158
|
+
if (message === null) {
|
|
150
159
|
doDelete()
|
|
160
|
+
} else {
|
|
161
|
+
deleteConfirm.value = typeof message === 'string' ? message : await message([...tableRef.value!.selected!])
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
164
|
|
|
@@ -156,7 +167,7 @@ async function doDelete(): Promise<void> {
|
|
|
156
167
|
if (!sel?.size) return
|
|
157
168
|
const keys = [...sel]
|
|
158
169
|
deleting.value = true
|
|
159
|
-
deleteConfirm.value =
|
|
170
|
+
deleteConfirm.value = null
|
|
160
171
|
try {
|
|
161
172
|
await props.onDelete!(keys)
|
|
162
173
|
for (const k of keys) masterDetailRef.value?.closeTab(String(k))
|
|
@@ -180,16 +191,20 @@ function isDupe(original: Key, candidate: Key): boolean {
|
|
|
180
191
|
// in place when it already exists; otherwise add the freshly created row and,
|
|
181
192
|
// given the originating `new:` tab's id, rename that tab to the persisted key
|
|
182
193
|
// so the editor's `record` slot prop resolves and subsequent saves route to
|
|
183
|
-
// update instead of create.
|
|
194
|
+
// update instead of create. With a rowLabel, the record's tab follows its
|
|
195
|
+
// label, which a save may change.
|
|
184
196
|
function applySaved(key: Key, record: T, tabId?: string): void {
|
|
185
197
|
if (tableRef.value?.getRow(key)) {
|
|
186
198
|
tableRef.value.patchRow(key, record)
|
|
187
199
|
} else {
|
|
188
200
|
tableRef.value?.addRow(record)
|
|
189
201
|
if (tabId && tabId !== String(key)) {
|
|
190
|
-
masterDetailRef.value?.updateTab(tabId, String(key), String(key))
|
|
202
|
+
masterDetailRef.value?.updateTab(tabId, String(key), labelOf(record, String(key)))
|
|
191
203
|
}
|
|
192
204
|
}
|
|
205
|
+
if (props.rowLabel) {
|
|
206
|
+
masterDetailRef.value?.updateTab(String(key), undefined, props.rowLabel(record))
|
|
207
|
+
}
|
|
193
208
|
}
|
|
194
209
|
|
|
195
210
|
const exposed: CrudPanelExposed<T> = {
|
|
@@ -231,8 +246,8 @@ defineExpose(exposed)
|
|
|
231
246
|
<div v-if="showRange && visibleRange.total" class="CrudPanel__range">{{ visibleRange.start }}-{{ visibleRange.end }}/{{ visibleRange.total }}</div>
|
|
232
247
|
<button v-if="onDelete" type="button" class="CrudPanel__delete" :class="{ disabled: !canDelete || deleting, spinner: deleting }" @click="onRequestDelete">{{ messages.delete }}</button>
|
|
233
248
|
<slot name="controls-right" :selected="tableRef?.selected" />
|
|
234
|
-
<Modal v-if="deleteConfirm" class="CrudPanel__delete-modal" :title="messages.delete" :ok-label="deleting ? messages.deleting : messages.delete" :ok-disabled="deleting" @ok="doDelete" @cancel="deleteConfirm =
|
|
235
|
-
<p>{{
|
|
249
|
+
<Modal v-if="deleteConfirm !== null" class="CrudPanel__delete-modal" :title="messages.delete" :ok-label="deleting ? messages.deleting : messages.delete" :ok-disabled="deleting" @ok="doDelete" @cancel="deleteConfirm = null">
|
|
250
|
+
<p>{{ deleteConfirm }}</p>
|
|
236
251
|
</Modal>
|
|
237
252
|
</div>
|
|
238
253
|
<InfiniteScrollTable ref="tableRef" :loader="loader" :columns="columns" :row-height="rowHeight" :filters="mergedFilters" :row-key="rowKey" :hide-header="singleColumn" v-bind="localSearchFields !== null ? { localSearchFields } : {}" selectable @rowactivate="onRowActivate" @range-changed="onRangeChanged" />
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
import { messages } from '@greendrake/ui'
|
|
3
|
+
import type { DetailLoader } from './useDetailLoader'
|
|
4
|
+
|
|
5
|
+
// A detail pane's record as it loads: the loading line, the error the load
|
|
6
|
+
// ended in, or — loaded — the default slot, given the record as `data`.
|
|
7
|
+
defineProps<{ loader: DetailLoader<T> }>()
|
|
8
|
+
defineSlots<{ default(props: { data: T }): unknown }>()
|
|
9
|
+
</script>
|
|
10
|
+
<template>
|
|
11
|
+
<div v-if="loader.loading.value">{{ messages.loading }}…</div>
|
|
12
|
+
<div v-else-if="loader.error.value" class="DetailPane__error">{{ loader.error.value }}</div>
|
|
13
|
+
<slot v-else-if="loader.data.value !== null" :data="loader.data.value" />
|
|
14
|
+
</template>
|
|
15
|
+
<style lang="scss">
|
|
16
|
+
.DetailPane__error {
|
|
17
|
+
color: var(--error-color);
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
@@ -7,23 +7,10 @@ const DEBOUNCE_MS = 150
|
|
|
7
7
|
const FLING_DECAY_TAU_MS = 325
|
|
8
8
|
const FLING_MIN_VELOCITY_PX_MS = 0.05
|
|
9
9
|
const FLING_SAMPLE_WINDOW_MS = 100
|
|
10
|
-
|
|
11
|
-
// setPointerCapture throws NotFoundError when the pointer is no longer active —
|
|
12
|
-
// released or cancelled between event dispatch and this call, a real race for
|
|
13
|
-
// touch/pen and under synthetic pointer streams. A gesture whose pointer is
|
|
14
|
-
// already gone has nothing to capture, so exactly that error is a no-op;
|
|
15
|
-
// anything else still throws.
|
|
16
|
-
const capturePointer = (event: PointerEvent): void => {
|
|
17
|
-
try {
|
|
18
|
-
;(event.currentTarget as Element).setPointerCapture(event.pointerId)
|
|
19
|
-
} catch (e) {
|
|
20
|
-
if (!(e instanceof DOMException && e.name === 'NotFoundError')) throw e
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
10
|
</script>
|
|
24
11
|
<script setup lang="ts" generic="T extends Record<string, unknown>">
|
|
25
12
|
import { ref, computed, watch, onMounted, onUnmounted, type Ref } from 'vue'
|
|
26
|
-
import { useSelection, messages, TOUCH_PAN_SLOP_PX } from '@greendrake/ui'
|
|
13
|
+
import { useSelection, useWidthDrag, capturePointer, messages, TOUCH_PAN_SLOP_PX } from '@greendrake/ui'
|
|
27
14
|
import { rowKeyOf, type Props, type DropTarget, type Filters, type LoaderParams, type LoaderResponse, type RowPosition, type VisibleRange, type InfiniteScrollTableExposed } from './InfiniteScrollTable.types'
|
|
28
15
|
|
|
29
16
|
const props = withDefaults(defineProps<Props<T>>(), {
|
|
@@ -67,7 +54,7 @@ const lastRequestOffset = ref<number | null>(null)
|
|
|
67
54
|
const baselineFilters = ref<Filters | null>(null)
|
|
68
55
|
const hasAllRecords = ref(false)
|
|
69
56
|
const colWidths = ref<number[]>([])
|
|
70
|
-
const
|
|
57
|
+
const { active: colResizing, start: startColDrag, move: onColResizeMove, end: onColResizeEnd } = useWidthDrag(30)
|
|
71
58
|
const dropTarget = ref(null) as Ref<DropTarget<T> | null>
|
|
72
59
|
|
|
73
60
|
const keyOf = (record: T): string | number => rowKeyOf(record, props.rowKey)
|
|
@@ -98,14 +85,7 @@ function onRowPointerDown(record: T, e: PointerEvent): void {
|
|
|
98
85
|
// Touch/pen select on pointerup instead (see onRowPointerUp), so that
|
|
99
86
|
// starting a scroll pan on a row doesn't change the selection.
|
|
100
87
|
if (!selection || e.pointerType !== 'mouse') return
|
|
101
|
-
|
|
102
|
-
if (e.shiftKey) {
|
|
103
|
-
selection.rangeTo(id)
|
|
104
|
-
} else if (e.ctrlKey || e.metaKey) {
|
|
105
|
-
selection.toggle(id)
|
|
106
|
-
} else {
|
|
107
|
-
selection.replace(id)
|
|
108
|
-
}
|
|
88
|
+
selection.pick(keyOf(record), e)
|
|
109
89
|
}
|
|
110
90
|
|
|
111
91
|
// A panning pointer is captured by the wrapper (see onWrapperPointerMove), so
|
|
@@ -117,17 +97,7 @@ function onRowPointerUp(record: T, e: PointerEvent): void {
|
|
|
117
97
|
|
|
118
98
|
function onTableKeyDown(e: KeyboardEvent): void {
|
|
119
99
|
if (!selection) return
|
|
120
|
-
|
|
121
|
-
e.preventDefault()
|
|
122
|
-
selection.selectAll()
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
const isUp = e.key === 'ArrowUp'
|
|
126
|
-
const isDown = e.key === 'ArrowDown'
|
|
127
|
-
if (!isUp && !isDown) return
|
|
128
|
-
e.preventDefault()
|
|
129
|
-
const newIndex = selection.moveCursor(isUp ? 'up' : 'down', e.shiftKey)
|
|
130
|
-
scrollToIndex(newIndex)
|
|
100
|
+
scrollToIndex(selection.onKeyDown(e))
|
|
131
101
|
}
|
|
132
102
|
|
|
133
103
|
const initColWidths = (): void => {
|
|
@@ -139,26 +109,13 @@ const initColWidths = (): void => {
|
|
|
139
109
|
// Column resize and scroll-handle dragging use pointer capture on the grabbed
|
|
140
110
|
// element, so the drag keeps tracking the pointer outside the element (and the
|
|
141
111
|
// component) without document-level listeners — and works for touch/pen too.
|
|
112
|
+
// A column's drag stops at its handle: the wrapper would read it as a pan.
|
|
142
113
|
const onColResizeStart = (event: PointerEvent, index: number): void => {
|
|
143
|
-
event.preventDefault()
|
|
144
114
|
event.stopPropagation()
|
|
145
|
-
capturePointer(event)
|
|
146
115
|
initColWidths()
|
|
147
|
-
|
|
148
|
-
index
|
|
149
|
-
|
|
150
|
-
startWidth: colWidths.value[index]
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const onColResizeMove = (event: PointerEvent): void => {
|
|
155
|
-
if (!resizeCol.value) return
|
|
156
|
-
const delta = event.clientX - resizeCol.value.startX
|
|
157
|
-
colWidths.value[resizeCol.value.index] = Math.max(30, resizeCol.value.startWidth + delta)
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const onColResizeEnd = (): void => {
|
|
161
|
-
resizeCol.value = null
|
|
116
|
+
startColDrag(event, colWidths.value[index], width => {
|
|
117
|
+
colWidths.value[index] = width
|
|
118
|
+
})
|
|
162
119
|
}
|
|
163
120
|
|
|
164
121
|
// Computed values
|
|
@@ -788,7 +745,7 @@ defineExpose(exposed)
|
|
|
788
745
|
</script>
|
|
789
746
|
|
|
790
747
|
<template>
|
|
791
|
-
<div ref="containerRef" class="infinite-scroll-table" :class="{ spinner: loading || hasVisibleGaps, 'col-resizing':
|
|
748
|
+
<div ref="containerRef" class="infinite-scroll-table" :class="{ spinner: loading || hasVisibleGaps, 'col-resizing': colResizing }">
|
|
792
749
|
<div class="table-wrapper" :tabindex="selectable ? 0 : undefined" :style="{ touchAction: scrollHandleHeight > 0 ? 'pinch-zoom' : undefined }" @wheel="handleWheel" @keydown="onTableKeyDown" @pointerdown="onWrapperPointerDown" @pointermove="onWrapperPointerMove" @pointerup="onWrapperPointerEnd" @pointercancel="onWrapperPointerEnd" @click.capture="onWrapperClickCapture" @dragover="onTableDragOver" @drop="onTableDrop" @dragleave="onTableDragLeave">
|
|
793
750
|
<table class="data-table">
|
|
794
751
|
<thead v-if="!hideHeader" ref="theadRef">
|
|
@@ -892,10 +849,7 @@ defineExpose(exposed)
|
|
|
892
849
|
}
|
|
893
850
|
|
|
894
851
|
&.col-resizing {
|
|
895
|
-
|
|
896
|
-
* {
|
|
897
|
-
cursor: col-resize !important;
|
|
898
|
-
}
|
|
852
|
+
@include dragging(col-resize);
|
|
899
853
|
}
|
|
900
854
|
}
|
|
901
855
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Markup rendered as one line of its inline formatting (inlineMarkup), cut
|
|
3
|
+
// with an ellipsis where it overflows.
|
|
4
|
+
import { computed } from 'vue'
|
|
5
|
+
import { inlineMarkup } from './inlineMarkup'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
markup: string
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
// Safe to v-html: the fragment holds nothing but bare allowlisted elements
|
|
12
|
+
// and text, which serializing escapes.
|
|
13
|
+
const html = computed(() => {
|
|
14
|
+
const holder = document.createElement('div')
|
|
15
|
+
holder.appendChild(inlineMarkup(props.markup))
|
|
16
|
+
return holder.innerHTML
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
<template>
|
|
20
|
+
<span class="InlineMarkup" v-html="html"></span>
|
|
21
|
+
</template>
|
|
22
|
+
<style lang="scss">
|
|
23
|
+
.InlineMarkup {
|
|
24
|
+
display: block;
|
|
25
|
+
min-width: 0;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
white-space: nowrap;
|
|
28
|
+
text-overflow: ellipsis;
|
|
29
|
+
}
|
|
30
|
+
</style>
|
package/src/MasterDetail.vue
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
|
-
import { TabBar, type TabDef } from '@greendrake/ui'
|
|
3
|
+
import { SplitPane, TabBar, type TabDef } from '@greendrake/ui'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
initialWidth: 150,
|
|
13
|
-
minWidth: 100,
|
|
14
|
-
fullWidthUntilTab: false
|
|
15
|
-
}
|
|
16
|
-
)
|
|
5
|
+
// The widths are the master pane's, handed to SplitPane (its defaults when unset).
|
|
6
|
+
defineProps<{
|
|
7
|
+
initialWidth?: number
|
|
8
|
+
minWidth?: number
|
|
9
|
+
fullWidthUntilTab?: boolean
|
|
10
|
+
}>()
|
|
17
11
|
|
|
18
12
|
const emit = defineEmits<{
|
|
19
13
|
'tab-open': [tab: TabDef]
|
|
@@ -23,19 +17,11 @@ const emit = defineEmits<{
|
|
|
23
17
|
|
|
24
18
|
const tabs = ref<TabDef[]>([])
|
|
25
19
|
const activeTabId = ref<string | null>(null)
|
|
26
|
-
const masterWidth = ref(props.initialWidth)
|
|
27
20
|
|
|
28
21
|
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) ?? null)
|
|
29
22
|
|
|
30
23
|
const hasTabs = computed(() => tabs.value.length > 0)
|
|
31
24
|
|
|
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
25
|
function openTab(id: string, label?: string): void {
|
|
40
26
|
let tab = tabs.value.find(t => t.id === id)
|
|
41
27
|
if (!tab) {
|
|
@@ -81,23 +67,6 @@ function updateTab(id: string, newId?: string, newLabel?: string): void {
|
|
|
81
67
|
}
|
|
82
68
|
}
|
|
83
69
|
|
|
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
70
|
defineExpose({
|
|
102
71
|
openTab,
|
|
103
72
|
closeTab,
|
|
@@ -109,12 +78,11 @@ defineExpose({
|
|
|
109
78
|
})
|
|
110
79
|
</script>
|
|
111
80
|
<template>
|
|
112
|
-
<
|
|
113
|
-
<
|
|
81
|
+
<SplitPane class="MasterDetail" :initial-width="initialWidth" :min-width="minWidth">
|
|
82
|
+
<template #start>
|
|
114
83
|
<slot name="master" :open-tab="openTab"></slot>
|
|
115
|
-
</
|
|
116
|
-
<template v-if="hasTabs || !fullWidthUntilTab">
|
|
117
|
-
<div class="MasterDetail__resize" @mousedown="onResizeStart"></div>
|
|
84
|
+
</template>
|
|
85
|
+
<template v-if="hasTabs || !fullWidthUntilTab" #end>
|
|
118
86
|
<div class="MasterDetail__detail">
|
|
119
87
|
<TabBar :tabs="tabs" :model-value="activeTabId" closable @update:model-value="activateTab" @close="closeTab" />
|
|
120
88
|
<div class="MasterDetail__content">
|
|
@@ -126,44 +94,11 @@ defineExpose({
|
|
|
126
94
|
</div>
|
|
127
95
|
</div>
|
|
128
96
|
</template>
|
|
129
|
-
</
|
|
97
|
+
</SplitPane>
|
|
130
98
|
</template>
|
|
131
99
|
<style lang="scss">
|
|
132
100
|
.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
101
|
&__detail {
|
|
164
|
-
flex-grow: 1;
|
|
165
|
-
min-width: 0;
|
|
166
|
-
min-height: 0;
|
|
167
102
|
overflow: hidden;
|
|
168
103
|
display: flex;
|
|
169
104
|
flex-direction: column;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// An operator's decision: what the button says, and what recording it does
|
|
3
|
+
// with the note — which it may take (`optional`), wait for as its reason
|
|
4
|
+
// (`required`), or ignore (absent). One with `confirm` asks that first.
|
|
5
|
+
export interface NoteAction {
|
|
6
|
+
label: string
|
|
7
|
+
fn: (note: string) => Promise<unknown>
|
|
8
|
+
note?: 'optional' | 'required'
|
|
9
|
+
confirm?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The decisions a suspendable record offers: Suspend, on a required reason,
|
|
13
|
+
// while it is not suspended; Unsuspend while it is.
|
|
14
|
+
export const suspensionActions = (suspended: boolean, suspend: (reason: string) => Promise<unknown>, unsuspend: () => Promise<unknown>): NoteAction[] =>
|
|
15
|
+
suspended
|
|
16
|
+
? [{ label: 'Unsuspend', fn: unsuspend }]
|
|
17
|
+
: [
|
|
18
|
+
{
|
|
19
|
+
label: 'Suspend',
|
|
20
|
+
fn: suspend,
|
|
21
|
+
note: 'required'
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
</script>
|
|
25
|
+
<script setup lang="ts">
|
|
26
|
+
import { computed, ref } from 'vue'
|
|
27
|
+
import { Modal } from '@greendrake/ui'
|
|
28
|
+
import { useAsyncAction } from './useAsyncAction'
|
|
29
|
+
|
|
30
|
+
// The operator-decision shell: a note where a decision takes one, and a button
|
|
31
|
+
// per decision that records it. The parent owns the (silent) API calls and
|
|
32
|
+
// re-reads on @done; this owns the note, the shared in-flight guard, the
|
|
33
|
+
// confirmation and the error line.
|
|
34
|
+
const props = defineProps<{ actions: NoteAction[]; placeholder: string }>()
|
|
35
|
+
const emit = defineEmits<{ done: [] }>()
|
|
36
|
+
|
|
37
|
+
const note = ref('')
|
|
38
|
+
const takesNote = computed(() => props.actions.some(a => a.note))
|
|
39
|
+
// The decision waiting on its confirmation.
|
|
40
|
+
const confirming = ref<NoteAction | null>(null)
|
|
41
|
+
|
|
42
|
+
const action = useAsyncAction(async (a: NoteAction) => {
|
|
43
|
+
confirming.value = null
|
|
44
|
+
await a.fn(note.value.trim())
|
|
45
|
+
note.value = ''
|
|
46
|
+
emit('done')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const request = (a: NoteAction): void => {
|
|
50
|
+
if (a.confirm) confirming.value = a
|
|
51
|
+
else void action.run(a)
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
<template>
|
|
55
|
+
<div class="NoteActions">
|
|
56
|
+
<input v-if="takesNote" v-model="note" :placeholder="placeholder" :disabled="action.acting.value" />
|
|
57
|
+
<button v-for="a in actions" :key="a.label" :disabled="action.acting.value || (a.note === 'required' && !note.trim())" @click="request(a)">{{ a.label }}</button>
|
|
58
|
+
<div v-if="action.error.value" class="NoteActions__error">{{ action.error.value }}</div>
|
|
59
|
+
<Modal v-if="confirming" :title="confirming.label" :ok-label="confirming.label" @ok="action.run(confirming)" @cancel="confirming = null">
|
|
60
|
+
<p>{{ confirming.confirm }}</p>
|
|
61
|
+
</Modal>
|
|
62
|
+
</div>
|
|
63
|
+
</template>
|
|
64
|
+
<style lang="scss">
|
|
65
|
+
.NoteActions {
|
|
66
|
+
margin-top: var(--space-4);
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: var(--space-2);
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
input {
|
|
72
|
+
flex: 1 1 200px;
|
|
73
|
+
}
|
|
74
|
+
&__error {
|
|
75
|
+
color: var(--error-color);
|
|
76
|
+
flex-basis: 100%;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</style>
|
package/src/formatters.ts
CHANGED
|
@@ -1,25 +1,98 @@
|
|
|
1
|
+
import { inlineMarkup } from './inlineMarkup'
|
|
2
|
+
|
|
1
3
|
// Wire timestamps drift between carrying an explicit zone ('Z' or ±hh:mm) and
|
|
2
4
|
// omitting it. JavaScript parses a zoneless date-time string as LOCAL time, so
|
|
3
|
-
// a missing zone is normalized to UTC before parsing.
|
|
4
|
-
// 'YYYY-MM-DD HH:MM:SSZ' — the value IS UTC and the suffix says so.
|
|
5
|
+
// a missing zone is normalized to UTC before parsing.
|
|
5
6
|
const HAS_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
let d: Date
|
|
8
|
+
/** An instant from an ISO/'YYYY-MM-DD HH:MM:SS' string (a zoneless one taken as UTC), a number or a Date. */
|
|
9
|
+
export const parseInstant = (v: unknown): Date => {
|
|
10
10
|
if (typeof v === 'string') {
|
|
11
11
|
const iso = v.replace(' ', 'T')
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
throw new TypeError(`fmtDate: unsupported value of type ${typeof v}`)
|
|
12
|
+
return new Date(iso.includes('T') && !HAS_ZONE.test(iso) ? iso + 'Z' : iso)
|
|
13
|
+
}
|
|
14
|
+
if (typeof v === 'number' || v instanceof Date) {
|
|
15
|
+
return new Date(v)
|
|
17
16
|
}
|
|
18
|
-
|
|
17
|
+
throw new TypeError(`parseInstant: unsupported value of type ${typeof v}`)
|
|
19
18
|
}
|
|
20
19
|
|
|
20
|
+
// A formatter rendering a missing value (null, undefined, '') as ''.
|
|
21
|
+
const blankable =
|
|
22
|
+
(format: (v: unknown) => string) =>
|
|
23
|
+
(v: unknown): string =>
|
|
24
|
+
v == null || v === '' ? '' : format(v)
|
|
25
|
+
|
|
26
|
+
/** 'YYYY-MM-DD HH:MM:SSZ': the instant (parseInstant) in UTC, as the suffix says. */
|
|
27
|
+
export const fmtDate = blankable(v => parseInstant(v).toISOString().replace('T', ' ').slice(0, 19) + 'Z')
|
|
28
|
+
|
|
21
29
|
/** Table-cell boolean: a tick for true, blank for false. */
|
|
22
30
|
export const fmtBool = (v: unknown): string => (v ? '✓' : '')
|
|
23
31
|
|
|
24
32
|
/** Prose boolean for detail views. */
|
|
25
33
|
export const fmtYesNo = (v: unknown): string => (v ? 'yes' : 'no')
|
|
34
|
+
|
|
35
|
+
// A number or a numeric string, as Intl formats it: a string exactly, digit
|
|
36
|
+
// for digit.
|
|
37
|
+
const numeric = (v: unknown): number | Intl.StringNumericLiteral => {
|
|
38
|
+
if (typeof v === 'number') {
|
|
39
|
+
return v
|
|
40
|
+
}
|
|
41
|
+
if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) {
|
|
42
|
+
return v as Intl.StringNumericLiteral
|
|
43
|
+
}
|
|
44
|
+
throw new TypeError(`not a number: ${String(v)}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The ES2023 ceiling: no fraction digit a value carries is rounded away.
|
|
48
|
+
const localeNumber = new Intl.NumberFormat(undefined, { maximumFractionDigits: 100 })
|
|
49
|
+
|
|
50
|
+
/** A number (or numeric string) in the browser locale's notation, never rounded. */
|
|
51
|
+
export const fmtNumber = blankable(v => localeNumber.format(numeric(v)))
|
|
52
|
+
|
|
53
|
+
const BYTE_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB']
|
|
54
|
+
|
|
55
|
+
const byteAmount = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 })
|
|
56
|
+
|
|
57
|
+
/** A byte count in binary units (B, KiB, MiB, …), at most one fraction digit, in the browser locale's notation. */
|
|
58
|
+
export const fmtBytes = blankable(v => {
|
|
59
|
+
let amount = Number(numeric(v))
|
|
60
|
+
let unit = 0
|
|
61
|
+
// A unit holds amounts that read under 1024 once rounded to a tenth.
|
|
62
|
+
while (unit < BYTE_UNITS.length - 1 && Math.abs(Math.round(amount * 10) / 10) >= 1024) {
|
|
63
|
+
amount /= 1024
|
|
64
|
+
unit++
|
|
65
|
+
}
|
|
66
|
+
return `${byteAmount.format(amount)} ${BYTE_UNITS[unit]}`
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/** An Intl date/time style. */
|
|
70
|
+
export type DateStyle = 'full' | 'long' | 'medium' | 'short'
|
|
71
|
+
|
|
72
|
+
// One formatter per style, built on first use.
|
|
73
|
+
const perStyle = (build: (style: DateStyle) => Intl.DateTimeFormat): ((style: DateStyle) => (v: unknown) => string) => {
|
|
74
|
+
const built = new Map<DateStyle, (v: unknown) => string>()
|
|
75
|
+
return style => {
|
|
76
|
+
let format = built.get(style)
|
|
77
|
+
if (!format) {
|
|
78
|
+
const intl = build(style)
|
|
79
|
+
format = blankable(v => intl.format(parseInstant(v)))
|
|
80
|
+
built.set(style, format)
|
|
81
|
+
}
|
|
82
|
+
return format
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A formatter of instants (parseInstant) in the browser's locale and time zone, at an Intl date and time style. */
|
|
87
|
+
export const fmtDateTime = perStyle(style => new Intl.DateTimeFormat(undefined, { dateStyle: style, timeStyle: style }))
|
|
88
|
+
|
|
89
|
+
/** A formatter of calendar dates ('YYYY-MM-DD') in the browser's locale, at an Intl date style; the day never shifts with the time zone. */
|
|
90
|
+
export const fmtDay = perStyle(style => new Intl.DateTimeFormat(undefined, { dateStyle: style, timeZone: 'UTC' }))
|
|
91
|
+
|
|
92
|
+
/** Markup as the one line of text it reads (inlineMarkup's text, whitespace collapsed). */
|
|
93
|
+
export const fmtMarkupText = blankable(v => {
|
|
94
|
+
if (typeof v !== 'string') {
|
|
95
|
+
throw new TypeError(`fmtMarkupText: markup must be a string, not ${typeof v}`)
|
|
96
|
+
}
|
|
97
|
+
return (inlineMarkup(v).textContent ?? '').replace(/\s+/g, ' ').trim()
|
|
98
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Markup rebuilt as one line of inline content, safe to render: what it reads,
|
|
2
|
+
// with only its inline formatting kept. Nothing of the source is carried over
|
|
3
|
+
// but text and the names of allowlisted elements, recreated bare — no
|
|
4
|
+
// attribute survives, so neither does a handler, a URL or a style.
|
|
5
|
+
|
|
6
|
+
/** Inline formatting: recreated, without attributes. */
|
|
7
|
+
const KEPT = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'del', 'ins', 'sub', 'sup', 'code', 'mark', 'small'])
|
|
8
|
+
|
|
9
|
+
/** Never read as text: dropped with everything inside. */
|
|
10
|
+
const DROPPED = new Set(['script', 'style', 'template', 'noscript', 'iframe', 'frame', 'frameset', 'object', 'embed', 'svg', 'math', 'img', 'picture', 'video', 'audio', 'canvas', 'map'])
|
|
11
|
+
|
|
12
|
+
/** Read as a break between runs of text: a single space on the line. */
|
|
13
|
+
const BLOCKS = new Set([
|
|
14
|
+
'address', 'article', 'aside', 'blockquote', 'br', 'caption', 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The markup as a fragment of text and bare allowlisted inline elements: other
|
|
19
|
+
* elements are unwrapped, blocks part runs of text with one space, and what is
|
|
20
|
+
* never read as text (scripts, styles, embedded and media content) is dropped
|
|
21
|
+
* whole. DOMParser's document is inert: nothing in it runs or loads.
|
|
22
|
+
*/
|
|
23
|
+
export const inlineMarkup = (markup: string): DocumentFragment => {
|
|
24
|
+
const out = document.createDocumentFragment()
|
|
25
|
+
// A block boundary owes a space to the next text or kept element, if any
|
|
26
|
+
// text came before.
|
|
27
|
+
let spaceOwed = false
|
|
28
|
+
let anyText = false
|
|
29
|
+
const paySpace = (into: Node): void => {
|
|
30
|
+
if (spaceOwed && anyText) {
|
|
31
|
+
into.appendChild(document.createTextNode(' '))
|
|
32
|
+
}
|
|
33
|
+
spaceOwed = false
|
|
34
|
+
}
|
|
35
|
+
const walk = (from: Node, into: Node): void => {
|
|
36
|
+
for (const node of from.childNodes) {
|
|
37
|
+
if (node instanceof Text) {
|
|
38
|
+
paySpace(into)
|
|
39
|
+
anyText = true
|
|
40
|
+
into.appendChild(document.createTextNode(node.data))
|
|
41
|
+
} else if (node instanceof Element && !DROPPED.has(node.localName)) {
|
|
42
|
+
const block = BLOCKS.has(node.localName)
|
|
43
|
+
spaceOwed ||= block
|
|
44
|
+
let target = into
|
|
45
|
+
if (KEPT.has(node.localName)) {
|
|
46
|
+
paySpace(into)
|
|
47
|
+
target = into.appendChild(document.createElement(node.localName))
|
|
48
|
+
}
|
|
49
|
+
walk(node, target)
|
|
50
|
+
spaceOwed ||= block
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
walk(new DOMParser().parseFromString(markup, 'text/html').body, out)
|
|
55
|
+
return out
|
|
56
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -7,9 +7,13 @@ export type { CrudPanelExposed } from './CrudPanel.vue'
|
|
|
7
7
|
export { default as useRecordEditor, type RecordEditor, type RecordEditorOptions } from './useRecordEditor'
|
|
8
8
|
export { useSortState, type SortState, type SortDir, type ColumnOptions } from './useSortState'
|
|
9
9
|
export { listLoader, type PageArgs } from './listLoader'
|
|
10
|
-
export { fmtDate, fmtBool, fmtYesNo } from './formatters'
|
|
10
|
+
export { parseInstant, fmtDate, fmtBool, fmtYesNo, fmtNumber, fmtBytes, fmtDateTime, fmtDay, fmtMarkupText, type DateStyle } from './formatters'
|
|
11
|
+
export { inlineMarkup } from './inlineMarkup'
|
|
12
|
+
export { default as InlineMarkup } from './InlineMarkup.vue'
|
|
11
13
|
export { useDetailLoader, type DetailLoader } from './useDetailLoader'
|
|
14
|
+
export { default as DetailPane } from './DetailPane.vue'
|
|
12
15
|
export { useAsyncAction, type AsyncAction } from './useAsyncAction'
|
|
13
16
|
export { errorMessage } from './errorMessage'
|
|
14
17
|
export { default as DescriptionList } from './DescriptionList.vue'
|
|
15
18
|
export type { DescriptionItem } from './DescriptionList.vue'
|
|
19
|
+
export { default as NoteActions, suspensionActions, type NoteAction } from './NoteActions.vue'
|