@svgrid/grid 2.2.0 → 2.2.1
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/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +178 -178
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvColorInput.svelte +187 -187
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvGrid.svelte +3131 -3131
- package/dist/SvGridBoard.svelte +2339 -2339
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridDropdown.svelte +689 -689
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +117 -117
- package/dist/SvNumberInput.svelte +131 -131
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPopover.svelte +139 -139
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvTooltip.svelte +127 -127
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +377 -377
- package/dist/SvTreeSelect.svelte +230 -230
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/chart-export.js +8 -8
- package/package.json +10 -10
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +178 -178
- package/src/SvCarousel.svelte +141 -141
- package/src/SvColorInput.svelte +187 -187
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvDrawer.svelte +250 -250
- package/src/SvGrid.controller.svelte.ts +3158 -3158
- package/src/SvGrid.svelte +3131 -3131
- package/src/SvGrid.types.ts +1489 -1489
- package/src/SvGridBoard.svelte +2339 -2339
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridDropdown.svelte +689 -689
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +117 -117
- package/src/SvNumberInput.svelte +131 -131
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPopover.svelte +139 -139
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvTooltip.svelte +127 -127
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +377 -377
- package/src/SvTreeSelect.svelte +230 -230
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/build-api.ts +870 -870
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +179 -179
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +162 -162
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +202 -202
- package/src/createPopoverSelect.svelte.ts +206 -206
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/flex-render.ts +3 -3
- package/src/index.ts +623 -623
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/menus.ts +639 -639
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +501 -501
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +527 -527
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +159 -159
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
package/src/SvColorInput.svelte
CHANGED
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* SvColorInput - a color swatch that opens a portalled popover with a hex
|
|
4
|
-
* field, a native picker, and a preset palette. Parity: Smart `smart-color-input`.
|
|
5
|
-
* Emits a hex string. Popover escapes the grid scroll container via popover.ts.
|
|
6
|
-
*
|
|
7
|
-
* The styled renderer over the headless `createColorInput` core: hex
|
|
8
|
-
* normalization, palette, draft field and open/close state come from the core;
|
|
9
|
-
* the portal + anchored measurement stay here (render concerns).
|
|
10
|
-
*/
|
|
11
|
-
import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
|
|
12
|
-
import { createDismissableLayer } from './a11y/dismissable'
|
|
13
|
-
import SvField from './SvField.svelte'
|
|
14
|
-
import { nextEditorId, resolveMessages, type SvEditorProps } from './editor-contract'
|
|
15
|
-
import { createColorInput } from './createColorInput.svelte'
|
|
16
|
-
|
|
17
|
-
/** User-facing strings for the color popover (localizable via `messages`). */
|
|
18
|
-
type ColorMessages = { dialog: string; picker: string; hex: string }
|
|
19
|
-
const DEFAULT_MESSAGES: ColorMessages = { dialog: 'Choose color', picker: 'Color picker', hex: 'Hex value' }
|
|
20
|
-
|
|
21
|
-
type Props = SvEditorProps & {
|
|
22
|
-
value?: string
|
|
23
|
-
onChange?: (hex: string) => void
|
|
24
|
-
/** Preset swatches. */
|
|
25
|
-
palette?: string[]
|
|
26
|
-
autoOpen?: boolean
|
|
27
|
-
/** Override the built-in popover strings. */
|
|
28
|
-
messages?: Partial<ColorMessages>
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let {
|
|
32
|
-
value = $bindable('#3b82f6'),
|
|
33
|
-
onChange,
|
|
34
|
-
disabled = false,
|
|
35
|
-
readonly = false,
|
|
36
|
-
palette,
|
|
37
|
-
name,
|
|
38
|
-
size = 'md',
|
|
39
|
-
ariaLabel,
|
|
40
|
-
invalid = false,
|
|
41
|
-
required = false,
|
|
42
|
-
error,
|
|
43
|
-
label,
|
|
44
|
-
hint,
|
|
45
|
-
dir,
|
|
46
|
-
id,
|
|
47
|
-
autoOpen = false,
|
|
48
|
-
messages,
|
|
49
|
-
}: Props = $props()
|
|
50
|
-
|
|
51
|
-
const autoId = nextEditorId('sv-color')
|
|
52
|
-
const uid = $derived(id ?? autoId)
|
|
53
|
-
const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
|
|
54
|
-
|
|
55
|
-
const col = createColorInput({
|
|
56
|
-
value: () => value,
|
|
57
|
-
// Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
|
|
58
|
-
onChange: (hex) => { value = hex; onChange?.(hex) },
|
|
59
|
-
disabled: () => disabled,
|
|
60
|
-
readonly: () => readonly,
|
|
61
|
-
palette: () => palette as string[],
|
|
62
|
-
id: () => uid,
|
|
63
|
-
invalid: () => invalid,
|
|
64
|
-
required: () => required,
|
|
65
|
-
error: () => error,
|
|
66
|
-
hint: () => hint,
|
|
67
|
-
ariaLabel: () => ariaLabel,
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
71
|
-
let panelEl = $state<HTMLDivElement | null>(null)
|
|
72
|
-
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
73
|
-
|
|
74
|
-
function updatePos() {
|
|
75
|
-
if (!triggerEl) return
|
|
76
|
-
panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 210, minWidth: 200 })
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
$effect(() => {
|
|
80
|
-
if (!col.popover.open) return
|
|
81
|
-
updatePos()
|
|
82
|
-
const reposition = () => updatePos()
|
|
83
|
-
window.addEventListener('scroll', reposition, true)
|
|
84
|
-
window.addEventListener('resize', reposition)
|
|
85
|
-
const layer = createDismissableLayer({
|
|
86
|
-
element: () => [triggerEl, panelEl],
|
|
87
|
-
onDismiss: () => col.popover.close(),
|
|
88
|
-
})
|
|
89
|
-
layer.activate()
|
|
90
|
-
return () => {
|
|
91
|
-
window.removeEventListener('scroll', reposition, true)
|
|
92
|
-
window.removeEventListener('resize', reposition)
|
|
93
|
-
layer.release()
|
|
94
|
-
}
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
function focusOpen(node: HTMLButtonElement) {
|
|
98
|
-
if (!autoOpen) return
|
|
99
|
-
node.focus(); col.popover.show()
|
|
100
|
-
}
|
|
101
|
-
</script>
|
|
102
|
-
|
|
103
|
-
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
104
|
-
<button
|
|
105
|
-
bind:this={triggerEl}
|
|
106
|
-
class="sv-color sv-color--{size}"
|
|
107
|
-
class:is-disabled={disabled}
|
|
108
|
-
class:is-invalid={invalid}
|
|
109
|
-
{...col.swatchProps()}
|
|
110
|
-
use:focusOpen
|
|
111
|
-
>
|
|
112
|
-
<span class="sv-color__swatch" style:background={col.normalized}></span>
|
|
113
|
-
<span class="sv-color__hex">{col.normalized}</span>
|
|
114
|
-
</button>
|
|
115
|
-
</SvField>
|
|
116
|
-
|
|
117
|
-
{#if col.popover.open}
|
|
118
|
-
<div
|
|
119
|
-
class="sv-color__panel"
|
|
120
|
-
bind:this={panelEl}
|
|
121
|
-
use:portalToBody
|
|
122
|
-
use:popIn={{ up: panelRect.openUpward }}
|
|
123
|
-
style:position="fixed"
|
|
124
|
-
style:top={`${panelRect.top}px`}
|
|
125
|
-
style:left={`${panelRect.left}px`}
|
|
126
|
-
role="dialog"
|
|
127
|
-
aria-label={M.dialog}
|
|
128
|
-
>
|
|
129
|
-
<div class="sv-color__top">
|
|
130
|
-
<input class="sv-color__native" type="color" value={col.normalized} oninput={(e) => col.pick((e.currentTarget as HTMLInputElement).value)} aria-label={M.picker} />
|
|
131
|
-
<input
|
|
132
|
-
class="sv-color__field"
|
|
133
|
-
type="text"
|
|
134
|
-
bind:value={col.hexDraft}
|
|
135
|
-
spellcheck="false"
|
|
136
|
-
aria-label={M.hex}
|
|
137
|
-
onblur={col.commitHex}
|
|
138
|
-
onkeydown={(e) => { if (e.key === 'Enter') col.commitHex() }}
|
|
139
|
-
/>
|
|
140
|
-
</div>
|
|
141
|
-
<div class="sv-color__palette">
|
|
142
|
-
{#each col.palette as c (c)}
|
|
143
|
-
<button
|
|
144
|
-
class="sv-color__chip"
|
|
145
|
-
class:is-active={col.isActive(c)}
|
|
146
|
-
style:background={c}
|
|
147
|
-
{...col.chipProps(c)}
|
|
148
|
-
></button>
|
|
149
|
-
{/each}
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
{/if}
|
|
153
|
-
{#if name}<input type="hidden" {name} value={col.normalized} />{/if}
|
|
154
|
-
|
|
155
|
-
<style>
|
|
156
|
-
.sv-color {
|
|
157
|
-
--_accent: var(--sg-accent, #2563eb);
|
|
158
|
-
display: inline-flex; align-items: center; gap: 8px;
|
|
159
|
-
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
160
|
-
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
|
|
161
|
-
padding: 0 10px; cursor: pointer; font: inherit;
|
|
162
|
-
}
|
|
163
|
-
.sv-color--sm { height: 28px; font-size: 12px; }
|
|
164
|
-
.sv-color--md { height: 34px; font-size: 13px; }
|
|
165
|
-
.sv-color--lg { height: 40px; font-size: 15px; }
|
|
166
|
-
.sv-color.is-disabled { opacity: 0.6; cursor: not-allowed; }
|
|
167
|
-
.sv-color.is-invalid { border-color: var(--sg-danger, #dc2626); }
|
|
168
|
-
.sv-color.is-invalid:focus-within { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
|
|
169
|
-
.sv-color:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
|
|
170
|
-
.sv-color__swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.15); flex: none; }
|
|
171
|
-
.sv-color__hex { font-variant-numeric: tabular-nums; text-transform: lowercase; }
|
|
172
|
-
|
|
173
|
-
:global(.sv-color__panel) {
|
|
174
|
-
z-index: 2147483647; width: 208px; padding: 12px;
|
|
175
|
-
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
|
|
176
|
-
border-radius: 12px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
|
|
177
|
-
}
|
|
178
|
-
:global(.sv-color__top) { display: flex; gap: 8px; margin-bottom: 10px; }
|
|
179
|
-
:global(.sv-color__native) { width: 36px; height: 34px; padding: 0; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; background: none; cursor: pointer; }
|
|
180
|
-
:global(.sv-color__field) {
|
|
181
|
-
flex: 1; min-width: 0; height: 34px; padding: 0 10px; font: inherit; font-size: 13px;
|
|
182
|
-
border: 1px solid var(--sg-input-border, #cbd5e1); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
|
|
183
|
-
}
|
|
184
|
-
:global(.sv-color__palette) { display: grid; grid-template-columns: repeat(10, 1fr); gap: 4px; }
|
|
185
|
-
:global(.sv-color__chip) { width: 100%; aspect-ratio: 1; border-radius: 5px; border: 1px solid rgba(0,0,0,0.12); cursor: pointer; padding: 0; }
|
|
186
|
-
:global(.sv-color__chip.is-active) { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }
|
|
187
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvColorInput - a color swatch that opens a portalled popover with a hex
|
|
4
|
+
* field, a native picker, and a preset palette. Parity: Smart `smart-color-input`.
|
|
5
|
+
* Emits a hex string. Popover escapes the grid scroll container via popover.ts.
|
|
6
|
+
*
|
|
7
|
+
* The styled renderer over the headless `createColorInput` core: hex
|
|
8
|
+
* normalization, palette, draft field and open/close state come from the core;
|
|
9
|
+
* the portal + anchored measurement stay here (render concerns).
|
|
10
|
+
*/
|
|
11
|
+
import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
|
|
12
|
+
import { createDismissableLayer } from './a11y/dismissable'
|
|
13
|
+
import SvField from './SvField.svelte'
|
|
14
|
+
import { nextEditorId, resolveMessages, type SvEditorProps } from './editor-contract'
|
|
15
|
+
import { createColorInput } from './createColorInput.svelte'
|
|
16
|
+
|
|
17
|
+
/** User-facing strings for the color popover (localizable via `messages`). */
|
|
18
|
+
type ColorMessages = { dialog: string; picker: string; hex: string }
|
|
19
|
+
const DEFAULT_MESSAGES: ColorMessages = { dialog: 'Choose color', picker: 'Color picker', hex: 'Hex value' }
|
|
20
|
+
|
|
21
|
+
type Props = SvEditorProps & {
|
|
22
|
+
value?: string
|
|
23
|
+
onChange?: (hex: string) => void
|
|
24
|
+
/** Preset swatches. */
|
|
25
|
+
palette?: string[]
|
|
26
|
+
autoOpen?: boolean
|
|
27
|
+
/** Override the built-in popover strings. */
|
|
28
|
+
messages?: Partial<ColorMessages>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let {
|
|
32
|
+
value = $bindable('#3b82f6'),
|
|
33
|
+
onChange,
|
|
34
|
+
disabled = false,
|
|
35
|
+
readonly = false,
|
|
36
|
+
palette,
|
|
37
|
+
name,
|
|
38
|
+
size = 'md',
|
|
39
|
+
ariaLabel,
|
|
40
|
+
invalid = false,
|
|
41
|
+
required = false,
|
|
42
|
+
error,
|
|
43
|
+
label,
|
|
44
|
+
hint,
|
|
45
|
+
dir,
|
|
46
|
+
id,
|
|
47
|
+
autoOpen = false,
|
|
48
|
+
messages,
|
|
49
|
+
}: Props = $props()
|
|
50
|
+
|
|
51
|
+
const autoId = nextEditorId('sv-color')
|
|
52
|
+
const uid = $derived(id ?? autoId)
|
|
53
|
+
const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
|
|
54
|
+
|
|
55
|
+
const col = createColorInput({
|
|
56
|
+
value: () => value,
|
|
57
|
+
// Write `value` (so `bind:value` works) AND fire `onChange` (callback users).
|
|
58
|
+
onChange: (hex) => { value = hex; onChange?.(hex) },
|
|
59
|
+
disabled: () => disabled,
|
|
60
|
+
readonly: () => readonly,
|
|
61
|
+
palette: () => palette as string[],
|
|
62
|
+
id: () => uid,
|
|
63
|
+
invalid: () => invalid,
|
|
64
|
+
required: () => required,
|
|
65
|
+
error: () => error,
|
|
66
|
+
hint: () => hint,
|
|
67
|
+
ariaLabel: () => ariaLabel,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
71
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
72
|
+
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
73
|
+
|
|
74
|
+
function updatePos() {
|
|
75
|
+
if (!triggerEl) return
|
|
76
|
+
panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 210, minWidth: 200 })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
$effect(() => {
|
|
80
|
+
if (!col.popover.open) return
|
|
81
|
+
updatePos()
|
|
82
|
+
const reposition = () => updatePos()
|
|
83
|
+
window.addEventListener('scroll', reposition, true)
|
|
84
|
+
window.addEventListener('resize', reposition)
|
|
85
|
+
const layer = createDismissableLayer({
|
|
86
|
+
element: () => [triggerEl, panelEl],
|
|
87
|
+
onDismiss: () => col.popover.close(),
|
|
88
|
+
})
|
|
89
|
+
layer.activate()
|
|
90
|
+
return () => {
|
|
91
|
+
window.removeEventListener('scroll', reposition, true)
|
|
92
|
+
window.removeEventListener('resize', reposition)
|
|
93
|
+
layer.release()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
function focusOpen(node: HTMLButtonElement) {
|
|
98
|
+
if (!autoOpen) return
|
|
99
|
+
node.focus(); col.popover.show()
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
104
|
+
<button
|
|
105
|
+
bind:this={triggerEl}
|
|
106
|
+
class="sv-color sv-color--{size}"
|
|
107
|
+
class:is-disabled={disabled}
|
|
108
|
+
class:is-invalid={invalid}
|
|
109
|
+
{...col.swatchProps()}
|
|
110
|
+
use:focusOpen
|
|
111
|
+
>
|
|
112
|
+
<span class="sv-color__swatch" style:background={col.normalized}></span>
|
|
113
|
+
<span class="sv-color__hex">{col.normalized}</span>
|
|
114
|
+
</button>
|
|
115
|
+
</SvField>
|
|
116
|
+
|
|
117
|
+
{#if col.popover.open}
|
|
118
|
+
<div
|
|
119
|
+
class="sv-color__panel"
|
|
120
|
+
bind:this={panelEl}
|
|
121
|
+
use:portalToBody
|
|
122
|
+
use:popIn={{ up: panelRect.openUpward }}
|
|
123
|
+
style:position="fixed"
|
|
124
|
+
style:top={`${panelRect.top}px`}
|
|
125
|
+
style:left={`${panelRect.left}px`}
|
|
126
|
+
role="dialog"
|
|
127
|
+
aria-label={M.dialog}
|
|
128
|
+
>
|
|
129
|
+
<div class="sv-color__top">
|
|
130
|
+
<input class="sv-color__native" type="color" value={col.normalized} oninput={(e) => col.pick((e.currentTarget as HTMLInputElement).value)} aria-label={M.picker} />
|
|
131
|
+
<input
|
|
132
|
+
class="sv-color__field"
|
|
133
|
+
type="text"
|
|
134
|
+
bind:value={col.hexDraft}
|
|
135
|
+
spellcheck="false"
|
|
136
|
+
aria-label={M.hex}
|
|
137
|
+
onblur={col.commitHex}
|
|
138
|
+
onkeydown={(e) => { if (e.key === 'Enter') col.commitHex() }}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="sv-color__palette">
|
|
142
|
+
{#each col.palette as c (c)}
|
|
143
|
+
<button
|
|
144
|
+
class="sv-color__chip"
|
|
145
|
+
class:is-active={col.isActive(c)}
|
|
146
|
+
style:background={c}
|
|
147
|
+
{...col.chipProps(c)}
|
|
148
|
+
></button>
|
|
149
|
+
{/each}
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
{/if}
|
|
153
|
+
{#if name}<input type="hidden" {name} value={col.normalized} />{/if}
|
|
154
|
+
|
|
155
|
+
<style>
|
|
156
|
+
.sv-color {
|
|
157
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
158
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
159
|
+
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
160
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
|
|
161
|
+
padding: 0 10px; cursor: pointer; font: inherit;
|
|
162
|
+
}
|
|
163
|
+
.sv-color--sm { height: 28px; font-size: 12px; }
|
|
164
|
+
.sv-color--md { height: 34px; font-size: 13px; }
|
|
165
|
+
.sv-color--lg { height: 40px; font-size: 15px; }
|
|
166
|
+
.sv-color.is-disabled { opacity: 0.6; cursor: not-allowed; }
|
|
167
|
+
.sv-color.is-invalid { border-color: var(--sg-danger, #dc2626); }
|
|
168
|
+
.sv-color.is-invalid:focus-within { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
|
|
169
|
+
.sv-color:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
|
|
170
|
+
.sv-color__swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.15); flex: none; }
|
|
171
|
+
.sv-color__hex { font-variant-numeric: tabular-nums; text-transform: lowercase; }
|
|
172
|
+
|
|
173
|
+
:global(.sv-color__panel) {
|
|
174
|
+
z-index: 2147483647; width: 208px; padding: 12px;
|
|
175
|
+
background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
|
|
176
|
+
border-radius: 12px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
|
|
177
|
+
}
|
|
178
|
+
:global(.sv-color__top) { display: flex; gap: 8px; margin-bottom: 10px; }
|
|
179
|
+
:global(.sv-color__native) { width: 36px; height: 34px; padding: 0; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; background: none; cursor: pointer; }
|
|
180
|
+
:global(.sv-color__field) {
|
|
181
|
+
flex: 1; min-width: 0; height: 34px; padding: 0 10px; font: inherit; font-size: 13px;
|
|
182
|
+
border: 1px solid var(--sg-input-border, #cbd5e1); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
|
|
183
|
+
}
|
|
184
|
+
:global(.sv-color__palette) { display: grid; grid-template-columns: repeat(10, 1fr); gap: 4px; }
|
|
185
|
+
:global(.sv-color__chip) { width: 100%; aspect-ratio: 1; border-radius: 5px; border: 1px solid rgba(0,0,0,0.12); cursor: pointer; padding: 0; }
|
|
186
|
+
:global(.sv-color__chip.is-active) { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }
|
|
187
|
+
</style>
|
package/src/SvContextMenu.svelte
CHANGED
|
@@ -1,116 +1,116 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
export type { MenuItem } from './SvMenuList.svelte'
|
|
3
|
-
</script>
|
|
4
|
-
|
|
5
|
-
<script lang="ts">
|
|
6
|
-
/**
|
|
7
|
-
* SvContextMenu - wraps a region and opens a menu at the pointer on right-click
|
|
8
|
-
* (or long-press). Reuses SvMenuList (submenus, keyboard, icons, shortcuts),
|
|
9
|
-
* portals to <body>, and closes via the shared dismissable layer stack.
|
|
10
|
-
* Parity: Smart context-menu.
|
|
11
|
-
*
|
|
12
|
-
* ```svelte
|
|
13
|
-
* <SvContextMenu items={items} onSelect={(i) => run(i)}>
|
|
14
|
-
* <div class="drop-zone">Right-click me</div>
|
|
15
|
-
* </SvContextMenu>
|
|
16
|
-
* ```
|
|
17
|
-
*/
|
|
18
|
-
import type { Snippet } from 'svelte'
|
|
19
|
-
import { portalToBody, popIn } from './popover'
|
|
20
|
-
import { createDismissableLayer } from './a11y/dismissable'
|
|
21
|
-
import { createFocusTrap } from './a11y/focus-trap'
|
|
22
|
-
import SvMenuList, { type MenuItem } from './SvMenuList.svelte'
|
|
23
|
-
|
|
24
|
-
type Props = {
|
|
25
|
-
items: ReadonlyArray<MenuItem>
|
|
26
|
-
onSelect?: (item: MenuItem) => void
|
|
27
|
-
disabled?: boolean
|
|
28
|
-
ariaLabel?: string
|
|
29
|
-
children?: Snippet
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
let { items, onSelect, disabled = false, ariaLabel, children }: Props = $props()
|
|
33
|
-
|
|
34
|
-
let open = $state(false)
|
|
35
|
-
let pos = $state({ x: 0, y: 0 })
|
|
36
|
-
let panelEl = $state<HTMLDivElement | null>(null)
|
|
37
|
-
let zoneEl = $state<HTMLDivElement | null>(null)
|
|
38
|
-
|
|
39
|
-
const MENU_W = 200
|
|
40
|
-
function openAt(clientX: number, clientY: number) {
|
|
41
|
-
// Clamp so the menu stays on-screen (estimate height from item count).
|
|
42
|
-
const estH = Math.min(items.length, 12) * 34 + 8
|
|
43
|
-
const x = Math.min(clientX, window.innerWidth - MENU_W - 6)
|
|
44
|
-
const y = Math.min(clientY, window.innerHeight - estH - 6)
|
|
45
|
-
pos = { x: Math.max(6, x), y: Math.max(6, y) }
|
|
46
|
-
open = true
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function onContextMenu(e: MouseEvent) {
|
|
50
|
-
if (disabled) return
|
|
51
|
-
e.preventDefault()
|
|
52
|
-
openAt(e.clientX, e.clientY)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Keyboard equivalent of a right-click: the ContextMenu key, or Shift+F10.
|
|
56
|
-
// Anchors at the focused zone's own position instead of the (nonexistent)
|
|
57
|
-
// pointer coordinates.
|
|
58
|
-
function onZoneKeydown(e: KeyboardEvent) {
|
|
59
|
-
if (disabled) return
|
|
60
|
-
const isMenuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)
|
|
61
|
-
if (!isMenuKey) return
|
|
62
|
-
e.preventDefault()
|
|
63
|
-
const rect = zoneEl?.getBoundingClientRect()
|
|
64
|
-
openAt(rect ? rect.left : 0, rect ? rect.bottom : 0)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
$effect(() => {
|
|
68
|
-
if (!open || !panelEl) return
|
|
69
|
-
const trap = createFocusTrap(panelEl, {
|
|
70
|
-
initialFocus: () => panelEl?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])') ?? null,
|
|
71
|
-
})
|
|
72
|
-
trap.activate()
|
|
73
|
-
const layer = createDismissableLayer({
|
|
74
|
-
element: () => panelEl,
|
|
75
|
-
onDismiss: () => (open = false),
|
|
76
|
-
})
|
|
77
|
-
layer.activate()
|
|
78
|
-
// Reposition-close on scroll (a moved anchor would leave the menu stranded).
|
|
79
|
-
const onScroll = () => (open = false)
|
|
80
|
-
window.addEventListener('scroll', onScroll, true)
|
|
81
|
-
return () => { layer.release(); trap.release(); window.removeEventListener('scroll', onScroll, true) }
|
|
82
|
-
})
|
|
83
|
-
</script>
|
|
84
|
-
|
|
85
|
-
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
|
86
|
-
<div class="sv-ctx__zone" bind:this={zoneEl} tabindex={disabled ? -1 : 0} oncontextmenu={onContextMenu} onkeydown={onZoneKeydown}>
|
|
87
|
-
{@render children?.()}
|
|
88
|
-
</div>
|
|
89
|
-
|
|
90
|
-
{#if open}
|
|
91
|
-
<div
|
|
92
|
-
bind:this={panelEl}
|
|
93
|
-
class="sv-ctx"
|
|
94
|
-
use:portalToBody
|
|
95
|
-
use:popIn={{}}
|
|
96
|
-
style:position="fixed"
|
|
97
|
-
style:top={`${pos.y}px`}
|
|
98
|
-
style:left={`${pos.x}px`}
|
|
99
|
-
aria-label={ariaLabel}
|
|
100
|
-
>
|
|
101
|
-
<SvMenuList {items} onclose={() => (open = false)} onselect={(i) => onSelect?.(i)} />
|
|
102
|
-
</div>
|
|
103
|
-
{/if}
|
|
104
|
-
|
|
105
|
-
<style>
|
|
106
|
-
/* display:contents generates no box and per spec cannot receive focus - it
|
|
107
|
-
must be a real box for the keyboard-open path (tabindex) to work at all. */
|
|
108
|
-
.sv-ctx__zone { display: block; }
|
|
109
|
-
.sv-ctx__zone:focus-visible { outline: 2px solid var(--sg-accent, #6366f1); outline-offset: 1px; }
|
|
110
|
-
:global(.sv-ctx) {
|
|
111
|
-
z-index: 2147483646; min-width: 200px;
|
|
112
|
-
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
113
|
-
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
|
|
114
|
-
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35); font-size: 13px;
|
|
115
|
-
}
|
|
116
|
-
</style>
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { MenuItem } from './SvMenuList.svelte'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* SvContextMenu - wraps a region and opens a menu at the pointer on right-click
|
|
8
|
+
* (or long-press). Reuses SvMenuList (submenus, keyboard, icons, shortcuts),
|
|
9
|
+
* portals to <body>, and closes via the shared dismissable layer stack.
|
|
10
|
+
* Parity: Smart context-menu.
|
|
11
|
+
*
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <SvContextMenu items={items} onSelect={(i) => run(i)}>
|
|
14
|
+
* <div class="drop-zone">Right-click me</div>
|
|
15
|
+
* </SvContextMenu>
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import type { Snippet } from 'svelte'
|
|
19
|
+
import { portalToBody, popIn } from './popover'
|
|
20
|
+
import { createDismissableLayer } from './a11y/dismissable'
|
|
21
|
+
import { createFocusTrap } from './a11y/focus-trap'
|
|
22
|
+
import SvMenuList, { type MenuItem } from './SvMenuList.svelte'
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
items: ReadonlyArray<MenuItem>
|
|
26
|
+
onSelect?: (item: MenuItem) => void
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
ariaLabel?: string
|
|
29
|
+
children?: Snippet
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let { items, onSelect, disabled = false, ariaLabel, children }: Props = $props()
|
|
33
|
+
|
|
34
|
+
let open = $state(false)
|
|
35
|
+
let pos = $state({ x: 0, y: 0 })
|
|
36
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
37
|
+
let zoneEl = $state<HTMLDivElement | null>(null)
|
|
38
|
+
|
|
39
|
+
const MENU_W = 200
|
|
40
|
+
function openAt(clientX: number, clientY: number) {
|
|
41
|
+
// Clamp so the menu stays on-screen (estimate height from item count).
|
|
42
|
+
const estH = Math.min(items.length, 12) * 34 + 8
|
|
43
|
+
const x = Math.min(clientX, window.innerWidth - MENU_W - 6)
|
|
44
|
+
const y = Math.min(clientY, window.innerHeight - estH - 6)
|
|
45
|
+
pos = { x: Math.max(6, x), y: Math.max(6, y) }
|
|
46
|
+
open = true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onContextMenu(e: MouseEvent) {
|
|
50
|
+
if (disabled) return
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
openAt(e.clientX, e.clientY)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Keyboard equivalent of a right-click: the ContextMenu key, or Shift+F10.
|
|
56
|
+
// Anchors at the focused zone's own position instead of the (nonexistent)
|
|
57
|
+
// pointer coordinates.
|
|
58
|
+
function onZoneKeydown(e: KeyboardEvent) {
|
|
59
|
+
if (disabled) return
|
|
60
|
+
const isMenuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)
|
|
61
|
+
if (!isMenuKey) return
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
const rect = zoneEl?.getBoundingClientRect()
|
|
64
|
+
openAt(rect ? rect.left : 0, rect ? rect.bottom : 0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (!open || !panelEl) return
|
|
69
|
+
const trap = createFocusTrap(panelEl, {
|
|
70
|
+
initialFocus: () => panelEl?.querySelector<HTMLElement>('[role="menuitem"]:not([disabled])') ?? null,
|
|
71
|
+
})
|
|
72
|
+
trap.activate()
|
|
73
|
+
const layer = createDismissableLayer({
|
|
74
|
+
element: () => panelEl,
|
|
75
|
+
onDismiss: () => (open = false),
|
|
76
|
+
})
|
|
77
|
+
layer.activate()
|
|
78
|
+
// Reposition-close on scroll (a moved anchor would leave the menu stranded).
|
|
79
|
+
const onScroll = () => (open = false)
|
|
80
|
+
window.addEventListener('scroll', onScroll, true)
|
|
81
|
+
return () => { layer.release(); trap.release(); window.removeEventListener('scroll', onScroll, true) }
|
|
82
|
+
})
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
|
86
|
+
<div class="sv-ctx__zone" bind:this={zoneEl} tabindex={disabled ? -1 : 0} oncontextmenu={onContextMenu} onkeydown={onZoneKeydown}>
|
|
87
|
+
{@render children?.()}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{#if open}
|
|
91
|
+
<div
|
|
92
|
+
bind:this={panelEl}
|
|
93
|
+
class="sv-ctx"
|
|
94
|
+
use:portalToBody
|
|
95
|
+
use:popIn={{}}
|
|
96
|
+
style:position="fixed"
|
|
97
|
+
style:top={`${pos.y}px`}
|
|
98
|
+
style:left={`${pos.x}px`}
|
|
99
|
+
aria-label={ariaLabel}
|
|
100
|
+
>
|
|
101
|
+
<SvMenuList {items} onclose={() => (open = false)} onselect={(i) => onSelect?.(i)} />
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
/* display:contents generates no box and per spec cannot receive focus - it
|
|
107
|
+
must be a real box for the keyboard-open path (tabindex) to work at all. */
|
|
108
|
+
.sv-ctx__zone { display: block; }
|
|
109
|
+
.sv-ctx__zone:focus-visible { outline: 2px solid var(--sg-accent, #6366f1); outline-offset: 1px; }
|
|
110
|
+
:global(.sv-ctx) {
|
|
111
|
+
z-index: 2147483646; min-width: 200px;
|
|
112
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
113
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px;
|
|
114
|
+
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35); font-size: 13px;
|
|
115
|
+
}
|
|
116
|
+
</style>
|