@svgrid/enterprise 2.6.4 → 2.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/dist/SvGridBulkEditDrawer.svelte +158 -0
- package/dist/SvGridBulkEditDrawer.svelte.d.ts +9 -0
- package/dist/SvGridSelectionBar.svelte +452 -0
- package/dist/SvGridSelectionBar.svelte.d.ts +6 -0
- package/dist/SvPivotDesigner.svelte +3 -3
- package/dist/SvPivotDesigner.svelte.d.ts +3 -3
- package/dist/bulk-edit.d.ts +64 -0
- package/dist/bulk-edit.js +152 -0
- package/dist/cdn/svgrid-enterprise.svelte-external.js +6182 -5779
- package/dist/designer/assets/GridMenus-Cgilexpk.js +8 -0
- package/dist/designer/assets/SvDateRangeInput-BV5OXYQ2.css +1 -0
- package/dist/designer/assets/SvDateRangeInput-CVn-yqrQ.js +1 -0
- package/dist/designer/assets/SvDateTimePicker-BdioTrJQ.js +1 -0
- package/dist/designer/assets/SvDateTimePicker-CY8TK4BT.css +1 -0
- package/dist/designer/assets/SvField-BsqiA5me.js +1 -0
- package/dist/designer/assets/SvGridCellEditor-dLY7_kk9.js +2 -0
- package/dist/designer/assets/SvListBox-CVYQcLX9.js +1 -0
- package/dist/designer/assets/{SvListBox-BMSfFJJT.css → SvListBox-DgN8kvjt.css} +1 -1
- package/dist/designer/assets/column-resize-C_inr5zT.js +1 -0
- package/dist/designer/assets/date-format-4DJvNtG2.js +1 -0
- package/dist/designer/assets/{SvDateTimePicker-Cygij-dr.css → date-format-Dzy4mTGh.css} +1 -1
- package/dist/designer/assets/dismissable-BO547MH9.js +1 -0
- package/dist/designer/assets/editor-registry-BjP16H0v.js +1 -0
- package/dist/designer/assets/{index-DxBZuosR.js → index-BvaLmQSd.js} +129 -129
- package/dist/designer/assets/index-Cp7fDJ3b.css +1 -0
- package/dist/designer/assets/{js-scroller.svelte-03aI6ovu.js → js-scroller.svelte-Bhxqb3-c.js} +2 -2
- package/dist/designer/assets/preload-helper-CG0n22O7.js +1 -0
- package/dist/designer/assets/row-resize-B0FWHcNr.js +1 -0
- package/dist/designer/assets/src-7qOYsBug.js +2896 -0
- package/dist/designer/assets/src-B7DNcoQK.css +1 -0
- package/dist/designer/assets/validate-h-aslRV9.js +1 -0
- package/dist/designer/index.html +18 -13
- package/dist/export.d.ts +4 -0
- package/dist/import.d.ts +7 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/install.d.ts +3 -0
- package/dist/install.js +2 -0
- package/dist/node/studio.js +65 -3
- package/dist/pivot.d.ts +11 -0
- package/dist/pivot.js +6 -1
- package/dist/selection-bar.d.ts +7 -0
- package/dist/selection-bar.js +42 -0
- package/dist/studio/emit-project.js +6 -0
- package/dist/studio/ui-components.generated.js +64 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +8 -3
- package/dist/designer/assets/GridMenus-BIU7Nfbr.js +0 -7
- package/dist/designer/assets/SvDateTimePicker-DOBYyLeN.js +0 -1
- package/dist/designer/assets/SvGrid.helpers-C6wxF55r.js +0 -1
- package/dist/designer/assets/SvGridCellEditor-CMtmQINJ.js +0 -2
- package/dist/designer/assets/SvListBox-D-_I_Aff.js +0 -1
- package/dist/designer/assets/dismissable-2EZKoens.js +0 -1
- package/dist/designer/assets/editor-registry-BB1Qb5mp.js +0 -1
- package/dist/designer/assets/index-BfzoL904.css +0 -1
- package/dist/designer/assets/src-D8qr49rE.css +0 -1
- package/dist/designer/assets/src-Dn1Axx6T.js +0 -2896
- /package/dist/designer/assets/{dismissable-BF-9TJSd.css → SvField-BF-9TJSd.css} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The bulk-edit drawer behind the selection bar's `editFields` button.
|
|
4
|
+
*
|
|
5
|
+
* Edits ONE OR MORE fields across ONE OR MORE selected rows. It is the same
|
|
6
|
+
* side drawer + schema form the grid already uses for single-row editing
|
|
7
|
+
* (`SvDrawer` + `SvForm`), so a user who has edited one row here already
|
|
8
|
+
* knows this screen.
|
|
9
|
+
*
|
|
10
|
+
* The rule that makes multi-field bulk edit safe: **only fields you actually
|
|
11
|
+
* change are written.** A field every selected row already agrees on opens
|
|
12
|
+
* showing that value; a field they disagree on opens blank and labelled
|
|
13
|
+
* "Multiple values". Leave either alone and every row keeps what it had.
|
|
14
|
+
*/
|
|
15
|
+
import { untrack } from 'svelte'
|
|
16
|
+
import { SvDrawer, SvForm, type FormField, type FormFieldType } from '@svgrid/grid'
|
|
17
|
+
import {
|
|
18
|
+
applyBulkEdit,
|
|
19
|
+
bulkEditInitialValues,
|
|
20
|
+
bulkEditableFields,
|
|
21
|
+
coerceBulkValue,
|
|
22
|
+
type BulkEditField,
|
|
23
|
+
type BulkEditResult,
|
|
24
|
+
} from './bulk-edit'
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
ctrl,
|
|
28
|
+
open = $bindable(false),
|
|
29
|
+
onApplied,
|
|
30
|
+
}: {
|
|
31
|
+
ctrl: any
|
|
32
|
+
open?: boolean
|
|
33
|
+
onApplied?: (r: BulkEditResult) => void
|
|
34
|
+
} = $props()
|
|
35
|
+
|
|
36
|
+
const messages = $derived(ctrl.messages)
|
|
37
|
+
const columns = $derived(bulkEditableFields(ctrl) as BulkEditField[])
|
|
38
|
+
const count = $derived((ctrl.selectionBarTarget?.ids ?? []).length as number)
|
|
39
|
+
|
|
40
|
+
/** A column's editor type mapped onto the form's control vocabulary. */
|
|
41
|
+
function controlFor(editorType: string, hasOptions: boolean): FormFieldType {
|
|
42
|
+
if (hasOptions) return 'select'
|
|
43
|
+
switch (editorType) {
|
|
44
|
+
case 'number': return 'number'
|
|
45
|
+
case 'checkbox': return 'checkbox'
|
|
46
|
+
case 'date': return 'date'
|
|
47
|
+
case 'datetime': return 'datetime'
|
|
48
|
+
case 'time': return 'time'
|
|
49
|
+
case 'textarea': return 'textarea'
|
|
50
|
+
case 'color': return 'color'
|
|
51
|
+
default: return 'text'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Snapshotted when the drawer opens rather than derived: the form is
|
|
56
|
+
// uncontrolled once mounted, and a baseline that kept re-deriving as rows
|
|
57
|
+
// changed underneath would make "did the user change this?" unanswerable.
|
|
58
|
+
let baseline = $state<Record<string, unknown>>({})
|
|
59
|
+
let mixed = $state<Set<string>>(new Set())
|
|
60
|
+
let formKey = $state(0)
|
|
61
|
+
|
|
62
|
+
// Only `open` is tracked. The snapshot reads `columns` and the grid's rows,
|
|
63
|
+
// and writes state that `fields` derives from - tracking either would make
|
|
64
|
+
// this effect depend on what it writes, which Svelte rejects.
|
|
65
|
+
$effect(() => {
|
|
66
|
+
if (!open) return
|
|
67
|
+
untrack(() => {
|
|
68
|
+
const snapshot = bulkEditInitialValues(ctrl, columns)
|
|
69
|
+
baseline = snapshot.values
|
|
70
|
+
mixed = snapshot.mixed
|
|
71
|
+
// Remount the form so it picks the new initial values up.
|
|
72
|
+
formKey += 1
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const fields = $derived<FormField[]>(
|
|
77
|
+
columns.map((c) => ({
|
|
78
|
+
name: c.id,
|
|
79
|
+
label: c.label,
|
|
80
|
+
type: controlFor(c.editorType, !!c.options?.length),
|
|
81
|
+
// Placeholder only. The same sentence repeated under every differing
|
|
82
|
+
// field was five identical lines of noise - the lead says it once.
|
|
83
|
+
placeholder: mixed.has(c.id) ? messages.bulkEditMixed : undefined,
|
|
84
|
+
options: c.options?.map((o) => ({ value: o.value as string | number, label: o.label })),
|
|
85
|
+
})),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const fill = (t: string, n: number) => t.replace(/\{count\}/g, String(n))
|
|
89
|
+
|
|
90
|
+
function submit(values: Record<string, unknown>) {
|
|
91
|
+
const byId = new Map(columns.map((c) => [c.id, c]))
|
|
92
|
+
const edits: Record<string, unknown> = {}
|
|
93
|
+
for (const [name, raw] of Object.entries(values)) {
|
|
94
|
+
const column = byId.get(name)
|
|
95
|
+
if (!column) continue
|
|
96
|
+
const next = coerceBulkValue(column.editorType, raw)
|
|
97
|
+
// Untouched fields are the ones that keep every row's own value. A field
|
|
98
|
+
// that opened "Multiple values" and was left blank is untouched too.
|
|
99
|
+
if (next === baseline[name]) continue
|
|
100
|
+
if (mixed.has(name) && (raw === '' || raw == null)) continue
|
|
101
|
+
edits[name] = next
|
|
102
|
+
}
|
|
103
|
+
const result = applyBulkEdit(ctrl, edits)
|
|
104
|
+
open = false
|
|
105
|
+
onApplied?.(result)
|
|
106
|
+
}
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<SvDrawer
|
|
110
|
+
bind:open
|
|
111
|
+
side="right"
|
|
112
|
+
title={messages.bulkEditTitle}
|
|
113
|
+
onClose={() => (open = false)}
|
|
114
|
+
>
|
|
115
|
+
<div class="sv-bulk">
|
|
116
|
+
{#if columns.length === 0}
|
|
117
|
+
<p class="sv-bulk-empty">{messages.bulkEditNoFields}</p>
|
|
118
|
+
{:else}
|
|
119
|
+
<p class="sv-bulk-lead">{fill(messages.bulkEditLead, count)}</p>
|
|
120
|
+
{#key formKey}
|
|
121
|
+
<SvForm
|
|
122
|
+
{fields}
|
|
123
|
+
initial={baseline}
|
|
124
|
+
columns={1}
|
|
125
|
+
submitLabel={fill(messages.bulkEditApply, count)}
|
|
126
|
+
cancelLabel={messages.bulkEditCancel}
|
|
127
|
+
onSubmit={submit}
|
|
128
|
+
onCancel={() => (open = false)}
|
|
129
|
+
/>
|
|
130
|
+
{/key}
|
|
131
|
+
{/if}
|
|
132
|
+
</div>
|
|
133
|
+
</SvDrawer>
|
|
134
|
+
|
|
135
|
+
<style>
|
|
136
|
+
/* Fill the drawer so the actions can sit against its bottom edge. Without
|
|
137
|
+
this the buttons stop wherever the last field happens to end, with the
|
|
138
|
+
rest of the panel empty below them - which reads as an unfinished panel
|
|
139
|
+
rather than a short form. */
|
|
140
|
+
.sv-bulk { display: flex; flex-direction: column; min-height: 100%; }
|
|
141
|
+
.sv-bulk :global(.sv-form) { flex: 1; }
|
|
142
|
+
.sv-bulk :global(.sv-form__actions) {
|
|
143
|
+
margin-top: auto;
|
|
144
|
+
padding-top: 14px;
|
|
145
|
+
border-top: 1px solid var(--sg-border, #e2e8f0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.sv-bulk-lead {
|
|
149
|
+
margin: 0 0 14px;
|
|
150
|
+
font-size: 13px;
|
|
151
|
+
color: var(--sg-muted, #64748b);
|
|
152
|
+
}
|
|
153
|
+
.sv-bulk-empty {
|
|
154
|
+
margin: 0;
|
|
155
|
+
font-size: 13px;
|
|
156
|
+
color: var(--sg-muted, #64748b);
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type BulkEditResult } from './bulk-edit';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
ctrl: any;
|
|
4
|
+
open?: boolean;
|
|
5
|
+
onApplied?: (r: BulkEditResult) => void;
|
|
6
|
+
};
|
|
7
|
+
declare const SvGridBulkEditDrawer: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
8
|
+
type SvGridBulkEditDrawer = ReturnType<typeof SvGridBulkEditDrawer>;
|
|
9
|
+
export default SvGridBulkEditDrawer;
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The bulk-action bar (Pro renderer for `<SvGrid selectionBar={...}>`).
|
|
4
|
+
*
|
|
5
|
+
* Floats over the grid while rows are selected: a count chip, then the
|
|
6
|
+
* actions that apply to the whole selection, then a clear button - the shape
|
|
7
|
+
* an issue tracker uses for bulk edit.
|
|
8
|
+
*
|
|
9
|
+
* The grid decides WHETHER this exists and normalises the prop; this renders
|
|
10
|
+
* it. Everything it reads comes off the controller handle, so the free grid
|
|
11
|
+
* needs no knowledge of what lives here.
|
|
12
|
+
*/
|
|
13
|
+
import { onMount } from 'svelte'
|
|
14
|
+
import SvGridBulkEditDrawer from './SvGridBulkEditDrawer.svelte'
|
|
15
|
+
|
|
16
|
+
let { ctrl }: { ctrl: any } = $props()
|
|
17
|
+
|
|
18
|
+
type Target = { rows: unknown[]; ids: string[] }
|
|
19
|
+
type Action = {
|
|
20
|
+
key: string
|
|
21
|
+
label: string
|
|
22
|
+
icon?: string
|
|
23
|
+
danger?: boolean
|
|
24
|
+
hidden?: (t: Target) => boolean
|
|
25
|
+
disabled?: (t: Target) => boolean
|
|
26
|
+
action: (t: Target) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const target = $derived(ctrl.selectionBarTarget as Target)
|
|
30
|
+
const count = $derived(target.ids.length)
|
|
31
|
+
const messages = $derived(ctrl.messages)
|
|
32
|
+
const position = $derived(ctrl.selectionBarPosition as 'top' | 'bottom')
|
|
33
|
+
const maxVisible = $derived(ctrl.selectionBarMaxVisible as number)
|
|
34
|
+
const hideClear = $derived(ctrl.selectionBarHideClear as boolean)
|
|
35
|
+
|
|
36
|
+
let bulkEditOpen = $state(false)
|
|
37
|
+
|
|
38
|
+
// Single-path 24x24 icons, matching the stroke style the grid uses elsewhere.
|
|
39
|
+
const ICON = {
|
|
40
|
+
selectAll: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
|
|
41
|
+
editFields: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7M18.5 2.5a2.1 2.1 0 0 1 3 3L12 15l-4 1 1-4Z',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Turn the built-in string keys into real actions. */
|
|
45
|
+
function resolve(entry: Action | string): Action | 'separator' | null {
|
|
46
|
+
if (typeof entry !== 'string') return entry
|
|
47
|
+
if (entry === 'separator') return 'separator'
|
|
48
|
+
if (entry === 'selectAll') {
|
|
49
|
+
return {
|
|
50
|
+
key: 'selectAll',
|
|
51
|
+
label: messages.selectionBarSelectAll,
|
|
52
|
+
icon: ICON.selectAll,
|
|
53
|
+
// Nothing to do once the whole view is already picked.
|
|
54
|
+
disabled: () => ctrl.headerSelectionState === 'all',
|
|
55
|
+
action: () => ctrl.toggleSelectAllRows(),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (entry === 'editFields') {
|
|
59
|
+
return {
|
|
60
|
+
key: 'editFields',
|
|
61
|
+
label: messages.selectionBarEditFields,
|
|
62
|
+
icon: ICON.editFields,
|
|
63
|
+
action: () => (bulkEditOpen = true),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Predicates run per render against the LIVE selection, so a rule like "only
|
|
70
|
+
// at two or more rows" flips as the user ticks rather than freezing at the
|
|
71
|
+
// first paint.
|
|
72
|
+
const entries = $derived(
|
|
73
|
+
(ctrl.selectionBarActions as Array<Action | string>)
|
|
74
|
+
.map(resolve)
|
|
75
|
+
.filter((e): e is Action | 'separator' => e != null)
|
|
76
|
+
.filter((e) => e === 'separator' || !e.hidden?.(target)),
|
|
77
|
+
)
|
|
78
|
+
// Separators do not count towards the limit - they are punctuation, not
|
|
79
|
+
// buttons, and letting them push a real action into the menu would be odd.
|
|
80
|
+
const buttons = $derived(entries.filter((e): e is Action => e !== 'separator'))
|
|
81
|
+
const overflow = $derived(buttons.length > maxVisible ? buttons.slice(maxVisible) : [])
|
|
82
|
+
const overflowKeys = $derived(new Set(overflow.map((a) => a.key)))
|
|
83
|
+
const inline = $derived(entries.filter((e) => e === 'separator' || !overflowKeys.has(e.key)))
|
|
84
|
+
|
|
85
|
+
let overflowOpen = $state(false)
|
|
86
|
+
let barEl = $state<HTMLElement | null>(null)
|
|
87
|
+
|
|
88
|
+
const fill = (template: string, n: number) => template.replace(/\{count\}/g, String(n))
|
|
89
|
+
const clearSelection = () => ctrl.grid.setRowSelection(() => ({}))
|
|
90
|
+
|
|
91
|
+
function run(a: Action) {
|
|
92
|
+
overflowOpen = false
|
|
93
|
+
a.action(target)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Roving focus, which is what `role="toolbar"` promises a screen reader:
|
|
98
|
+
* one tab stop for the bar, then Left/Right between its buttons. Without it
|
|
99
|
+
* a bar with six actions puts six stops between the grid and whatever comes
|
|
100
|
+
* after it.
|
|
101
|
+
*/
|
|
102
|
+
function onKeydown(event: KeyboardEvent) {
|
|
103
|
+
if (event.key === 'Escape') {
|
|
104
|
+
if (overflowOpen) {
|
|
105
|
+
overflowOpen = false
|
|
106
|
+
event.stopPropagation()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
clearSelection()
|
|
110
|
+
event.stopPropagation()
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
|
|
114
|
+
const items = [...(barEl?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])]
|
|
115
|
+
if (items.length < 2) return
|
|
116
|
+
const at = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
117
|
+
if (at < 0) return
|
|
118
|
+
event.preventDefault()
|
|
119
|
+
const step = event.key === 'ArrowRight' ? 1 : -1
|
|
120
|
+
items[(at + step + items.length) % items.length]!.focus()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Close the overflow menu on any pointerdown that is not inside the bar.
|
|
124
|
+
onMount(() => {
|
|
125
|
+
const onDown = (e: PointerEvent) => {
|
|
126
|
+
if (!overflowOpen) return
|
|
127
|
+
if (barEl && e.composedPath().includes(barEl)) return
|
|
128
|
+
overflowOpen = false
|
|
129
|
+
}
|
|
130
|
+
window.addEventListener('pointerdown', onDown, true)
|
|
131
|
+
return () => window.removeEventListener('pointerdown', onDown, true)
|
|
132
|
+
})
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
136
|
+
<div
|
|
137
|
+
bind:this={barEl}
|
|
138
|
+
class="sv-selbar"
|
|
139
|
+
data-position={position}
|
|
140
|
+
role="toolbar"
|
|
141
|
+
aria-label={messages.selectionBarLabel}
|
|
142
|
+
aria-orientation="horizontal"
|
|
143
|
+
onkeydown={onKeydown}
|
|
144
|
+
>
|
|
145
|
+
<span class="sv-selbar-count" aria-live="polite">
|
|
146
|
+
<span class="sv-selbar-chip">{count}</span>
|
|
147
|
+
<span class="sv-selbar-count-label">{messages.selectionBarCount}</span>
|
|
148
|
+
</span>
|
|
149
|
+
|
|
150
|
+
{#if inline.length}
|
|
151
|
+
<span class="sv-selbar-sep" aria-hidden="true"></span>
|
|
152
|
+
{/if}
|
|
153
|
+
|
|
154
|
+
{#each inline as entry, i (entry === 'separator' ? `sep${i}` : entry.key)}
|
|
155
|
+
{#if entry === 'separator'}
|
|
156
|
+
<span class="sv-selbar-sep" aria-hidden="true"></span>
|
|
157
|
+
{:else}
|
|
158
|
+
{@const isDisabled = entry.disabled?.(target) ?? false}
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
class="sv-selbar-btn"
|
|
162
|
+
class:is-danger={entry.danger}
|
|
163
|
+
class:has-icon={!!entry.icon}
|
|
164
|
+
title={entry.label}
|
|
165
|
+
aria-label={entry.label}
|
|
166
|
+
disabled={isDisabled}
|
|
167
|
+
onclick={() => run(entry)}
|
|
168
|
+
>
|
|
169
|
+
{#if entry.icon}
|
|
170
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
171
|
+
<path d={entry.icon} />
|
|
172
|
+
</svg>
|
|
173
|
+
{/if}
|
|
174
|
+
<span>{entry.label}</span>
|
|
175
|
+
</button>
|
|
176
|
+
{/if}
|
|
177
|
+
{/each}
|
|
178
|
+
|
|
179
|
+
{#if overflow.length}
|
|
180
|
+
<div class="sv-selbar-more">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
class="sv-selbar-btn sv-selbar-more-btn"
|
|
184
|
+
aria-haspopup="menu"
|
|
185
|
+
aria-expanded={overflowOpen}
|
|
186
|
+
aria-label={messages.selectionBarMore}
|
|
187
|
+
title={messages.selectionBarMore}
|
|
188
|
+
onclick={() => (overflowOpen = !overflowOpen)}
|
|
189
|
+
>
|
|
190
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true">
|
|
191
|
+
<circle cx="5" cy="12" r="1.8" /><circle cx="12" cy="12" r="1.8" /><circle cx="19" cy="12" r="1.8" />
|
|
192
|
+
</svg>
|
|
193
|
+
</button>
|
|
194
|
+
{#if overflowOpen}
|
|
195
|
+
<div class="sv-selbar-menu" data-position={position} role="menu">
|
|
196
|
+
{#each overflow as a (a.key)}
|
|
197
|
+
{@const isDisabled = a.disabled?.(target) ?? false}
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
class="sv-selbar-menu-item"
|
|
201
|
+
class:is-danger={a.danger}
|
|
202
|
+
role="menuitem"
|
|
203
|
+
disabled={isDisabled}
|
|
204
|
+
onclick={() => run(a)}
|
|
205
|
+
>
|
|
206
|
+
{#if a.icon}
|
|
207
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
208
|
+
<path d={a.icon} />
|
|
209
|
+
</svg>
|
|
210
|
+
{/if}
|
|
211
|
+
<span>{a.label}</span>
|
|
212
|
+
</button>
|
|
213
|
+
{/each}
|
|
214
|
+
</div>
|
|
215
|
+
{/if}
|
|
216
|
+
</div>
|
|
217
|
+
{/if}
|
|
218
|
+
|
|
219
|
+
{#if !hideClear}
|
|
220
|
+
<span class="sv-selbar-sep" aria-hidden="true"></span>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
class="sv-selbar-clear"
|
|
224
|
+
aria-label={messages.selectionBarClear}
|
|
225
|
+
title={messages.selectionBarClear}
|
|
226
|
+
onclick={clearSelection}
|
|
227
|
+
>
|
|
228
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
229
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
230
|
+
</svg>
|
|
231
|
+
</button>
|
|
232
|
+
{/if}
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<SvGridBulkEditDrawer {ctrl} bind:open={bulkEditOpen} />
|
|
236
|
+
|
|
237
|
+
<style>
|
|
238
|
+
/* `absolute` against `.sv-grid-root`, not `fixed`: fixed escapes the grid and
|
|
239
|
+
floats over the page, which is wrong when there are two grids on a screen,
|
|
240
|
+
and it anchors to the wrong box inside any ancestor with a transform. */
|
|
241
|
+
.sv-selbar {
|
|
242
|
+
position: absolute;
|
|
243
|
+
left: 50%;
|
|
244
|
+
/*
|
|
245
|
+
* Above the grid's own custom scrollbars, which sit at 40 / 41 / 42.
|
|
246
|
+
*
|
|
247
|
+
* At 30 the bar tied with pinned cells and the sticky header and lost to
|
|
248
|
+
* the scrollbars outright: a bottom-anchored bar overlaps the horizontal
|
|
249
|
+
* scrollbar by a pixel and the vertical one by a few, and the scrollbar
|
|
250
|
+
* painted over the bar's corner and shadow.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately BELOW find-in-grid (60) and the menus / backdrops (900+):
|
|
253
|
+
* an overlay the user just opened should win over persistent chrome.
|
|
254
|
+
*/
|
|
255
|
+
z-index: 45;
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: center;
|
|
258
|
+
gap: 2px;
|
|
259
|
+
max-width: calc(100% - 24px);
|
|
260
|
+
--selbar-pad-y: 6px;
|
|
261
|
+
padding: var(--selbar-pad-y) 8px;
|
|
262
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
263
|
+
border-radius: 8px;
|
|
264
|
+
background: var(--sg-bg, #fff);
|
|
265
|
+
box-shadow: 0 8px 28px rgb(15 23 42 / 20%), 0 1px 2px rgb(15 23 42 / 10%);
|
|
266
|
+
font-family: var(--sg-font, inherit);
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
color: var(--sg-fg, #0f172a);
|
|
269
|
+
/* NO overflow here. Any value but `visible` clips absolutely-positioned
|
|
270
|
+
descendants, and the "..." menu is one - setting `overflow-x: auto` as
|
|
271
|
+
an anti-overlap backstop made the menu invisible and unclickable.
|
|
272
|
+
Buttons shrink instead; see .sv-selbar-btn. */
|
|
273
|
+
}
|
|
274
|
+
.sv-selbar[data-position='bottom'] {
|
|
275
|
+
bottom: 16px;
|
|
276
|
+
transform: translateX(-50%);
|
|
277
|
+
animation: sv-selbar-up 140ms ease-out;
|
|
278
|
+
}
|
|
279
|
+
.sv-selbar[data-position='top'] {
|
|
280
|
+
top: 16px;
|
|
281
|
+
transform: translateX(-50%);
|
|
282
|
+
animation: sv-selbar-down 140ms ease-out;
|
|
283
|
+
}
|
|
284
|
+
/* The entrance is what makes the bar read as arriving rather than as a row
|
|
285
|
+
that was always there. Both keyframes keep the -50% X so the slide is
|
|
286
|
+
vertical only. */
|
|
287
|
+
@keyframes sv-selbar-up {
|
|
288
|
+
from { transform: translate(-50%, 10px); opacity: 0; }
|
|
289
|
+
to { transform: translate(-50%, 0); opacity: 1; }
|
|
290
|
+
}
|
|
291
|
+
@keyframes sv-selbar-down {
|
|
292
|
+
from { transform: translate(-50%, -10px); opacity: 0; }
|
|
293
|
+
to { transform: translate(-50%, 0); opacity: 1; }
|
|
294
|
+
}
|
|
295
|
+
@media (prefers-reduced-motion: reduce) {
|
|
296
|
+
.sv-selbar[data-position='bottom'],
|
|
297
|
+
.sv-selbar[data-position='top'] { animation: none; }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* Count as a chip + label rather than one string: the number is the thing a
|
|
301
|
+
user checks before hitting a destructive button, so it gets its own
|
|
302
|
+
high-contrast block instead of blending into the sentence. */
|
|
303
|
+
.sv-selbar-count {
|
|
304
|
+
display: inline-flex;
|
|
305
|
+
align-items: center;
|
|
306
|
+
gap: 7px;
|
|
307
|
+
padding: 0 6px 0 4px;
|
|
308
|
+
white-space: nowrap;
|
|
309
|
+
}
|
|
310
|
+
.sv-selbar-chip {
|
|
311
|
+
display: inline-flex;
|
|
312
|
+
align-items: center;
|
|
313
|
+
justify-content: center;
|
|
314
|
+
min-width: 20px;
|
|
315
|
+
height: 20px;
|
|
316
|
+
padding: 0 6px;
|
|
317
|
+
border-radius: 4px;
|
|
318
|
+
background: var(--sg-accent, #2563eb);
|
|
319
|
+
color: var(--sg-on-accent, #fff);
|
|
320
|
+
font-size: 12px;
|
|
321
|
+
font-weight: 700;
|
|
322
|
+
font-variant-numeric: tabular-nums;
|
|
323
|
+
}
|
|
324
|
+
.sv-selbar-count-label { color: var(--sg-muted, #64748b); }
|
|
325
|
+
|
|
326
|
+
.sv-selbar-sep {
|
|
327
|
+
width: 1px;
|
|
328
|
+
align-self: stretch;
|
|
329
|
+
margin: 3px 4px;
|
|
330
|
+
background: var(--sg-border, #e2e8f0);
|
|
331
|
+
flex: none;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.sv-selbar-btn {
|
|
335
|
+
display: inline-flex;
|
|
336
|
+
align-items: center;
|
|
337
|
+
gap: 7px;
|
|
338
|
+
/* A flex item will not shrink below its content unless told to, which is
|
|
339
|
+
how six nowrap labels ended up drawn on top of each other on a phone.
|
|
340
|
+
With min-width 0 the label ellipsises instead. */
|
|
341
|
+
min-width: 0;
|
|
342
|
+
border: 1px solid transparent;
|
|
343
|
+
border-radius: 6px;
|
|
344
|
+
background: transparent;
|
|
345
|
+
color: var(--sg-fg, #0f172a);
|
|
346
|
+
padding: 5px 9px;
|
|
347
|
+
font: inherit;
|
|
348
|
+
white-space: nowrap;
|
|
349
|
+
cursor: pointer;
|
|
350
|
+
}
|
|
351
|
+
.sv-selbar-btn:hover:not(:disabled) {
|
|
352
|
+
background: var(--sg-row-hover-bg, rgb(148 163 184 / 14%));
|
|
353
|
+
}
|
|
354
|
+
.sv-selbar-btn:disabled { opacity: 0.4; cursor: default; }
|
|
355
|
+
/* Not red by default. An issue tracker's Delete sits in the same neutral row
|
|
356
|
+
as everything else, and a bar of one red button trains people to ignore
|
|
357
|
+
the colour. `danger: true` is there for when an action really warrants it. */
|
|
358
|
+
.sv-selbar-btn.is-danger { color: var(--sg-danger, #dc2626); }
|
|
359
|
+
.sv-selbar-btn.is-danger:hover:not(:disabled) {
|
|
360
|
+
background: color-mix(in srgb, var(--sg-danger, #dc2626) 12%, transparent 88%);
|
|
361
|
+
}
|
|
362
|
+
.sv-selbar-more-btn { padding: 5px 7px; flex: none; }
|
|
363
|
+
.sv-selbar-btn > span {
|
|
364
|
+
overflow: hidden;
|
|
365
|
+
text-overflow: ellipsis;
|
|
366
|
+
}
|
|
367
|
+
.sv-selbar-btn > svg { flex: none; }
|
|
368
|
+
|
|
369
|
+
.sv-selbar-more { position: relative; display: inline-flex; }
|
|
370
|
+
.sv-selbar-menu {
|
|
371
|
+
position: absolute;
|
|
372
|
+
right: 0;
|
|
373
|
+
min-width: 172px;
|
|
374
|
+
padding: 4px;
|
|
375
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
376
|
+
border-radius: 8px;
|
|
377
|
+
background: var(--sg-bg, #fff);
|
|
378
|
+
box-shadow: 0 8px 28px rgb(15 23 42 / 20%);
|
|
379
|
+
display: flex;
|
|
380
|
+
flex-direction: column;
|
|
381
|
+
gap: 1px;
|
|
382
|
+
z-index: 1;
|
|
383
|
+
}
|
|
384
|
+
/* Open away from the edge the bar is pinned to, so the menu never falls off
|
|
385
|
+
the grid: a bottom bar opens upward, a top bar opens downward.
|
|
386
|
+
|
|
387
|
+
The offset clears the BAR, not the button. `100%` is the trigger wrapper,
|
|
388
|
+
which sits inside the bar's vertical padding - so a plain `100% + 6px`
|
|
389
|
+
left the menu overlapping the bar by the padding. Hence + --selbar-pad-y. */
|
|
390
|
+
.sv-selbar-menu[data-position='bottom'] { bottom: calc(100% + var(--selbar-pad-y) + 6px); }
|
|
391
|
+
.sv-selbar-menu[data-position='top'] { top: calc(100% + var(--selbar-pad-y) + 6px); }
|
|
392
|
+
|
|
393
|
+
.sv-selbar-menu-item {
|
|
394
|
+
display: flex;
|
|
395
|
+
align-items: center;
|
|
396
|
+
gap: 8px;
|
|
397
|
+
width: 100%;
|
|
398
|
+
border: 0;
|
|
399
|
+
border-radius: 5px;
|
|
400
|
+
background: transparent;
|
|
401
|
+
color: var(--sg-fg, #0f172a);
|
|
402
|
+
padding: 7px 9px;
|
|
403
|
+
font: inherit;
|
|
404
|
+
text-align: left;
|
|
405
|
+
cursor: pointer;
|
|
406
|
+
}
|
|
407
|
+
.sv-selbar-menu-item:hover:not(:disabled) {
|
|
408
|
+
background: var(--sg-row-hover-bg, rgb(148 163 184 / 14%));
|
|
409
|
+
}
|
|
410
|
+
.sv-selbar-menu-item:disabled { opacity: 0.4; cursor: default; }
|
|
411
|
+
.sv-selbar-menu-item.is-danger { color: var(--sg-danger, #dc2626); }
|
|
412
|
+
|
|
413
|
+
.sv-selbar-clear {
|
|
414
|
+
display: inline-flex;
|
|
415
|
+
align-items: center;
|
|
416
|
+
justify-content: center;
|
|
417
|
+
width: 26px;
|
|
418
|
+
height: 26px;
|
|
419
|
+
border: 0;
|
|
420
|
+
border-radius: 6px;
|
|
421
|
+
background: transparent;
|
|
422
|
+
color: var(--sg-muted, #64748b);
|
|
423
|
+
cursor: pointer;
|
|
424
|
+
flex: none;
|
|
425
|
+
}
|
|
426
|
+
.sv-selbar-clear:hover {
|
|
427
|
+
background: var(--sg-row-hover-bg, rgb(148 163 184 / 14%));
|
|
428
|
+
color: var(--sg-fg, #0f172a);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.sv-selbar button:focus-visible {
|
|
432
|
+
outline: 2px solid var(--sg-accent, #2563eb);
|
|
433
|
+
outline-offset: 1px;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* Narrow: drop to icons and keep the count chip. Six labelled buttons do not
|
|
437
|
+
fit a phone, and a bar you have to scroll sideways is a bar whose last
|
|
438
|
+
action nobody reaches.
|
|
439
|
+
|
|
440
|
+
Only buttons that HAVE an icon lose their label - an icon-less action would
|
|
441
|
+
otherwise render as an empty box. The label survives as the tooltip and as
|
|
442
|
+
the accessible name, which is on the button itself, so nothing is lost to a
|
|
443
|
+
screen reader. */
|
|
444
|
+
@media (max-width: 640px) {
|
|
445
|
+
.sv-selbar { gap: 0; padding-left: 6px; }
|
|
446
|
+
.sv-selbar-btn.has-icon span { display: none; }
|
|
447
|
+
.sv-selbar-btn.has-icon { padding: 6px 8px; }
|
|
448
|
+
.sv-selbar-count { padding-right: 2px; }
|
|
449
|
+
.sv-selbar-count-label { display: none; }
|
|
450
|
+
.sv-selbar-sep { margin: 3px 2px; }
|
|
451
|
+
}
|
|
452
|
+
</style>
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
* grid. `'top'` (default) keeps the classic Excel layout: left rail +
|
|
101
101
|
* a horizontal row of wells above the grid. `'right'` docks everything
|
|
102
102
|
* as a single vertical tool panel on the right of the grid - the
|
|
103
|
-
* "enterprise data grid"
|
|
103
|
+
* conventional "enterprise data grid" pivot-panel arrangement.
|
|
104
104
|
*/
|
|
105
105
|
panelPosition?: 'top' | 'right'
|
|
106
106
|
/** Width of the docked panel when `panelPosition='right'`. Default 280. */
|
|
@@ -131,7 +131,7 @@
|
|
|
131
131
|
*/
|
|
132
132
|
columnVirtualization?: boolean
|
|
133
133
|
/**
|
|
134
|
-
* Render the field picker as
|
|
134
|
+
* Render the field picker as a **Columns** tool panel: a
|
|
135
135
|
* collapsible column-group TREE whose checkboxes toggle column VISIBILITY
|
|
136
136
|
* (with a select-all), instead of the flat "tick to add to a well" list.
|
|
137
137
|
* Fields still drag into the Rows / Columns / Values wells. The embedded
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
onHiddenFieldsChange?: (hidden: string[]) => void
|
|
145
145
|
/**
|
|
146
146
|
* Show a vertical Columns / Filters tab rail on the docked panel
|
|
147
|
-
* (`panelPosition='right'`). The Filters tab is
|
|
147
|
+
* (`panelPosition='right'`). The Filters tab is a set-filter
|
|
148
148
|
* builder: "Add Filter" -> pick a column -> tick which values pass. Filters
|
|
149
149
|
* narrow the source rows (they drive `layout.filters`). Default false.
|
|
150
150
|
*/
|
|
@@ -95,7 +95,7 @@ declare function $$render<T extends PivotInputRow>(): {
|
|
|
95
95
|
* grid. `'top'` (default) keeps the classic Excel layout: left rail +
|
|
96
96
|
* a horizontal row of wells above the grid. `'right'` docks everything
|
|
97
97
|
* as a single vertical tool panel on the right of the grid - the
|
|
98
|
-
* "enterprise data grid"
|
|
98
|
+
* conventional "enterprise data grid" pivot-panel arrangement.
|
|
99
99
|
*/
|
|
100
100
|
panelPosition?: "top" | "right";
|
|
101
101
|
/** Width of the docked panel when `panelPosition='right'`. Default 280. */
|
|
@@ -133,7 +133,7 @@ declare function $$render<T extends PivotInputRow>(): {
|
|
|
133
133
|
*/
|
|
134
134
|
columnVirtualization?: boolean;
|
|
135
135
|
/**
|
|
136
|
-
* Render the field picker as
|
|
136
|
+
* Render the field picker as a **Columns** tool panel: a
|
|
137
137
|
* collapsible column-group TREE whose checkboxes toggle column VISIBILITY
|
|
138
138
|
* (with a select-all), instead of the flat "tick to add to a well" list.
|
|
139
139
|
* Fields still drag into the Rows / Columns / Values wells. The embedded
|
|
@@ -146,7 +146,7 @@ declare function $$render<T extends PivotInputRow>(): {
|
|
|
146
146
|
onHiddenFieldsChange?: (hidden: string[]) => void;
|
|
147
147
|
/**
|
|
148
148
|
* Show a vertical Columns / Filters tab rail on the docked panel
|
|
149
|
-
* (`panelPosition='right'`). The Filters tab is
|
|
149
|
+
* (`panelPosition='right'`). The Filters tab is a set-filter
|
|
150
150
|
* builder: "Add Filter" -> pick a column -> tick which values pass. Filters
|
|
151
151
|
* narrow the source rows (they drive `layout.filters`). Default false.
|
|
152
152
|
*/
|