@svgrid/grid 2.2.25 → 2.2.27
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 +199 -145
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +179 -179
- package/dist/GridMenus.svelte +734 -728
- package/dist/SvAutoComplete.svelte +165 -165
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +101 -101
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvDropDownList.svelte +374 -374
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.css +2723 -2719
- package/dist/SvGrid.svelte +3333 -3333
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +696 -696
- package/dist/SvGridSelect.svelte +259 -259
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +287 -287
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +281 -281
- package/dist/SvNumberInput.svelte +168 -168
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +131 -131
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +107 -107
- package/dist/SvTagsInput.svelte +112 -112
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +437 -437
- package/dist/SvTreeSelect.svelte +233 -233
- package/dist/a11y/dismissable.d.ts +11 -0
- package/dist/a11y/dismissable.js +21 -0
- package/dist/ai.js +3 -3
- package/dist/cdn/GridMenus-BzxEYNtW.js +421 -0
- package/dist/cdn/GridMenus-Dr-nGEXn.js +417 -0
- package/dist/cdn/{SvDateTimePicker-CYC7gRER.js → SvDateTimePicker-B1sDc9Pz.js} +451 -444
- package/dist/cdn/{SvDateTimePicker-DtQKd4Ns.js → SvDateTimePicker-Bh0FTs1E.js} +114 -107
- package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-BKQsO5fZ.js} +1 -1
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Bgk4hDs8.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-DSkHIQDd.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-DkCc2Z7b.js} +1 -1
- package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-BrbVaELm.js} +1 -1
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-i9rWvSKg.js} +1 -1
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CGxxopQ4.js} +1 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D08sVmAy.js} +1 -1
- package/dist/cdn/{src-DysW9qbk.js → src-Cw8d9U8j.js} +5753 -5753
- package/dist/cdn/{src-bYfr_7aR.js → src-cgs1wcUS.js} +8102 -8102
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +30 -13
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +179 -179
- package/src/GridMenus.svelte +734 -728
- package/src/SvAutoComplete.svelte +165 -165
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +101 -101
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +250 -250
- package/src/SvDropDownList.svelte +374 -374
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +3239 -3239
- package/src/SvGrid.css +2723 -2719
- package/src/SvGrid.svelte +3333 -3333
- package/src/SvGrid.types.ts +1613 -1613
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +696 -696
- package/src/SvGridSelect.svelte +259 -259
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +287 -287
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +281 -281
- package/src/SvNumberInput.svelte +168 -168
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +131 -131
- package/src/SvPopover.svelte +197 -197
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +107 -107
- package/src/SvTagsInput.svelte +112 -112
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +437 -437
- package/src/SvTreeSelect.svelte +233 -233
- package/src/a11y/dismissable.test.ts +119 -95
- package/src/a11y/dismissable.ts +114 -94
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +895 -895
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- 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 +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- 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 +225 -225
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/editor-contract.ts +157 -157
- 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/filter-operators.ts +157 -157
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/index.ts +713 -712
- package/src/js-scroller.svelte.ts +173 -173
- 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/list-option.test.ts +56 -56
- package/src/list-option.ts +172 -172
- package/src/menus.test.ts +700 -700
- package/src/menus.ts +652 -652
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- 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 +524 -524
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-menu-scroll.test.ts +102 -0
- 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 +215 -215
- package/src/toast-store.svelte.ts +246 -246
- package/src/toast-store.test.ts +147 -147
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- 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/themes/ember.css +71 -71
- package/dist/cdn/GridMenus-GfCAHgkZ.js +0 -420
- package/dist/cdn/GridMenus-GiVNSeDU.js +0 -416
package/src/SvComboBox.svelte
CHANGED
|
@@ -1,275 +1,275 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* SvComboBox - an editable combobox: type to filter a portalled list, pick an
|
|
4
|
-
* option (value must come from the list). Parity: Smart `smart-combo-box`.
|
|
5
|
-
* Controlled via `value` + `onChange`. On blur, unmatched text reverts.
|
|
6
|
-
*
|
|
7
|
-
* This is one styled renderer over the headless `createCombobox` core (state +
|
|
8
|
-
* keyboard + ARIA); only portal/measure/scroll render concerns live here.
|
|
9
|
-
*/
|
|
10
|
-
import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
|
|
11
|
-
import { startPanelResize } from './panel-resize'
|
|
12
|
-
import { createDismissableLayer } from './a11y/dismissable'
|
|
13
|
-
import { groupOptions, normalizeOptions, type ListOption } from './list-option'
|
|
14
|
-
import SvField from './SvField.svelte'
|
|
15
|
-
import { nextEditorId, resolveMessages, type SvEditorProps } from './editor-contract'
|
|
16
|
-
import { createCombobox } from './createCombobox.svelte'
|
|
17
|
-
import type { Snippet } from 'svelte'
|
|
18
|
-
|
|
19
|
-
/** User-facing strings (localizable via `messages`). */
|
|
20
|
-
type ComboMessages = { noResults: string; loading: string; typeMore: string }
|
|
21
|
-
const DEFAULT_MESSAGES: ComboMessages = { noResults: 'No matches', loading: 'Loading...', typeMore: 'Type to search' }
|
|
22
|
-
|
|
23
|
-
type Props = SvEditorProps & {
|
|
24
|
-
options?: ReadonlyArray<ListOption>
|
|
25
|
-
value?: string | number | null
|
|
26
|
-
onChange?: (value: string | number | null) => void
|
|
27
|
-
placeholder?: string
|
|
28
|
-
autoOpen?: boolean
|
|
29
|
-
/** Show a clear (x) button when a value is selected. */
|
|
30
|
-
clearable?: boolean
|
|
31
|
-
/** Show a bottom drag grip so the user can resize the open panel's height
|
|
32
|
-
* (hidden when the panel flips upward, where there is no room to grow). */
|
|
33
|
-
resizable?: boolean
|
|
34
|
-
/** Load options from a server as the user types (debounced). Disables local
|
|
35
|
-
* filtering; the returned list is shown as-is. */
|
|
36
|
-
loadOptions?: (query: string) => Promise<ListOption[]>
|
|
37
|
-
/** Min chars before a remote search fires. Default 1. */
|
|
38
|
-
minLength?: number
|
|
39
|
-
/** Debounce (ms) before a remote search fires. Default 250. */
|
|
40
|
-
debounce?: number
|
|
41
|
-
/** Override the built-in strings (empty-state / loading text). */
|
|
42
|
-
messages?: Partial<ComboMessages>
|
|
43
|
-
/** Custom render for each option (receives the ListOption). */
|
|
44
|
-
item?: Snippet<[ListOption]>
|
|
45
|
-
/** Content pinned above the option list. */
|
|
46
|
-
header?: Snippet
|
|
47
|
-
/** Content pinned below the option list. */
|
|
48
|
-
footer?: Snippet
|
|
49
|
-
/** Shown when the (non-loading) list is empty; overrides the default text. */
|
|
50
|
-
noData?: Snippet
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
let {
|
|
54
|
-
options = [],
|
|
55
|
-
value = null,
|
|
56
|
-
onChange,
|
|
57
|
-
placeholder = 'Select…',
|
|
58
|
-
disabled = false,
|
|
59
|
-
readonly = false,
|
|
60
|
-
clearable = false,
|
|
61
|
-
resizable = false,
|
|
62
|
-
name,
|
|
63
|
-
size = 'md',
|
|
64
|
-
ariaLabel,
|
|
65
|
-
invalid = false,
|
|
66
|
-
required = false,
|
|
67
|
-
error,
|
|
68
|
-
label,
|
|
69
|
-
hint,
|
|
70
|
-
dir,
|
|
71
|
-
id,
|
|
72
|
-
loading: loadingProp = false,
|
|
73
|
-
autoOpen = false,
|
|
74
|
-
loadOptions,
|
|
75
|
-
minLength = 1,
|
|
76
|
-
debounce = 250,
|
|
77
|
-
messages,
|
|
78
|
-
item,
|
|
79
|
-
header,
|
|
80
|
-
footer,
|
|
81
|
-
noData,
|
|
82
|
-
}: Props = $props()
|
|
83
|
-
|
|
84
|
-
const autoId = nextEditorId('sv-combo')
|
|
85
|
-
const uid = $derived(id ?? autoId)
|
|
86
|
-
const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
|
|
87
|
-
|
|
88
|
-
let inputEl = $state<HTMLInputElement | null>(null)
|
|
89
|
-
let fieldEl = $state<HTMLDivElement | null>(null)
|
|
90
|
-
let panelEl = $state<HTMLDivElement | null>(null)
|
|
91
|
-
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
92
|
-
// User-chosen panel height (px) once they drag the resize grip; null = auto.
|
|
93
|
-
let userHeight = $state<number | null>(null)
|
|
94
|
-
// Default comfortable cap (px) before the list scrolls; the grip can grow past it.
|
|
95
|
-
const DEFAULT_CAP = 288
|
|
96
|
-
const panelMaxH = $derived(
|
|
97
|
-
resizable && userHeight != null ? Math.min(userHeight, rect.availHeight) : Math.min(rect.availHeight, DEFAULT_CAP),
|
|
98
|
-
)
|
|
99
|
-
const showGrip = $derived(resizable && !rect.openUpward)
|
|
100
|
-
|
|
101
|
-
// Remote data source: fetch options as the query changes (debounced).
|
|
102
|
-
let remoteOptions = $state<ListOption[]>([])
|
|
103
|
-
let loading = $state(false)
|
|
104
|
-
let searched = $state(false)
|
|
105
|
-
let debTimer: ReturnType<typeof setTimeout> | undefined
|
|
106
|
-
let reqSeq = 0
|
|
107
|
-
const effectiveOptions = $derived(normalizeOptions(loadOptions ? remoteOptions : options))
|
|
108
|
-
|
|
109
|
-
const combo = createCombobox({
|
|
110
|
-
options: () => effectiveOptions,
|
|
111
|
-
value: () => value,
|
|
112
|
-
onChange: (v) => onChange?.(v),
|
|
113
|
-
disabled: () => disabled,
|
|
114
|
-
readonly: () => readonly,
|
|
115
|
-
ariaLabel: () => ariaLabel,
|
|
116
|
-
localFilter: () => !loadOptions,
|
|
117
|
-
focusInput: () => inputEl?.focus(),
|
|
118
|
-
blurInput: () => inputEl?.blur(),
|
|
119
|
-
id: () => uid,
|
|
120
|
-
invalid: () => invalid,
|
|
121
|
-
required: () => required,
|
|
122
|
-
error: () => error,
|
|
123
|
-
hint: () => hint,
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
// Debounced remote search driven by the combobox query while editing.
|
|
127
|
-
$effect(() => {
|
|
128
|
-
if (!loadOptions) return
|
|
129
|
-
const q = combo.editing ? combo.query : ''
|
|
130
|
-
if (debTimer) clearTimeout(debTimer)
|
|
131
|
-
if (q.trim().length < minLength) { remoteOptions = []; loading = false; searched = false; return }
|
|
132
|
-
loading = true
|
|
133
|
-
const seq = ++reqSeq
|
|
134
|
-
debTimer = setTimeout(async () => {
|
|
135
|
-
try {
|
|
136
|
-
const res = await loadOptions!(q.trim())
|
|
137
|
-
if (seq === reqSeq) { remoteOptions = res; searched = true }
|
|
138
|
-
} finally {
|
|
139
|
-
if (seq === reqSeq) loading = false
|
|
140
|
-
}
|
|
141
|
-
}, debounce)
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
function updatePos() {
|
|
145
|
-
if (!fieldEl) return
|
|
146
|
-
// estimatedHeight only picks the flip direction (up vs down); the panel's
|
|
147
|
-
// real max-height comes from availHeight below, so it takes its natural
|
|
148
|
-
// content height (options + group labels) and scrolls only when it truly
|
|
149
|
-
// overflows the room - never from an estimate that under-counts a row. When
|
|
150
|
-
// the user has dragged the grip, anchor the flip decision to THAT height.
|
|
151
|
-
const est = resizable && userHeight != null ? userHeight : Math.min(combo.filtered.length, 8) * 34 + 12
|
|
152
|
-
rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: est })
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function startResize(e: PointerEvent) {
|
|
156
|
-
if (!panelEl) return
|
|
157
|
-
startPanelResize(e, {
|
|
158
|
-
startHeight: panelEl.clientHeight,
|
|
159
|
-
min: 96,
|
|
160
|
-
max: () => rect.availHeight,
|
|
161
|
-
onHeight: (h) => { userHeight = h; updatePos() },
|
|
162
|
-
})
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Position + reposition + outside-click close are render concerns (need the DOM).
|
|
166
|
-
$effect(() => {
|
|
167
|
-
if (!combo.open) return
|
|
168
|
-
updatePos()
|
|
169
|
-
const rp = () => updatePos()
|
|
170
|
-
window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
|
|
171
|
-
const layer = createDismissableLayer({ element: () => [fieldEl, panelEl], onDismiss: () => combo.close(), closeOnEscape: false })
|
|
172
|
-
layer.activate()
|
|
173
|
-
return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); layer.release() }
|
|
174
|
-
})
|
|
175
|
-
// Keep the active option scrolled into view (render concern).
|
|
176
|
-
$effect(() => {
|
|
177
|
-
if (!combo.open) return
|
|
178
|
-
const i = combo.activeIndex
|
|
179
|
-
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
180
|
-
})
|
|
181
|
-
function focusOpen(node: HTMLInputElement) { if (autoOpen) { node.focus() } }
|
|
182
|
-
</script>
|
|
183
|
-
|
|
184
|
-
<SvField id={uid} {label} {hint} {error} {required} {dir} loading={loadingProp || loading}>
|
|
185
|
-
<div bind:this={fieldEl} class="sv-combo sv-combo--{size}" class:is-open={combo.open} class:is-disabled={disabled} class:is-invalid={invalid}>
|
|
186
|
-
<input
|
|
187
|
-
bind:this={inputEl}
|
|
188
|
-
class="sv-combo__input"
|
|
189
|
-
type="text"
|
|
190
|
-
{placeholder}
|
|
191
|
-
{...combo.inputProps()}
|
|
192
|
-
use:focusOpen
|
|
193
|
-
/>
|
|
194
|
-
{#if clearable && combo.value != null && !disabled && !readonly}
|
|
195
|
-
<button type="button" class="sv-combo__clear" tabindex="-1" aria-label="Clear" onclick={() => { combo.clear(); inputEl?.focus() }}>
|
|
196
|
-
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
|
197
|
-
</button>
|
|
198
|
-
{/if}
|
|
199
|
-
<button class="sv-combo__chev" {...combo.triggerProps()}>
|
|
200
|
-
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
201
|
-
</button>
|
|
202
|
-
</div>
|
|
203
|
-
</SvField>
|
|
204
|
-
|
|
205
|
-
{#if combo.open}
|
|
206
|
-
<div bind:this={panelEl} class="sv-ddl__panel" class:is-resizable={showGrip} use:portalToBody use:popIn={{ up: rect.openUpward }} style:position="fixed" style:top={rect.openUpward ? undefined : `${rect.top}px`} style:bottom={rect.openUpward ? `${rect.bottom}px` : undefined} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${panelMaxH}px`} style:height={resizable && userHeight != null && !rect.openUpward ? `${panelMaxH}px` : undefined} {...combo.listboxProps()}>
|
|
207
|
-
{#if header}<div class="sv-ddl__header">{@render header()}</div>{/if}
|
|
208
|
-
{#if loading}
|
|
209
|
-
<div class="sv-ddl__empty sv-ddl__loading"><svg class="sv-ddl__spin" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>{M.loading}</div>
|
|
210
|
-
{:else if loadOptions && !searched && combo.query.trim().length < minLength}
|
|
211
|
-
<div class="sv-ddl__empty">{M.typeMore}</div>
|
|
212
|
-
{:else if combo.filtered.length}
|
|
213
|
-
{#each groupOptions(combo.filtered) as g (g.group ?? ' ')}
|
|
214
|
-
{#if g.group != null}<div class="sv-ddl__group-label" aria-hidden="true">{g.group}</div>{/if}
|
|
215
|
-
{#each g.options as opt (opt.index)}
|
|
216
|
-
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
|
217
|
-
<div class="sv-ddl__opt" class:is-active={combo.isActive(opt.index)} class:is-selected={combo.isSelected(opt)} class:is-disabled={opt.disabled} {...combo.optionProps(opt.index)}>{#if item}{@render item(opt)}{:else}{opt.label}{/if}</div>
|
|
218
|
-
{/each}
|
|
219
|
-
{/each}
|
|
220
|
-
{:else if noData}
|
|
221
|
-
{@render noData()}
|
|
222
|
-
{:else}
|
|
223
|
-
<div class="sv-ddl__empty">{M.noResults}</div>
|
|
224
|
-
{/if}
|
|
225
|
-
{#if footer}<div class="sv-ddl__footer">{@render footer()}</div>{/if}
|
|
226
|
-
{#if showGrip}
|
|
227
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
228
|
-
<div class="sv-ddl__grip" onpointerdown={startResize} title="Drag to resize" aria-hidden="true">
|
|
229
|
-
<span class="sv-ddl__grip-dots"></span>
|
|
230
|
-
</div>
|
|
231
|
-
{/if}
|
|
232
|
-
</div>
|
|
233
|
-
{/if}
|
|
234
|
-
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
235
|
-
|
|
236
|
-
<style>
|
|
237
|
-
.sv-combo {
|
|
238
|
-
--_accent: var(--sg-accent, #2563eb);
|
|
239
|
-
display: inline-flex; align-items: center; width: 200px;
|
|
240
|
-
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
241
|
-
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
|
|
242
|
-
}
|
|
243
|
-
.sv-combo--sm { height: 28px; font-size: 12px; }
|
|
244
|
-
.sv-combo--md { height: 34px; font-size: 13px; }
|
|
245
|
-
.sv-combo--lg { height: 40px; font-size: 15px; }
|
|
246
|
-
.sv-combo.is-open, .sv-combo:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
|
|
247
|
-
.sv-combo.is-invalid { border-color: var(--sg-danger, #dc2626); }
|
|
248
|
-
.sv-combo.is-invalid.is-open, .sv-combo.is-invalid:focus-within { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
|
|
249
|
-
.sv-combo.is-disabled { opacity: 0.6; }
|
|
250
|
-
.sv-combo__input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit; font: inherit; padding-block: 0; padding-inline: 10px 4px; }
|
|
251
|
-
.sv-combo__clear { display: grid; place-items: center; width: 22px; align-self: center; flex: none; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 4px; }
|
|
252
|
-
.sv-combo__clear:hover { color: var(--sg-danger, #dc2626); }
|
|
253
|
-
.sv-combo__chev { display: grid; place-items: center; width: 28px; align-self: stretch; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; }
|
|
254
|
-
:global(.sv-ddl__empty) { padding: 8px 10px; color: var(--sg-muted, #94a3b8); font-size: 13px; }
|
|
255
|
-
:global(.sv-ddl__header) { padding: 8px 10px; border-bottom: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; color: var(--sg-muted, #64748b); }
|
|
256
|
-
:global(.sv-ddl__footer) { padding: 8px 10px; border-top: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; }
|
|
257
|
-
:global(.sv-ddl__loading) { display: flex; align-items: center; gap: 7px; }
|
|
258
|
-
:global(.sv-ddl__spin) { animation: sv-combo-spin 0.7s linear infinite; }
|
|
259
|
-
@keyframes sv-combo-spin { to { transform: rotate(360deg); } }
|
|
260
|
-
@media (prefers-reduced-motion: reduce) { :global(.sv-ddl__spin) { animation: none; } }
|
|
261
|
-
/* Bottom resize grip (shared "····" handle - see SvDropDownList / panel-resize). */
|
|
262
|
-
:global(.sv-ddl__panel.is-resizable) { padding-bottom: 0; }
|
|
263
|
-
:global(.sv-ddl__grip) {
|
|
264
|
-
position: sticky; bottom: 0; z-index: 1; margin: 2px -4px -4px; height: 15px;
|
|
265
|
-
display: flex; align-items: center; justify-content: center; cursor: ns-resize;
|
|
266
|
-
background: var(--sg-bg, #fff); border-top: 1px solid var(--sg-border, #e2e8f0);
|
|
267
|
-
border-radius: 0 0 10px 10px; touch-action: none;
|
|
268
|
-
}
|
|
269
|
-
:global(.sv-ddl__grip-dots) {
|
|
270
|
-
width: 26px; height: 4px; color: var(--sg-muted, #94a3b8); opacity: 0.6;
|
|
271
|
-
background-image: radial-gradient(currentColor 1px, transparent 1.6px);
|
|
272
|
-
background-size: 6px 4px; background-position: center; background-repeat: repeat-x;
|
|
273
|
-
}
|
|
274
|
-
:global(.sv-ddl__grip:hover .sv-ddl__grip-dots) { opacity: 1; }
|
|
275
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvComboBox - an editable combobox: type to filter a portalled list, pick an
|
|
4
|
+
* option (value must come from the list). Parity: Smart `smart-combo-box`.
|
|
5
|
+
* Controlled via `value` + `onChange`. On blur, unmatched text reverts.
|
|
6
|
+
*
|
|
7
|
+
* This is one styled renderer over the headless `createCombobox` core (state +
|
|
8
|
+
* keyboard + ARIA); only portal/measure/scroll render concerns live here.
|
|
9
|
+
*/
|
|
10
|
+
import { anchoredRect, portalToBody, popIn, type AnchoredRect } from './popover'
|
|
11
|
+
import { startPanelResize } from './panel-resize'
|
|
12
|
+
import { createDismissableLayer } from './a11y/dismissable'
|
|
13
|
+
import { groupOptions, normalizeOptions, type ListOption } from './list-option'
|
|
14
|
+
import SvField from './SvField.svelte'
|
|
15
|
+
import { nextEditorId, resolveMessages, type SvEditorProps } from './editor-contract'
|
|
16
|
+
import { createCombobox } from './createCombobox.svelte'
|
|
17
|
+
import type { Snippet } from 'svelte'
|
|
18
|
+
|
|
19
|
+
/** User-facing strings (localizable via `messages`). */
|
|
20
|
+
type ComboMessages = { noResults: string; loading: string; typeMore: string }
|
|
21
|
+
const DEFAULT_MESSAGES: ComboMessages = { noResults: 'No matches', loading: 'Loading...', typeMore: 'Type to search' }
|
|
22
|
+
|
|
23
|
+
type Props = SvEditorProps & {
|
|
24
|
+
options?: ReadonlyArray<ListOption>
|
|
25
|
+
value?: string | number | null
|
|
26
|
+
onChange?: (value: string | number | null) => void
|
|
27
|
+
placeholder?: string
|
|
28
|
+
autoOpen?: boolean
|
|
29
|
+
/** Show a clear (x) button when a value is selected. */
|
|
30
|
+
clearable?: boolean
|
|
31
|
+
/** Show a bottom drag grip so the user can resize the open panel's height
|
|
32
|
+
* (hidden when the panel flips upward, where there is no room to grow). */
|
|
33
|
+
resizable?: boolean
|
|
34
|
+
/** Load options from a server as the user types (debounced). Disables local
|
|
35
|
+
* filtering; the returned list is shown as-is. */
|
|
36
|
+
loadOptions?: (query: string) => Promise<ListOption[]>
|
|
37
|
+
/** Min chars before a remote search fires. Default 1. */
|
|
38
|
+
minLength?: number
|
|
39
|
+
/** Debounce (ms) before a remote search fires. Default 250. */
|
|
40
|
+
debounce?: number
|
|
41
|
+
/** Override the built-in strings (empty-state / loading text). */
|
|
42
|
+
messages?: Partial<ComboMessages>
|
|
43
|
+
/** Custom render for each option (receives the ListOption). */
|
|
44
|
+
item?: Snippet<[ListOption]>
|
|
45
|
+
/** Content pinned above the option list. */
|
|
46
|
+
header?: Snippet
|
|
47
|
+
/** Content pinned below the option list. */
|
|
48
|
+
footer?: Snippet
|
|
49
|
+
/** Shown when the (non-loading) list is empty; overrides the default text. */
|
|
50
|
+
noData?: Snippet
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let {
|
|
54
|
+
options = [],
|
|
55
|
+
value = null,
|
|
56
|
+
onChange,
|
|
57
|
+
placeholder = 'Select…',
|
|
58
|
+
disabled = false,
|
|
59
|
+
readonly = false,
|
|
60
|
+
clearable = false,
|
|
61
|
+
resizable = false,
|
|
62
|
+
name,
|
|
63
|
+
size = 'md',
|
|
64
|
+
ariaLabel,
|
|
65
|
+
invalid = false,
|
|
66
|
+
required = false,
|
|
67
|
+
error,
|
|
68
|
+
label,
|
|
69
|
+
hint,
|
|
70
|
+
dir,
|
|
71
|
+
id,
|
|
72
|
+
loading: loadingProp = false,
|
|
73
|
+
autoOpen = false,
|
|
74
|
+
loadOptions,
|
|
75
|
+
minLength = 1,
|
|
76
|
+
debounce = 250,
|
|
77
|
+
messages,
|
|
78
|
+
item,
|
|
79
|
+
header,
|
|
80
|
+
footer,
|
|
81
|
+
noData,
|
|
82
|
+
}: Props = $props()
|
|
83
|
+
|
|
84
|
+
const autoId = nextEditorId('sv-combo')
|
|
85
|
+
const uid = $derived(id ?? autoId)
|
|
86
|
+
const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
|
|
87
|
+
|
|
88
|
+
let inputEl = $state<HTMLInputElement | null>(null)
|
|
89
|
+
let fieldEl = $state<HTMLDivElement | null>(null)
|
|
90
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
91
|
+
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
|
|
92
|
+
// User-chosen panel height (px) once they drag the resize grip; null = auto.
|
|
93
|
+
let userHeight = $state<number | null>(null)
|
|
94
|
+
// Default comfortable cap (px) before the list scrolls; the grip can grow past it.
|
|
95
|
+
const DEFAULT_CAP = 288
|
|
96
|
+
const panelMaxH = $derived(
|
|
97
|
+
resizable && userHeight != null ? Math.min(userHeight, rect.availHeight) : Math.min(rect.availHeight, DEFAULT_CAP),
|
|
98
|
+
)
|
|
99
|
+
const showGrip = $derived(resizable && !rect.openUpward)
|
|
100
|
+
|
|
101
|
+
// Remote data source: fetch options as the query changes (debounced).
|
|
102
|
+
let remoteOptions = $state<ListOption[]>([])
|
|
103
|
+
let loading = $state(false)
|
|
104
|
+
let searched = $state(false)
|
|
105
|
+
let debTimer: ReturnType<typeof setTimeout> | undefined
|
|
106
|
+
let reqSeq = 0
|
|
107
|
+
const effectiveOptions = $derived(normalizeOptions(loadOptions ? remoteOptions : options))
|
|
108
|
+
|
|
109
|
+
const combo = createCombobox({
|
|
110
|
+
options: () => effectiveOptions,
|
|
111
|
+
value: () => value,
|
|
112
|
+
onChange: (v) => onChange?.(v),
|
|
113
|
+
disabled: () => disabled,
|
|
114
|
+
readonly: () => readonly,
|
|
115
|
+
ariaLabel: () => ariaLabel,
|
|
116
|
+
localFilter: () => !loadOptions,
|
|
117
|
+
focusInput: () => inputEl?.focus(),
|
|
118
|
+
blurInput: () => inputEl?.blur(),
|
|
119
|
+
id: () => uid,
|
|
120
|
+
invalid: () => invalid,
|
|
121
|
+
required: () => required,
|
|
122
|
+
error: () => error,
|
|
123
|
+
hint: () => hint,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Debounced remote search driven by the combobox query while editing.
|
|
127
|
+
$effect(() => {
|
|
128
|
+
if (!loadOptions) return
|
|
129
|
+
const q = combo.editing ? combo.query : ''
|
|
130
|
+
if (debTimer) clearTimeout(debTimer)
|
|
131
|
+
if (q.trim().length < minLength) { remoteOptions = []; loading = false; searched = false; return }
|
|
132
|
+
loading = true
|
|
133
|
+
const seq = ++reqSeq
|
|
134
|
+
debTimer = setTimeout(async () => {
|
|
135
|
+
try {
|
|
136
|
+
const res = await loadOptions!(q.trim())
|
|
137
|
+
if (seq === reqSeq) { remoteOptions = res; searched = true }
|
|
138
|
+
} finally {
|
|
139
|
+
if (seq === reqSeq) loading = false
|
|
140
|
+
}
|
|
141
|
+
}, debounce)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
function updatePos() {
|
|
145
|
+
if (!fieldEl) return
|
|
146
|
+
// estimatedHeight only picks the flip direction (up vs down); the panel's
|
|
147
|
+
// real max-height comes from availHeight below, so it takes its natural
|
|
148
|
+
// content height (options + group labels) and scrolls only when it truly
|
|
149
|
+
// overflows the room - never from an estimate that under-counts a row. When
|
|
150
|
+
// the user has dragged the grip, anchor the flip decision to THAT height.
|
|
151
|
+
const est = resizable && userHeight != null ? userHeight : Math.min(combo.filtered.length, 8) * 34 + 12
|
|
152
|
+
rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: est })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function startResize(e: PointerEvent) {
|
|
156
|
+
if (!panelEl) return
|
|
157
|
+
startPanelResize(e, {
|
|
158
|
+
startHeight: panelEl.clientHeight,
|
|
159
|
+
min: 96,
|
|
160
|
+
max: () => rect.availHeight,
|
|
161
|
+
onHeight: (h) => { userHeight = h; updatePos() },
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Position + reposition + outside-click close are render concerns (need the DOM).
|
|
166
|
+
$effect(() => {
|
|
167
|
+
if (!combo.open) return
|
|
168
|
+
updatePos()
|
|
169
|
+
const rp = () => updatePos()
|
|
170
|
+
window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
|
|
171
|
+
const layer = createDismissableLayer({ element: () => [fieldEl, panelEl], onDismiss: () => combo.close(), closeOnEscape: false })
|
|
172
|
+
layer.activate()
|
|
173
|
+
return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); layer.release() }
|
|
174
|
+
})
|
|
175
|
+
// Keep the active option scrolled into view (render concern).
|
|
176
|
+
$effect(() => {
|
|
177
|
+
if (!combo.open) return
|
|
178
|
+
const i = combo.activeIndex
|
|
179
|
+
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
180
|
+
})
|
|
181
|
+
function focusOpen(node: HTMLInputElement) { if (autoOpen) { node.focus() } }
|
|
182
|
+
</script>
|
|
183
|
+
|
|
184
|
+
<SvField id={uid} {label} {hint} {error} {required} {dir} loading={loadingProp || loading}>
|
|
185
|
+
<div bind:this={fieldEl} class="sv-combo sv-combo--{size}" class:is-open={combo.open} class:is-disabled={disabled} class:is-invalid={invalid}>
|
|
186
|
+
<input
|
|
187
|
+
bind:this={inputEl}
|
|
188
|
+
class="sv-combo__input"
|
|
189
|
+
type="text"
|
|
190
|
+
{placeholder}
|
|
191
|
+
{...combo.inputProps()}
|
|
192
|
+
use:focusOpen
|
|
193
|
+
/>
|
|
194
|
+
{#if clearable && combo.value != null && !disabled && !readonly}
|
|
195
|
+
<button type="button" class="sv-combo__clear" tabindex="-1" aria-label="Clear" onclick={() => { combo.clear(); inputEl?.focus() }}>
|
|
196
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
|
197
|
+
</button>
|
|
198
|
+
{/if}
|
|
199
|
+
<button class="sv-combo__chev" {...combo.triggerProps()}>
|
|
200
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</SvField>
|
|
204
|
+
|
|
205
|
+
{#if combo.open}
|
|
206
|
+
<div bind:this={panelEl} class="sv-ddl__panel" class:is-resizable={showGrip} use:portalToBody use:popIn={{ up: rect.openUpward }} style:position="fixed" style:top={rect.openUpward ? undefined : `${rect.top}px`} style:bottom={rect.openUpward ? `${rect.bottom}px` : undefined} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} style:max-height={`${panelMaxH}px`} style:height={resizable && userHeight != null && !rect.openUpward ? `${panelMaxH}px` : undefined} {...combo.listboxProps()}>
|
|
207
|
+
{#if header}<div class="sv-ddl__header">{@render header()}</div>{/if}
|
|
208
|
+
{#if loading}
|
|
209
|
+
<div class="sv-ddl__empty sv-ddl__loading"><svg class="sv-ddl__spin" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>{M.loading}</div>
|
|
210
|
+
{:else if loadOptions && !searched && combo.query.trim().length < minLength}
|
|
211
|
+
<div class="sv-ddl__empty">{M.typeMore}</div>
|
|
212
|
+
{:else if combo.filtered.length}
|
|
213
|
+
{#each groupOptions(combo.filtered) as g (g.group ?? ' ')}
|
|
214
|
+
{#if g.group != null}<div class="sv-ddl__group-label" aria-hidden="true">{g.group}</div>{/if}
|
|
215
|
+
{#each g.options as opt (opt.index)}
|
|
216
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
|
217
|
+
<div class="sv-ddl__opt" class:is-active={combo.isActive(opt.index)} class:is-selected={combo.isSelected(opt)} class:is-disabled={opt.disabled} {...combo.optionProps(opt.index)}>{#if item}{@render item(opt)}{:else}{opt.label}{/if}</div>
|
|
218
|
+
{/each}
|
|
219
|
+
{/each}
|
|
220
|
+
{:else if noData}
|
|
221
|
+
{@render noData()}
|
|
222
|
+
{:else}
|
|
223
|
+
<div class="sv-ddl__empty">{M.noResults}</div>
|
|
224
|
+
{/if}
|
|
225
|
+
{#if footer}<div class="sv-ddl__footer">{@render footer()}</div>{/if}
|
|
226
|
+
{#if showGrip}
|
|
227
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
228
|
+
<div class="sv-ddl__grip" onpointerdown={startResize} title="Drag to resize" aria-hidden="true">
|
|
229
|
+
<span class="sv-ddl__grip-dots"></span>
|
|
230
|
+
</div>
|
|
231
|
+
{/if}
|
|
232
|
+
</div>
|
|
233
|
+
{/if}
|
|
234
|
+
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
235
|
+
|
|
236
|
+
<style>
|
|
237
|
+
.sv-combo {
|
|
238
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
239
|
+
display: inline-flex; align-items: center; width: 200px;
|
|
240
|
+
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
241
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
|
|
242
|
+
}
|
|
243
|
+
.sv-combo--sm { height: 28px; font-size: 12px; }
|
|
244
|
+
.sv-combo--md { height: 34px; font-size: 13px; }
|
|
245
|
+
.sv-combo--lg { height: 40px; font-size: 15px; }
|
|
246
|
+
.sv-combo.is-open, .sv-combo:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
|
|
247
|
+
.sv-combo.is-invalid { border-color: var(--sg-danger, #dc2626); }
|
|
248
|
+
.sv-combo.is-invalid.is-open, .sv-combo.is-invalid:focus-within { box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-danger, #dc2626) 22%, transparent); }
|
|
249
|
+
.sv-combo.is-disabled { opacity: 0.6; }
|
|
250
|
+
.sv-combo__input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit; font: inherit; padding-block: 0; padding-inline: 10px 4px; }
|
|
251
|
+
.sv-combo__clear { display: grid; place-items: center; width: 22px; align-self: center; flex: none; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 4px; }
|
|
252
|
+
.sv-combo__clear:hover { color: var(--sg-danger, #dc2626); }
|
|
253
|
+
.sv-combo__chev { display: grid; place-items: center; width: 28px; align-self: stretch; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; }
|
|
254
|
+
:global(.sv-ddl__empty) { padding: 8px 10px; color: var(--sg-muted, #94a3b8); font-size: 13px; }
|
|
255
|
+
:global(.sv-ddl__header) { padding: 8px 10px; border-bottom: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; color: var(--sg-muted, #64748b); }
|
|
256
|
+
:global(.sv-ddl__footer) { padding: 8px 10px; border-top: 1px solid var(--sg-border, #e2e8f0); font-size: 12.5px; }
|
|
257
|
+
:global(.sv-ddl__loading) { display: flex; align-items: center; gap: 7px; }
|
|
258
|
+
:global(.sv-ddl__spin) { animation: sv-combo-spin 0.7s linear infinite; }
|
|
259
|
+
@keyframes sv-combo-spin { to { transform: rotate(360deg); } }
|
|
260
|
+
@media (prefers-reduced-motion: reduce) { :global(.sv-ddl__spin) { animation: none; } }
|
|
261
|
+
/* Bottom resize grip (shared "····" handle - see SvDropDownList / panel-resize). */
|
|
262
|
+
:global(.sv-ddl__panel.is-resizable) { padding-bottom: 0; }
|
|
263
|
+
:global(.sv-ddl__grip) {
|
|
264
|
+
position: sticky; bottom: 0; z-index: 1; margin: 2px -4px -4px; height: 15px;
|
|
265
|
+
display: flex; align-items: center; justify-content: center; cursor: ns-resize;
|
|
266
|
+
background: var(--sg-bg, #fff); border-top: 1px solid var(--sg-border, #e2e8f0);
|
|
267
|
+
border-radius: 0 0 10px 10px; touch-action: none;
|
|
268
|
+
}
|
|
269
|
+
:global(.sv-ddl__grip-dots) {
|
|
270
|
+
width: 26px; height: 4px; color: var(--sg-muted, #94a3b8); opacity: 0.6;
|
|
271
|
+
background-image: radial-gradient(currentColor 1px, transparent 1.6px);
|
|
272
|
+
background-size: 6px 4px; background-position: center; background-repeat: repeat-x;
|
|
273
|
+
}
|
|
274
|
+
:global(.sv-ddl__grip:hover .sv-ddl__grip-dots) { opacity: 1; }
|
|
275
|
+
</style>
|