@greendrake/ui-data 0.6.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 CHANGED
@@ -158,7 +158,7 @@ const table = ref<InfiniteScrollTableExposed<User>>()
158
158
 
159
159
  ## MasterDetail
160
160
 
161
- Resizable master pane on the left, tabbed detail panes on the right (a `TabBar` from `@greendrake/ui` with closable tabs). Props: `initialWidth` (default 150), `minWidth` (default 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`.
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
- - `fmtDate(v: unknown): string` — `'YYYY-MM-DD HH:MM:SSZ'` in UTC from an ISO/`'YYYY-MM-DD HH:MM:SS'` string (a zoneless string is taken as UTC), a number or a `Date`; `''` for `null`/`undefined`/`''`; throws on other types.
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.6.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.6.0",
37
- "@greendrake/ui": "0.6.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.6.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
- deleteConfirmMessage?: string | null
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
- const deleteConfirm = ref(false)
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
- if (props.deleteConfirmMessage) {
148
- deleteConfirm.value = true
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 = false
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 = false">
235
- <p>{{ deleteConfirmMessage }}</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 resizeCol = ref<{ index: number; startX: number; startWidth: number } | null>(null)
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
- const id = keyOf(record)
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
- if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
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
- resizeCol.value = {
148
- index,
149
- startX: event.clientX,
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': resizeCol }">
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
- user-select: none;
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>
@@ -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
- 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
- )
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
- <div class="MasterDetail">
113
- <div class="MasterDetail__master" :style="masterStyle">
81
+ <SplitPane class="MasterDetail" :initial-width="initialWidth" :min-width="minWidth">
82
+ <template #start>
114
83
  <slot name="master" :open-tab="openTab"></slot>
115
- </div>
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
- </div>
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. Output is always
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
- export const fmtDate = (v: unknown): string => {
8
- if (v == null || v === '') return ''
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
- 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}`)
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
- return d.toISOString().replace('T', ' ').slice(0, 19) + 'Z'
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'