@graphenedata/cli 0.0.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.
Files changed (123) hide show
  1. package/LICENSE.md +100 -0
  2. package/THIRD_PARTY_NOTICES.md +12 -0
  3. package/cli.ts +157 -0
  4. package/dist/cli/cli.js +43 -0
  5. package/dist/docs/data_apps/components/charts/annotations.md +673 -0
  6. package/dist/docs/data_apps/components/charts/area-chart.md +202 -0
  7. package/dist/docs/data_apps/components/charts/bar-chart.md +317 -0
  8. package/dist/docs/data_apps/components/charts/box-plot.md +190 -0
  9. package/dist/docs/data_apps/components/charts/bubble-chart.md +151 -0
  10. package/dist/docs/data_apps/components/charts/calendar-heatmap.md +112 -0
  11. package/dist/docs/data_apps/components/charts/custom-echarts.md +308 -0
  12. package/dist/docs/data_apps/components/charts/echarts-options.md +217 -0
  13. package/dist/docs/data_apps/components/charts/funnel-chart.md +106 -0
  14. package/dist/docs/data_apps/components/charts/heatmap.md +180 -0
  15. package/dist/docs/data_apps/components/charts/histogram.md +107 -0
  16. package/dist/docs/data_apps/components/charts/line-chart.md +265 -0
  17. package/dist/docs/data_apps/components/charts/mixed-type-charts.md +240 -0
  18. package/dist/docs/data_apps/components/charts/sankey-diagram.md +301 -0
  19. package/dist/docs/data_apps/components/charts/scatter-plot.md +134 -0
  20. package/dist/docs/data_apps/components/charts/sparkline.md +68 -0
  21. package/dist/docs/data_apps/components/data/big-value.md +153 -0
  22. package/dist/docs/data_apps/components/data/delta.md +89 -0
  23. package/dist/docs/data_apps/components/data/table.md +470 -0
  24. package/dist/docs/data_apps/components/data/value.md +97 -0
  25. package/dist/docs/data_apps/components/inputs/button-group.md +154 -0
  26. package/dist/docs/data_apps/components/inputs/checkbox.md +52 -0
  27. package/dist/docs/data_apps/components/inputs/date-input.md +131 -0
  28. package/dist/docs/data_apps/components/inputs/date-range.md +124 -0
  29. package/dist/docs/data_apps/components/inputs/dimension-grid.md +67 -0
  30. package/dist/docs/data_apps/components/inputs/dropdown.md +199 -0
  31. package/dist/docs/data_apps/components/inputs/index.md +3 -0
  32. package/dist/docs/data_apps/components/inputs/slider.md +126 -0
  33. package/dist/docs/data_apps/components/inputs/text-input.md +86 -0
  34. package/dist/docs/data_apps/components/maps/area-map.md +397 -0
  35. package/dist/docs/data_apps/components/maps/base-map.md +269 -0
  36. package/dist/docs/data_apps/components/maps/bubble-map.md +361 -0
  37. package/dist/docs/data_apps/components/maps/point-map.md +326 -0
  38. package/dist/docs/data_apps/components/maps/us-map.md +167 -0
  39. package/dist/docs/data_apps/components/ui/accordion.md +116 -0
  40. package/dist/docs/data_apps/components/ui/alert.md +37 -0
  41. package/dist/docs/data_apps/components/ui/big-link.md +19 -0
  42. package/dist/docs/data_apps/components/ui/details.md +58 -0
  43. package/dist/docs/data_apps/components/ui/download-data.md +41 -0
  44. package/dist/docs/data_apps/components/ui/embed.md +47 -0
  45. package/dist/docs/data_apps/components/ui/grid.md +45 -0
  46. package/dist/docs/data_apps/components/ui/image.md +61 -0
  47. package/dist/docs/data_apps/components/ui/info.md +47 -0
  48. package/dist/docs/data_apps/components/ui/last-refreshed.md +28 -0
  49. package/dist/docs/data_apps/components/ui/link-button.md +20 -0
  50. package/dist/docs/data_apps/components/ui/link.md +40 -0
  51. package/dist/docs/data_apps/components/ui/modal.md +57 -0
  52. package/dist/docs/data_apps/components/ui/note.md +32 -0
  53. package/dist/docs/data_apps/components/ui/print-format-components.md +85 -0
  54. package/dist/docs/data_apps/components/ui/tabs.md +122 -0
  55. package/dist/docs/graphene.md +129 -0
  56. package/dist/ui/app.css +332 -0
  57. package/dist/ui/assets/favicon.ico +0 -0
  58. package/dist/ui/component-utilities/autoFormatting.js +301 -0
  59. package/dist/ui/component-utilities/builtInFormats.js +482 -0
  60. package/dist/ui/component-utilities/chartContext.js +12 -0
  61. package/dist/ui/component-utilities/chartWindowDebug.js +21 -0
  62. package/dist/ui/component-utilities/checkInputs.js +95 -0
  63. package/dist/ui/component-utilities/convert.js +15 -0
  64. package/dist/ui/component-utilities/dateParsing.js +57 -0
  65. package/dist/ui/component-utilities/dropdownContext.ts +1 -0
  66. package/dist/ui/component-utilities/echarts.js +262 -0
  67. package/dist/ui/component-utilities/echartsThemes.js +453 -0
  68. package/dist/ui/component-utilities/formatTitle.js +24 -0
  69. package/dist/ui/component-utilities/formatting.js +258 -0
  70. package/dist/ui/component-utilities/getColumnExtents.js +79 -0
  71. package/dist/ui/component-utilities/getColumnSummary.js +67 -0
  72. package/dist/ui/component-utilities/getCompletedData.js +114 -0
  73. package/dist/ui/component-utilities/getDistinctCount.js +7 -0
  74. package/dist/ui/component-utilities/getDistinctValues.js +15 -0
  75. package/dist/ui/component-utilities/getSeriesConfig.js +236 -0
  76. package/dist/ui/component-utilities/getSortedData.js +7 -0
  77. package/dist/ui/component-utilities/getStackPercentages.js +43 -0
  78. package/dist/ui/component-utilities/getStackedData.js +17 -0
  79. package/dist/ui/component-utilities/getYAxisIndex.js +15 -0
  80. package/dist/ui/component-utilities/globalContexts.js +1 -0
  81. package/dist/ui/component-utilities/helpers/getCompletedData.helpers.js +119 -0
  82. package/dist/ui/component-utilities/inputUtils.ts +25 -0
  83. package/dist/ui/component-utilities/replaceNulls.js +14 -0
  84. package/dist/ui/component-utilities/tableUtils.ts +120 -0
  85. package/dist/ui/component-utilities/themeStores.ts +116 -0
  86. package/dist/ui/components/Area.svelte +174 -0
  87. package/dist/ui/components/AreaChart.svelte +150 -0
  88. package/dist/ui/components/Bar.svelte +326 -0
  89. package/dist/ui/components/BarChart.svelte +194 -0
  90. package/dist/ui/components/BigValue.svelte +69 -0
  91. package/dist/ui/components/Chart.svelte +1070 -0
  92. package/dist/ui/components/Column.svelte +172 -0
  93. package/dist/ui/components/DateRange.svelte +324 -0
  94. package/dist/ui/components/Dropdown.svelte +738 -0
  95. package/dist/ui/components/DropdownOption.svelte +21 -0
  96. package/dist/ui/components/ECharts.svelte +77 -0
  97. package/dist/ui/components/ErrorChart.svelte +54 -0
  98. package/dist/ui/components/GrapheneQuery.svelte +12 -0
  99. package/dist/ui/components/InlineDelta.svelte +150 -0
  100. package/dist/ui/components/Line.svelte +210 -0
  101. package/dist/ui/components/LineChart.svelte +178 -0
  102. package/dist/ui/components/PieChart.svelte +36 -0
  103. package/dist/ui/components/QueryLoad.svelte +82 -0
  104. package/dist/ui/components/Row.svelte +14 -0
  105. package/dist/ui/components/SortIcon.svelte +32 -0
  106. package/dist/ui/components/Table.svelte +19 -0
  107. package/dist/ui/components/TableCell.svelte +75 -0
  108. package/dist/ui/components/TableGroupRow.svelte +136 -0
  109. package/dist/ui/components/TableGroupToggle.svelte +42 -0
  110. package/dist/ui/components/TableHeader.svelte +242 -0
  111. package/dist/ui/components/TableRow.svelte +283 -0
  112. package/dist/ui/components/TableSubtotalRow.svelte +62 -0
  113. package/dist/ui/components/TableTotalRow.svelte +88 -0
  114. package/dist/ui/components/TextInput.svelte +92 -0
  115. package/dist/ui/components/_Table.svelte +516 -0
  116. package/dist/ui/internal/clientCache.ts +43 -0
  117. package/dist/ui/internal/queryEngine.ts +169 -0
  118. package/dist/ui/internal/telemetry.ts +28 -0
  119. package/dist/ui/internal/theme.ts +88 -0
  120. package/dist/ui/layout.svelte +3 -0
  121. package/dist/ui/playwright.config.ts +30 -0
  122. package/dist/ui/web.js +106 -0
  123. package/package.json +71 -0
@@ -0,0 +1,738 @@
1
+ <script lang="ts">
2
+ import {onMount, setContext, tick} from 'svelte'
3
+ import {DROPDOWN_CONTEXT} from '../component-utilities/dropdownContext'
4
+ import {ensureArray, toBoolean} from '../component-utilities/inputUtils'
5
+
6
+ interface Option {
7
+ value: any
8
+ label: string
9
+ }
10
+
11
+ export let name: string
12
+ export let data: string | undefined = undefined
13
+ export let value: string = 'value'
14
+ export let label: string | undefined = undefined
15
+ export let optionLabel: string | undefined = undefined
16
+ export let labelField: string | undefined = undefined
17
+ export let title: string | undefined = undefined
18
+ export let placeholder: string = 'Select option'
19
+ export let multiple: boolean | string = false
20
+ export let defaultValue: string | string[] | undefined = undefined
21
+ export let selectAllByDefault: boolean | string = false
22
+ export let noDefault: boolean | string = false
23
+ export let disableSelectAll: boolean | string = false
24
+ export let hideDuringPrint: boolean | string = true
25
+ export let description: string | undefined = undefined
26
+ export let disabled: boolean | string = false
27
+
28
+ let mounted = false
29
+ let queryOptions: Option[] = []
30
+ let manualOptions: Option[] = []
31
+ let selection: any[] = []
32
+ let touched = false
33
+ let queryHandler: ((res: {rows?: any[]; error?: any}) => void) | null = null
34
+ let queryKey = ''
35
+
36
+ let isOpen = false
37
+ let searchTerm = ''
38
+ let activeIndex = -1
39
+ let triggerEl: HTMLButtonElement | null = null
40
+ let menuEl: HTMLDivElement | null = null
41
+ let searchInput: HTMLInputElement | null = null
42
+ let triggerWidth = 0
43
+
44
+ const registerOption = (opt: Option) => {
45
+ manualOptions = [...manualOptions, opt]
46
+ syncSelection(false)
47
+ return () => {
48
+ manualOptions = manualOptions.filter(o => optionKey(o.value) !== optionKey(opt.value))
49
+ syncSelection(false)
50
+ }
51
+ }
52
+ setContext(DROPDOWN_CONTEXT, registerOption)
53
+
54
+ const optionKey = (val: any): string => {
55
+ if (val === null) return 'null'
56
+ if (val === undefined) return 'undefined'
57
+ if (typeof val === 'object') {
58
+ try {
59
+ return JSON.stringify(val)
60
+ } catch {
61
+ return String(val)
62
+ }
63
+ }
64
+ return String(val)
65
+ }
66
+
67
+ const combineOptions = (manual: Option[], queried: Option[]) => {
68
+ let map = new Map<string, Option>()
69
+ for (let opt of [...manual, ...queried]) {
70
+ let key = optionKey(opt.value)
71
+ if (!map.has(key)) map.set(key, opt)
72
+ }
73
+ return Array.from(map.values())
74
+ }
75
+
76
+ $: multi = toBoolean(multiple)
77
+ $: selectAllDefault = toBoolean(selectAllByDefault)
78
+ $: hasNoDefault = toBoolean(noDefault)
79
+ $: hidePrint = toBoolean(hideDuringPrint)
80
+ $: isDisabled = toBoolean(disabled)
81
+ $: disableSelectAllButton = toBoolean(disableSelectAll)
82
+
83
+ $: resolvedLabelField = optionLabel || labelField || (label && data ? label : undefined)
84
+ $: resolvedTitle = title || (!data ? label : undefined)
85
+ $: triggerPlaceholder = placeholder || resolvedTitle || 'Select option'
86
+ $: searchPlaceholder = resolvedTitle || placeholder || 'Search options'
87
+
88
+ $: availableOptions = combineOptions(manualOptions, queryOptions)
89
+ $: valueMap = new Map(availableOptions.map(opt => [optionKey(opt.value), opt]))
90
+ $: filteredOptions = filterOptions(availableOptions, searchTerm)
91
+ $: if (isOpen) activeIndex = ensureActiveIndex(activeIndex, filteredOptions)
92
+ $: selectedDisplayOptions = selection.map(val => valueMap.get(optionKey(val)) || {value: val, label: String(val ?? '')})
93
+
94
+ function setupQuery () {
95
+ if (!mounted) return
96
+ let key = data ? `${data}::${value}::${resolvedLabelField || ''}` : ''
97
+ if (key === queryKey) return
98
+ if (queryHandler) {
99
+ window.$GRAPHENE?.unsubscribe?.(queryHandler)
100
+ queryHandler = null
101
+ }
102
+ queryKey = key
103
+ if (!data) {
104
+ queryOptions = []
105
+ syncSelection(false)
106
+ return
107
+ }
108
+ let columns = [value]
109
+ if (resolvedLabelField && resolvedLabelField !== value) columns.push(resolvedLabelField)
110
+ let handler = (res: {rows?: any[]; error?: any}) => {
111
+ if (res.error) return
112
+ if (!res.rows) return
113
+ queryOptions = res.rows.map(row => ({
114
+ value: row[value],
115
+ label: resolvedLabelField ? row[resolvedLabelField] : row[value],
116
+ }))
117
+ syncSelection(false)
118
+ }
119
+ if (typeof window !== 'undefined' && window.$GRAPHENE?.query) {
120
+ window.$GRAPHENE.query(data, columns, handler)
121
+ queryHandler = handler
122
+ }
123
+ }
124
+
125
+ const handlePointerDown = (event: PointerEvent) => {
126
+ if (!isOpen) return
127
+ let target = event.target as Node | null
128
+ if (menuEl?.contains(target) || triggerEl?.contains(target)) return
129
+ closeMenu(false)
130
+ }
131
+
132
+ function filterOptions (opts: Option[], term: string): Option[] {
133
+ let trimmed = term.trim().toLowerCase()
134
+ if (!trimmed) return opts
135
+ return opts.filter(opt => {
136
+ let text = opt.label ?? opt.value
137
+ return String(text ?? '').toLowerCase().includes(trimmed)
138
+ })
139
+ }
140
+
141
+ function ensureActiveIndex (current: number, opts: Option[]): number {
142
+ if (!opts.length) return -1
143
+ if (current >= 0 && current < opts.length) return current
144
+ let selectedIdx = opts.findIndex(opt => isOptionSelected(opt))
145
+ return selectedIdx >= 0 ? selectedIdx : 0
146
+ }
147
+
148
+ function isOptionSelected (opt: Option): boolean {
149
+ let key = optionKey(opt.value)
150
+ return selection.some(val => optionKey(val) === key)
151
+ }
152
+
153
+ async function focusSearchInput () {
154
+ await tick()
155
+ if (searchInput) searchInput.focus()
156
+ }
157
+
158
+ function updateTriggerWidth () {
159
+ if (!triggerEl) {
160
+ if (triggerWidth !== 0) triggerWidth = 0
161
+ return
162
+ }
163
+ let width = Math.round(triggerEl.getBoundingClientRect().width)
164
+ if (width !== triggerWidth) triggerWidth = width
165
+ }
166
+
167
+ function openMenu (focusSearch = true) {
168
+ if (isDisabled) return
169
+ updateTriggerWidth()
170
+ isOpen = true
171
+ if (focusSearch) focusSearchInput()
172
+ }
173
+
174
+ function closeMenu (focusTrigger: boolean) {
175
+ if (!isOpen) return
176
+ isOpen = false
177
+ activeIndex = -1
178
+ searchTerm = ''
179
+ if (focusTrigger) triggerEl?.focus()
180
+ }
181
+
182
+ function toggleMenu () {
183
+ if (isDisabled) return
184
+ if (isOpen) {
185
+ closeMenu(false)
186
+ } else {
187
+ openMenu(true)
188
+ }
189
+ }
190
+
191
+ function moveActive (delta: number) {
192
+ if (!filteredOptions.length) {
193
+ activeIndex = -1
194
+ return
195
+ }
196
+ let next = activeIndex
197
+ if (next < 0) {
198
+ next = delta > 0 ? 0 : filteredOptions.length - 1
199
+ } else {
200
+ next = (next + delta + filteredOptions.length) % filteredOptions.length
201
+ }
202
+ activeIndex = next
203
+ }
204
+
205
+ function selectActiveOption () {
206
+ if (!filteredOptions.length || activeIndex < 0 || activeIndex >= filteredOptions.length) return
207
+ handleOptionSelect(filteredOptions[activeIndex], true)
208
+ }
209
+
210
+ function handleTriggerKeydown (event: KeyboardEvent) {
211
+ if (event.defaultPrevented) return
212
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter' || event.key === ' ') {
213
+ event.preventDefault()
214
+ if (!isOpen) {
215
+ openMenu(true)
216
+ } else if (event.key === 'ArrowDown') {
217
+ moveActive(1)
218
+ } else if (event.key === 'ArrowUp') {
219
+ moveActive(-1)
220
+ } else if (event.key === 'Enter') {
221
+ selectActiveOption()
222
+ }
223
+ }
224
+ }
225
+
226
+ function handleMenuKeydown (event: KeyboardEvent) {
227
+ if (event.key === 'ArrowDown') {
228
+ event.preventDefault()
229
+ moveActive(1)
230
+ } else if (event.key === 'ArrowUp') {
231
+ event.preventDefault()
232
+ moveActive(-1)
233
+ } else if (event.key === 'Home') {
234
+ event.preventDefault()
235
+ if (filteredOptions.length) activeIndex = 0
236
+ } else if (event.key === 'End') {
237
+ event.preventDefault()
238
+ if (filteredOptions.length) activeIndex = filteredOptions.length - 1
239
+ } else if (event.key === 'Enter') {
240
+ event.preventDefault()
241
+ selectActiveOption()
242
+ } else if (event.key === 'Escape') {
243
+ event.preventDefault()
244
+ closeMenu(true)
245
+ } else if (event.key === 'Tab') {
246
+ closeMenu(false)
247
+ }
248
+ }
249
+
250
+ function handleOptionKeydown (event: KeyboardEvent, opt: Option) {
251
+ if (event.key === 'Enter' || event.key === ' ') {
252
+ event.preventDefault()
253
+ handleOptionSelect(opt, true)
254
+ }
255
+ }
256
+
257
+ function handleOptionSelect (opt: Option, fromKeyboard: boolean) {
258
+ let key = optionKey(opt.value)
259
+ if (multi) {
260
+ let exists = selection.some(val => optionKey(val) === key)
261
+ if (exists) {
262
+ let next = selection.filter(val => optionKey(val) !== key)
263
+ setSelection(next, true)
264
+ } else {
265
+ setSelection([...selection, opt.value], true)
266
+ }
267
+ } else {
268
+ setSelection([opt.value], true)
269
+ closeMenu(fromKeyboard)
270
+ }
271
+ }
272
+
273
+ function scrollActiveIntoView () {
274
+ if (!isOpen || activeIndex < 0) return
275
+ let optionEl = menuEl?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
276
+ optionEl?.scrollIntoView({block: 'nearest'})
277
+ }
278
+
279
+ $: if (isOpen && activeIndex >= 0) tick().then(scrollActiveIntoView)
280
+
281
+ onMount(() => {
282
+ mounted = true
283
+ let defaults = ensureArray(defaultValue)
284
+ if (!hasNoDefault && defaults.length) setSelection(defaults, false)
285
+ syncSelection(false)
286
+ setupQuery()
287
+ if (typeof document !== 'undefined') {
288
+ document.addEventListener('pointerdown', handlePointerDown)
289
+ window.addEventListener('resize', updateTriggerWidth)
290
+ }
291
+ return () => {
292
+ mounted = false
293
+ if (queryHandler) {
294
+ window.$GRAPHENE?.unsubscribe?.(queryHandler)
295
+ queryHandler = null
296
+ }
297
+ if (typeof document !== 'undefined') {
298
+ document.removeEventListener('pointerdown', handlePointerDown)
299
+ }
300
+ if (typeof window !== 'undefined') {
301
+ window.removeEventListener('resize', updateTriggerWidth)
302
+ }
303
+ }
304
+ })
305
+
306
+ $: setupQuery()
307
+
308
+ $: if (triggerEl) updateTriggerWidth()
309
+
310
+ function syncSelection (fromUser: boolean) {
311
+ let opts = availableOptions
312
+ if (!opts.length) {
313
+ if (selection.length) updateInputPayload(selection)
314
+ return
315
+ }
316
+ let nextSelection = selection.filter(val => valueMap.has(optionKey(val)))
317
+ if (!fromUser) {
318
+ let defaults = ensureArray(defaultValue)
319
+ if (multi && selectAllDefault) {
320
+ nextSelection = opts.map(o => o.value)
321
+ } else if (!touched) {
322
+ if (defaults.length) {
323
+ nextSelection = defaults.filter(val => valueMap.has(optionKey(val)))
324
+ } else if (!multi && !hasNoDefault) {
325
+ nextSelection = selection.length ? nextSelection : []
326
+ }
327
+ }
328
+ }
329
+ setSelection(nextSelection, fromUser)
330
+ }
331
+
332
+ function setSelection (values: any[], fromUser: boolean) {
333
+ let keys = values.map(optionKey)
334
+ let existingKeys = selection.map(optionKey)
335
+ let changed = keys.length !== existingKeys.length || keys.some((k, idx) => k !== existingKeys[idx])
336
+ if (!changed) {
337
+ if (!fromUser) updateInputPayload(selection)
338
+ return
339
+ }
340
+ selection = values
341
+ if (fromUser) touched = true
342
+ updateInputPayload(selection)
343
+ }
344
+
345
+ function updateInputPayload (values: any[]) {
346
+ let paramValue = values.length ? values[0] : null
347
+ window.$GRAPHENE.updateParam(name, paramValue)
348
+ }
349
+
350
+ function selectAll () {
351
+ if (!multi) return
352
+ setSelection(availableOptions.map(opt => opt.value), true)
353
+ }
354
+
355
+ function clearSelection () {
356
+ setSelection([], true)
357
+ }
358
+
359
+ const elementId = `dropdown-${name}`
360
+ const menuId = `${elementId}-menu`
361
+
362
+ function getContainerClass () {
363
+ if (!hidePrint) return 'input-block'
364
+ return 'input-block hide-print'
365
+ }
366
+
367
+ function getDropdownClass () {
368
+ if (!isDisabled) return 'dropdown'
369
+ return 'dropdown is-disabled'
370
+ }
371
+
372
+ function getOptionClass (opt: Option, idx: number) {
373
+ let classes = 'dropdown-option'
374
+ if (isOptionSelected(opt)) classes += ' is-selected'
375
+ if (activeIndex === idx) classes += ' is-active'
376
+ return classes
377
+ }
378
+ </script>
379
+
380
+ <div class={getContainerClass()}>
381
+ {#if resolvedTitle}
382
+ <label class="input-label" for={elementId}>{resolvedTitle}</label>
383
+ {/if}
384
+ {#if description}
385
+ <div class="input-description">{description}</div>
386
+ {/if}
387
+ <div class={getDropdownClass()}>
388
+ <button
389
+ bind:this={triggerEl}
390
+ id={elementId}
391
+ class="dropdown-trigger"
392
+ type="button"
393
+ disabled={isDisabled}
394
+ role="combobox"
395
+ aria-label={resolvedTitle || name}
396
+ aria-haspopup="listbox"
397
+ aria-expanded={isOpen}
398
+ aria-controls={menuId}
399
+ aria-activedescendant={isOpen && activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
400
+ on:click={toggleMenu}
401
+ on:keydown={handleTriggerKeydown}
402
+ >
403
+ <span class="dropdown-trigger-label">
404
+ {#if multi}
405
+ {#if selectedDisplayOptions.length === 0}
406
+ {triggerPlaceholder}
407
+ {:else if selectedDisplayOptions.length > 3}
408
+ <span class="dropdown-badge">{selectedDisplayOptions.length} selected</span>
409
+ {:else}
410
+ {#each selectedDisplayOptions as opt (optionKey(opt.value))}
411
+ <span class="dropdown-badge">{opt.label}</span>
412
+ {/each}
413
+ {/if}
414
+ {:else}
415
+ {#if selectedDisplayOptions.length}
416
+ {selectedDisplayOptions[0].label}
417
+ {:else}
418
+ {triggerPlaceholder}
419
+ {/if}
420
+ {/if}
421
+ </span>
422
+ <span class={`dropdown-caret${isOpen ? ' is-open' : ''}`} aria-hidden="true">
423
+ <svg viewBox="0 0 16 16" role="presentation"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
424
+ </span>
425
+ </button>
426
+ {#if isOpen}
427
+ <div
428
+ bind:this={menuEl}
429
+ id={menuId}
430
+ class="dropdown-menu"
431
+ role="listbox"
432
+ tabindex="0"
433
+ aria-multiselectable={multi}
434
+ style={`min-width: ${Math.max(triggerWidth, 220)}px`}
435
+ on:keydown={handleMenuKeydown}
436
+ >
437
+ <div class="dropdown-search">
438
+ <input
439
+ bind:this={searchInput}
440
+ type="text"
441
+ placeholder={searchPlaceholder}
442
+ bind:value={searchTerm}
443
+ class="dropdown-search-input"
444
+ />
445
+ </div>
446
+ <div class="dropdown-options pretty-scrollbar" role="presentation">
447
+ {#if !filteredOptions.length}
448
+ <div class="dropdown-empty">No results found</div>
449
+ {:else}
450
+ {#each filteredOptions as opt, index (optionKey(opt.value))}
451
+ <div
452
+ class={getOptionClass(opt, index)}
453
+ role="option"
454
+ tabindex="-1"
455
+ aria-selected={isOptionSelected(opt)}
456
+ data-index={index}
457
+ id={`${menuId}-option-${index}`}
458
+ on:mousedown|preventDefault
459
+ on:mouseenter={() => activeIndex = index}
460
+ on:click={() => handleOptionSelect(opt, false)}
461
+ on:keydown={(event) => handleOptionKeydown(event, opt)}
462
+ >
463
+ <span class="dropdown-check" aria-hidden="true">
464
+ {#if multi}
465
+ <svg viewBox="0 0 16 16">
466
+ <rect
467
+ x="1.5"
468
+ y="1.5"
469
+ width="13"
470
+ height="13"
471
+ rx="3"
472
+ ry="3"
473
+ fill="none"
474
+ stroke="currentColor"
475
+ stroke-width="1.5"
476
+ class={`checkbox-border${isOptionSelected(opt) ? ' is-filled' : ''}`}
477
+ />
478
+ {#if isOptionSelected(opt)}
479
+ <path d="M4 8l2.5 2.5L12 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
480
+ {/if}
481
+ </svg>
482
+ {:else}
483
+ <svg viewBox="0 0 16 16">
484
+ <path
485
+ d="M4 6l4 4 4-4"
486
+ fill="none"
487
+ stroke="currentColor"
488
+ stroke-width="1.5"
489
+ stroke-linecap="round"
490
+ stroke-linejoin="round"
491
+ class={`checkmark${isOptionSelected(opt) ? ' is-visible' : ''}`}
492
+ />
493
+ </svg>
494
+ {/if}
495
+ </span>
496
+ <span class="dropdown-option-label">{opt.label}</span>
497
+ </div>
498
+ {/each}
499
+ {/if}
500
+ </div>
501
+ {#if multi}
502
+ <div class="dropdown-footer">
503
+ {#if !disableSelectAllButton}
504
+ <button type="button" class="dropdown-footer-action" on:click|stopPropagation={(event) => { event.preventDefault(); selectAll() }}>Select all</button>
505
+ {/if}
506
+ <button
507
+ type="button"
508
+ class="dropdown-footer-action"
509
+ disabled={!selection.length}
510
+ on:click|stopPropagation={(event) => { event.preventDefault(); clearSelection() }}
511
+ >
512
+ Clear selection
513
+ </button>
514
+ </div>
515
+ {/if}
516
+ </div>
517
+ {/if}
518
+ </div>
519
+ </div>
520
+
521
+ <style>
522
+ .input-block {
523
+ display: flex;
524
+ flex-direction: column;
525
+ gap: 4px;
526
+ margin: 8px 0;
527
+ }
528
+ .hide-print {
529
+ /* Hide during print */
530
+ }
531
+ @media print {
532
+ .hide-print {
533
+ display: none !important;
534
+ }
535
+ }
536
+ .input-label {
537
+ font-size: 12px;
538
+ font-weight: 600;
539
+ color: var(--input-label-color, #374151);
540
+ }
541
+ .input-description {
542
+ font-size: 12px;
543
+ color: rgba(55, 65, 81, 0.8);
544
+ }
545
+ .dropdown {
546
+ position: relative;
547
+ display: inline-block;
548
+ }
549
+ .dropdown.is-disabled {
550
+ opacity: 0.6;
551
+ pointer-events: none;
552
+ }
553
+ .dropdown-trigger {
554
+ display: inline-flex;
555
+ align-items: center;
556
+ justify-content: space-between;
557
+ gap: 8px;
558
+ min-width: 200px;
559
+ min-height: 36px;
560
+ padding: 0 12px;
561
+ border-radius: 8px;
562
+ border: 1px solid #d1d5db;
563
+ background: #ffffff;
564
+ color: #1f2937;
565
+ font-size: 14px;
566
+ font-family: var(--ui-font-family);
567
+ cursor: pointer;
568
+ transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
569
+ }
570
+ .dropdown-trigger:hover {
571
+ border-color: #94a3b8;
572
+ }
573
+ .dropdown-trigger:focus-visible {
574
+ outline: 2px solid rgba(37, 99, 235, 0.25);
575
+ outline-offset: 2px;
576
+ }
577
+ .dropdown-trigger[disabled] {
578
+ cursor: not-allowed;
579
+ background: #f3f4f6;
580
+ color: #94a3b8;
581
+ }
582
+ .dropdown-trigger-label {
583
+ display: flex;
584
+ align-items: center;
585
+ gap: 6px;
586
+ flex: 1;
587
+ overflow: hidden;
588
+ text-overflow: ellipsis;
589
+ white-space: nowrap;
590
+ }
591
+ .dropdown-badge {
592
+ display: inline-flex;
593
+ align-items: center;
594
+ padding: 0 6px;
595
+ border-radius: 6px;
596
+ background: #e2e8f0;
597
+ color: #1f2937;
598
+ font-size: 12px;
599
+ line-height: 20px;
600
+ white-space: nowrap;
601
+ }
602
+ .dropdown-caret {
603
+ display: inline-flex;
604
+ align-items: center;
605
+ justify-content: center;
606
+ width: 16px;
607
+ height: 16px;
608
+ transition: transform 120ms ease;
609
+ }
610
+ .dropdown-caret svg {
611
+ width: 16px;
612
+ height: 16px;
613
+ }
614
+ .dropdown-caret.is-open {
615
+ transform: rotate(180deg);
616
+ }
617
+ .dropdown-menu {
618
+ position: absolute;
619
+ top: calc(100% + 6px);
620
+ left: 0;
621
+ z-index: 40;
622
+ background: #ffffff;
623
+ color: #1f2937;
624
+ border: 1px solid #d1d5db;
625
+ border-radius: 10px;
626
+ box-shadow: 0 18px 32px rgba(15, 23, 42, 0.12);
627
+ padding: 10px 0 8px;
628
+ display: flex;
629
+ flex-direction: column;
630
+ gap: 8px;
631
+ }
632
+ .dropdown-search {
633
+ padding: 0 16px;
634
+ }
635
+ .dropdown-search-input {
636
+ width: 100%;
637
+ border-radius: 6px;
638
+ border: 1px solid #d1d5db;
639
+ padding: 6px 10px;
640
+ font-size: 13px;
641
+ background: #f9fafb;
642
+ color: inherit;
643
+ box-sizing: border-box;
644
+ }
645
+ .dropdown-search-input:focus {
646
+ outline: none;
647
+ border-color: #2563eb;
648
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
649
+ }
650
+ .dropdown-options {
651
+ max-height: 220px;
652
+ overflow-y: auto;
653
+ padding: 4px 8px;
654
+ display: flex;
655
+ flex-direction: column;
656
+ gap: 4px;
657
+ }
658
+ .dropdown-option {
659
+ display: flex;
660
+ align-items: center;
661
+ gap: 10px;
662
+ padding: 6px 10px;
663
+ border-radius: 8px;
664
+ cursor: pointer;
665
+ font-size: 14px;
666
+ transition: background 100ms ease, color 100ms ease;
667
+ color: #1f2937;
668
+ }
669
+ .dropdown-option:hover,
670
+ .dropdown-option.is-active {
671
+ background: #f1f5f9;
672
+ }
673
+ .dropdown-option.is-selected {
674
+ font-weight: 600;
675
+ }
676
+ .dropdown-option.is-selected .dropdown-check {
677
+ color: #2563eb;
678
+ }
679
+ .dropdown-option-label {
680
+ flex: 1;
681
+ overflow: hidden;
682
+ text-overflow: ellipsis;
683
+ white-space: nowrap;
684
+ }
685
+ .dropdown-check {
686
+ display: inline-flex;
687
+ width: 18px;
688
+ height: 18px;
689
+ align-items: center;
690
+ justify-content: center;
691
+ color: #9ca3af;
692
+ }
693
+ .dropdown-check svg {
694
+ width: 18px;
695
+ height: 18px;
696
+ }
697
+ .dropdown-option.is-selected .dropdown-check {
698
+ color: #2563eb;
699
+ }
700
+ .checkbox-border.is-filled {
701
+ fill: #2563eb;
702
+ stroke: #2563eb;
703
+ }
704
+ .checkmark {
705
+ opacity: 0;
706
+ stroke: #2563eb;
707
+ }
708
+ .checkmark.is-visible {
709
+ opacity: 1;
710
+ }
711
+ .dropdown-empty {
712
+ padding: 12px;
713
+ text-align: center;
714
+ color: #6b7280;
715
+ font-size: 13px;
716
+ }
717
+ .dropdown-footer {
718
+ display: flex;
719
+ gap: 12px;
720
+ padding: 8px 16px 0 16px;
721
+ border-top: 1px solid #e5e7eb;
722
+ }
723
+ .dropdown-footer-action {
724
+ border: none;
725
+ background: none;
726
+ color: #2563eb;
727
+ font-size: 13px;
728
+ cursor: pointer;
729
+ padding: 4px 0;
730
+ }
731
+ .dropdown-footer-action[disabled] {
732
+ opacity: 0.4;
733
+ cursor: not-allowed;
734
+ }
735
+ .dropdown-footer-action:hover:not([disabled]) {
736
+ text-decoration: underline;
737
+ }
738
+ </style>