@joewinke/jatui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/package.json +46 -0
  2. package/src/lib/components/AudioWaveform.svelte +694 -0
  3. package/src/lib/components/AvailabilityModal.svelte +173 -0
  4. package/src/lib/components/Badge.svelte +38 -0
  5. package/src/lib/components/BookingForm.svelte +276 -0
  6. package/src/lib/components/Button.svelte +72 -0
  7. package/src/lib/components/CalendarPicker.svelte +284 -0
  8. package/src/lib/components/Card.svelte +67 -0
  9. package/src/lib/components/CharacterCounter.svelte +82 -0
  10. package/src/lib/components/ChipInput.svelte +596 -0
  11. package/src/lib/components/ColorSelector.svelte +163 -0
  12. package/src/lib/components/ConfirmModal.svelte +75 -0
  13. package/src/lib/components/CountdownTimer.svelte +94 -0
  14. package/src/lib/components/DateRangePicker.svelte +192 -0
  15. package/src/lib/components/Drawer.svelte +110 -0
  16. package/src/lib/components/FilterDropdown.svelte +202 -0
  17. package/src/lib/components/ImageUpload.svelte +97 -0
  18. package/src/lib/components/InlineEdit.svelte +283 -0
  19. package/src/lib/components/LazyImage.svelte +122 -0
  20. package/src/lib/components/LoadingSpinner.svelte +102 -0
  21. package/src/lib/components/Modal.svelte +208 -0
  22. package/src/lib/components/PhoneInput.svelte +92 -0
  23. package/src/lib/components/ResizableDivider.svelte +305 -0
  24. package/src/lib/components/ResizablePanel.svelte +302 -0
  25. package/src/lib/components/SearchDropdown.svelte +341 -0
  26. package/src/lib/components/SelectInput.svelte +215 -0
  27. package/src/lib/components/SignaturePad.svelte +171 -0
  28. package/src/lib/components/SortDropdown.svelte +148 -0
  29. package/src/lib/components/Sparkline.svelte +107 -0
  30. package/src/lib/components/SpeechForm.svelte +114 -0
  31. package/src/lib/components/StatusBadge.svelte +155 -0
  32. package/src/lib/components/TextArea.svelte +143 -0
  33. package/src/lib/components/TextInput.svelte +108 -0
  34. package/src/lib/components/ThemeSelector.svelte +195 -0
  35. package/src/lib/components/TimeSlotPicker.svelte +162 -0
  36. package/src/lib/components/VoicePlayer.svelte +420 -0
  37. package/src/lib/components/messaging/Avatar.svelte +81 -0
  38. package/src/lib/components/messaging/ChannelInfoModal.svelte +163 -0
  39. package/src/lib/components/messaging/ChannelList.svelte +107 -0
  40. package/src/lib/components/messaging/ChannelMemberAvatarStack.svelte +69 -0
  41. package/src/lib/components/messaging/ChannelMembersModal.svelte +182 -0
  42. package/src/lib/components/messaging/CreateChannelModal.svelte +190 -0
  43. package/src/lib/components/messaging/DirectMessageList.svelte +145 -0
  44. package/src/lib/components/messaging/EmojiSelector.svelte +260 -0
  45. package/src/lib/components/messaging/MentionAutocomplete.svelte +193 -0
  46. package/src/lib/components/messaging/MessageAttachment.svelte +270 -0
  47. package/src/lib/components/messaging/MessageAttachmentUpload.svelte +243 -0
  48. package/src/lib/components/messaging/MessageInput.svelte +451 -0
  49. package/src/lib/components/messaging/MessageItem.svelte +338 -0
  50. package/src/lib/components/messaging/MessageThread.svelte +306 -0
  51. package/src/lib/components/messaging/NotificationSettingsModal.svelte +234 -0
  52. package/src/lib/components/messaging/QuotedMessageDisplay.svelte +118 -0
  53. package/src/lib/components/messaging/StartDMModal.svelte +100 -0
  54. package/src/lib/components/messaging/ThreadPanel.svelte +153 -0
  55. package/src/lib/index.ts +185 -0
  56. package/src/lib/types/booking.ts +143 -0
  57. package/src/lib/types/messaging.ts +459 -0
  58. package/src/lib/utils/currency.ts +20 -0
  59. package/src/lib/utils/daisyuiColors.ts +243 -0
  60. package/src/lib/utils/dateFormatters.ts +153 -0
  61. package/src/lib/utils/mentionParser.ts +188 -0
  62. package/src/lib/utils/phoneFormat.ts +74 -0
@@ -0,0 +1,215 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SelectInput Component
4
+ *
5
+ * Dropdown select with searchable mode, grouped options, and validation.
6
+ * Converted from Svelte 4 to Svelte 5 runes.
7
+ */
8
+
9
+ import { onMount } from 'svelte';
10
+
11
+ export interface SelectOption {
12
+ value: string | number;
13
+ label: string;
14
+ disabled?: boolean;
15
+ group?: string;
16
+ }
17
+
18
+ interface Props {
19
+ label: string;
20
+ value?: string | number;
21
+ options?: SelectOption[];
22
+ placeholder?: string;
23
+ required?: boolean;
24
+ disabled?: boolean;
25
+ searchable?: boolean;
26
+ error?: string;
27
+ helpText?: string;
28
+ size?: 'sm' | 'md' | 'lg';
29
+ class?: string;
30
+ id?: string;
31
+ name?: string;
32
+ clearSearchOnSelect?: boolean;
33
+ onchange?: (value: string | number, option: SelectOption | undefined) => void;
34
+ }
35
+
36
+ let {
37
+ label,
38
+ value = $bindable(''),
39
+ options = [],
40
+ placeholder = 'Select an option',
41
+ required = false,
42
+ disabled = false,
43
+ searchable = false,
44
+ error = '',
45
+ helpText = '',
46
+ size = 'md',
47
+ class: className = '',
48
+ id = `select-input-${Math.random().toString(36).substr(2, 9)}`,
49
+ name = '',
50
+ clearSearchOnSelect = true,
51
+ onchange
52
+ }: Props = $props();
53
+
54
+ let searchTerm = $state('');
55
+ let isOpen = $state(false);
56
+ let selectElement: HTMLElement | undefined = $state();
57
+
58
+ const sizeClass = $derived({ sm: 'select-sm', md: '', lg: 'select-lg' }[size]);
59
+
60
+ const selectClass = $derived(
61
+ ['select select-bordered w-full', sizeClass, error ? 'select-error' : '', disabled ? 'select-disabled' : '', className]
62
+ .filter(Boolean)
63
+ .join(' ')
64
+ );
65
+
66
+ const filteredOptions = $derived(
67
+ searchable && searchTerm
68
+ ? options.filter((o) => o.label.toLowerCase().includes(searchTerm.toLowerCase()))
69
+ : options
70
+ );
71
+
72
+ const selectedOption = $derived(options.find((o) => o.value === value));
73
+
74
+ const groupedOptions = $derived(
75
+ filteredOptions.reduce(
76
+ (acc, option) => {
77
+ const group = option.group || 'default';
78
+ if (!acc[group]) acc[group] = [];
79
+ acc[group].push(option);
80
+ return acc;
81
+ },
82
+ {} as Record<string, SelectOption[]>
83
+ )
84
+ );
85
+
86
+ function handleSelect(option: SelectOption) {
87
+ if (option.disabled) return;
88
+ value = option.value;
89
+ onchange?.(value, option);
90
+ if (clearSearchOnSelect) searchTerm = '';
91
+ isOpen = false;
92
+ }
93
+
94
+ function handleKeydown(event: KeyboardEvent) {
95
+ if (event.key === 'Escape') {
96
+ isOpen = false;
97
+ searchTerm = '';
98
+ }
99
+ }
100
+
101
+ function handleClickOutside(event: Event) {
102
+ if (selectElement && !selectElement.contains(event.target as Node)) {
103
+ isOpen = false;
104
+ searchTerm = '';
105
+ }
106
+ }
107
+
108
+ function handleNativeChange(e: Event) {
109
+ const target = e.currentTarget as HTMLSelectElement;
110
+ const option = options.find((opt) => opt.value.toString() === target.value);
111
+ onchange?.(value!, option);
112
+ }
113
+
114
+ onMount(() => {
115
+ document.addEventListener('click', handleClickOutside);
116
+ return () => document.removeEventListener('click', handleClickOutside);
117
+ });
118
+ </script>
119
+
120
+ <div class="form-control w-full" bind:this={selectElement}>
121
+ <label for={id} class="label">
122
+ <span class="label-text font-medium">
123
+ {label}
124
+ {#if required}
125
+ <span class="text-error ml-1">*</span>
126
+ {/if}
127
+ </span>
128
+ </label>
129
+
130
+ {#if searchable}
131
+ <div class="dropdown w-full {isOpen ? 'dropdown-open' : ''}">
132
+ <div tabindex="0" role="button" class="btn btn-outline w-full justify-between {sizeClass}">
133
+ <input
134
+ {id}
135
+ {name}
136
+ type="text"
137
+ placeholder={selectedOption?.label || placeholder}
138
+ bind:value={searchTerm}
139
+ onfocus={() => (isOpen = true)}
140
+ onkeydown={handleKeydown}
141
+ class="input input-ghost w-full p-0 h-auto min-h-0 focus:outline-none"
142
+ {disabled}
143
+ {required}
144
+ aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
145
+ aria-invalid={error ? 'true' : 'false'}
146
+ />
147
+ <svg class="w-4 h-4 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
148
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
149
+ </svg>
150
+ </div>
151
+
152
+ {#if isOpen}
153
+ <div class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-full max-h-60 overflow-auto">
154
+ {#each Object.entries(groupedOptions) as [groupName, groupOpts]}
155
+ {#if groupName !== 'default'}
156
+ <div class="menu-title"><span>{groupName}</span></div>
157
+ {/if}
158
+ {#each groupOpts as option}
159
+ <li>
160
+ <button
161
+ type="button"
162
+ class="text-left {option.disabled ? 'disabled' : ''}"
163
+ class:active={option.value === value}
164
+ onclick={() => handleSelect(option)}
165
+ disabled={option.disabled}
166
+ >
167
+ {option.label}
168
+ </button>
169
+ </li>
170
+ {/each}
171
+ {/each}
172
+ </div>
173
+ {/if}
174
+ </div>
175
+ {:else}
176
+ <select
177
+ {id}
178
+ {name}
179
+ {required}
180
+ {disabled}
181
+ bind:value
182
+ class={selectClass}
183
+ aria-describedby={error ? `${id}-error` : helpText ? `${id}-help` : undefined}
184
+ aria-invalid={error ? 'true' : 'false'}
185
+ onchange={handleNativeChange}
186
+ >
187
+ {#if placeholder}
188
+ <option value="" disabled>{placeholder}</option>
189
+ {/if}
190
+ {#each Object.entries(groupedOptions) as [groupName, groupOpts]}
191
+ {#if groupName !== 'default'}
192
+ <optgroup label={groupName}>
193
+ {#each groupOpts as option}
194
+ <option value={option.value} disabled={option.disabled}>{option.label}</option>
195
+ {/each}
196
+ </optgroup>
197
+ {:else}
198
+ {#each groupOpts as option}
199
+ <option value={option.value} disabled={option.disabled}>{option.label}</option>
200
+ {/each}
201
+ {/if}
202
+ {/each}
203
+ </select>
204
+ {/if}
205
+
206
+ {#if error}
207
+ <div class="label">
208
+ <span id="{id}-error" class="label-text-alt text-error" role="alert">{error}</span>
209
+ </div>
210
+ {:else if helpText}
211
+ <div class="label">
212
+ <span id="{id}-help" class="label-text-alt text-base-content/70">{helpText}</span>
213
+ </div>
214
+ {/if}
215
+ </div>
@@ -0,0 +1,171 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SignaturePad — Canvas-based digital signature capture with mouse and touch support.
4
+ *
5
+ * Exports `clear()` and `isEmpty()` methods. Outputs signature as base64 PNG data URL
6
+ * via the bindable `signatureData` prop or `onSignatureChange` callback.
7
+ */
8
+ import { onMount } from 'svelte';
9
+
10
+ let {
11
+ signatureData = $bindable(''),
12
+ onSignatureChange,
13
+ width = 400,
14
+ height = 150,
15
+ strokeColor = '#000',
16
+ strokeWidth = 2,
17
+ label = 'Signature',
18
+ required = false,
19
+ placeholder = 'Sign here',
20
+ }: {
21
+ signatureData?: string;
22
+ onSignatureChange?: (data: string) => void;
23
+ width?: number;
24
+ height?: number;
25
+ strokeColor?: string;
26
+ strokeWidth?: number;
27
+ label?: string;
28
+ required?: boolean;
29
+ placeholder?: string;
30
+ } = $props();
31
+
32
+ let canvas: HTMLCanvasElement;
33
+ let isDrawing = false;
34
+ let lastX = 0;
35
+ let lastY = 0;
36
+ let hasDrawn = $state(false);
37
+
38
+ onMount(() => {
39
+ if (!canvas) return;
40
+
41
+ const ctx = canvas.getContext('2d');
42
+ if (!ctx) return;
43
+
44
+ ctx.strokeStyle = strokeColor;
45
+ ctx.lineWidth = strokeWidth;
46
+ ctx.lineCap = 'round';
47
+ ctx.lineJoin = 'round';
48
+
49
+ function getCoordinates(e: MouseEvent | TouchEvent) {
50
+ const rect = canvas.getBoundingClientRect();
51
+ const scaleX = canvas.width / rect.width;
52
+ const scaleY = canvas.height / rect.height;
53
+
54
+ if (e instanceof MouseEvent) {
55
+ return {
56
+ x: (e.clientX - rect.left) * scaleX,
57
+ y: (e.clientY - rect.top) * scaleY
58
+ };
59
+ } else {
60
+ return {
61
+ x: (e.touches[0].clientX - rect.left) * scaleX,
62
+ y: (e.touches[0].clientY - rect.top) * scaleY
63
+ };
64
+ }
65
+ }
66
+
67
+ function startDrawing(e: MouseEvent | TouchEvent) {
68
+ e.preventDefault();
69
+ isDrawing = true;
70
+ const coords = getCoordinates(e);
71
+ lastX = coords.x;
72
+ lastY = coords.y;
73
+ }
74
+
75
+ function draw(e: MouseEvent | TouchEvent) {
76
+ if (!isDrawing) return;
77
+ e.preventDefault();
78
+
79
+ const coords = getCoordinates(e);
80
+ ctx.beginPath();
81
+ ctx.moveTo(lastX, lastY);
82
+ ctx.lineTo(coords.x, coords.y);
83
+ ctx.stroke();
84
+ lastX = coords.x;
85
+ lastY = coords.y;
86
+ hasDrawn = true;
87
+ }
88
+
89
+ function stopDrawing() {
90
+ if (isDrawing && hasDrawn) {
91
+ isDrawing = false;
92
+ signatureData = canvas.toDataURL();
93
+ onSignatureChange?.(signatureData);
94
+ }
95
+ isDrawing = false;
96
+ }
97
+
98
+ canvas.addEventListener('mousedown', startDrawing);
99
+ canvas.addEventListener('mousemove', draw);
100
+ canvas.addEventListener('mouseup', stopDrawing);
101
+ canvas.addEventListener('mouseleave', stopDrawing);
102
+
103
+ canvas.addEventListener('touchstart', startDrawing, { passive: false });
104
+ canvas.addEventListener('touchmove', draw, { passive: false });
105
+ canvas.addEventListener('touchend', stopDrawing);
106
+ canvas.addEventListener('touchcancel', stopDrawing);
107
+
108
+ return () => {
109
+ canvas.removeEventListener('mousedown', startDrawing);
110
+ canvas.removeEventListener('mousemove', draw);
111
+ canvas.removeEventListener('mouseup', stopDrawing);
112
+ canvas.removeEventListener('mouseleave', stopDrawing);
113
+ canvas.removeEventListener('touchstart', startDrawing);
114
+ canvas.removeEventListener('touchmove', draw);
115
+ canvas.removeEventListener('touchend', stopDrawing);
116
+ canvas.removeEventListener('touchcancel', stopDrawing);
117
+ };
118
+ });
119
+
120
+ export function clear() {
121
+ if (!canvas) return;
122
+ const ctx = canvas.getContext('2d');
123
+ if (!ctx) return;
124
+
125
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
126
+ signatureData = '';
127
+ hasDrawn = false;
128
+ onSignatureChange?.('');
129
+ }
130
+
131
+ export function isEmpty(): boolean {
132
+ return !hasDrawn || !signatureData;
133
+ }
134
+ </script>
135
+
136
+ <div class="signature-pad-container">
137
+ {#if label}
138
+ <div class="flex justify-between items-center mb-2">
139
+ <label class="text-sm text-base-content/70">
140
+ {label}
141
+ {#if required}
142
+ <span class="text-error">*</span>
143
+ {/if}
144
+ </label>
145
+ <button type="button" onclick={() => clear()} class="btn btn-ghost btn-xs">
146
+ Clear
147
+ </button>
148
+ </div>
149
+ {/if}
150
+
151
+ <div class="border-2 border-base-300 rounded-lg bg-white overflow-hidden relative">
152
+ <canvas
153
+ bind:this={canvas}
154
+ {width}
155
+ {height}
156
+ class="w-full touch-none cursor-crosshair"
157
+ ></canvas>
158
+
159
+ {#if !hasDrawn}
160
+ <div class="absolute inset-0 flex items-center justify-center pointer-events-none">
161
+ <span class="text-base-content/30 text-sm">{placeholder}</span>
162
+ </div>
163
+ {/if}
164
+ </div>
165
+ </div>
166
+
167
+ <style>
168
+ canvas {
169
+ display: block;
170
+ }
171
+ </style>
@@ -0,0 +1,148 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SortDropdown Component - Reusable sort dropdown with optional filter input
4
+ *
5
+ * A standardized dropdown for sorting lists with:
6
+ * - Sort button showing current option icon, label, and direction indicator
7
+ * - Dropdown menu with configurable sort options
8
+ * - Optional filter input for search/filtering
9
+ */
10
+
11
+ type SortDirection = 'asc' | 'desc';
12
+
13
+ export interface SortOption {
14
+ value: string;
15
+ label: string;
16
+ icon: string;
17
+ defaultDir?: SortDirection;
18
+ }
19
+
20
+ interface Props {
21
+ /** Sort options array (required) */
22
+ options: SortOption[];
23
+ /** Current sort value */
24
+ sortBy: string;
25
+ /** Current sort direction */
26
+ sortDir: SortDirection;
27
+ /** Current filter input value */
28
+ filterValue?: string;
29
+ /** Whether to show the filter input */
30
+ showFilter?: boolean;
31
+ /** Placeholder text for filter input */
32
+ filterPlaceholder?: string;
33
+ /** Button size variant */
34
+ size?: 'xs' | 'sm' | 'md';
35
+ /** Show label on small screens */
36
+ showLabelOnMobile?: boolean;
37
+ /** Callback when sort changes */
38
+ onSortChange?: (value: string, dir: SortDirection) => void;
39
+ /** Callback when filter changes */
40
+ onFilterChange?: (value: string) => void;
41
+ }
42
+
43
+ let {
44
+ options,
45
+ sortBy,
46
+ sortDir,
47
+ filterValue = '',
48
+ showFilter = false,
49
+ filterPlaceholder = 'Filter...',
50
+ size = 'xs',
51
+ showLabelOnMobile = false,
52
+ onSortChange,
53
+ onFilterChange
54
+ }: Props = $props();
55
+
56
+ // Find current sort option
57
+ const currentOption = $derived(options.find(o => o.value === sortBy));
58
+ const currentIcon = $derived(currentOption?.icon || '');
59
+ const currentLabel = $derived(currentOption?.label || 'Sort');
60
+
61
+ // Handle sort option click
62
+ function handleSortClick(value: string) {
63
+ if (sortBy === value) {
64
+ onSortChange?.(value, sortDir === 'asc' ? 'desc' : 'asc');
65
+ } else {
66
+ const opt = options.find(o => o.value === value);
67
+ onSortChange?.(value, opt?.defaultDir ?? 'asc');
68
+ }
69
+ }
70
+
71
+ // Handle filter input change
72
+ function handleFilterInput(e: Event) {
73
+ const target = e.currentTarget as HTMLInputElement;
74
+ onFilterChange?.(target.value);
75
+ }
76
+
77
+ // Size classes
78
+ const buttonSizeClass = $derived({
79
+ xs: 'btn-xs',
80
+ sm: 'btn-sm',
81
+ md: ''
82
+ }[size]);
83
+
84
+ const menuSizeClass = $derived({
85
+ xs: 'menu-xs',
86
+ sm: 'menu-sm',
87
+ md: ''
88
+ }[size]);
89
+
90
+ const inputSizeClass = $derived({
91
+ xs: 'input-xs',
92
+ sm: 'input-sm',
93
+ md: ''
94
+ }[size]);
95
+
96
+ const inputWidthClass = $derived({
97
+ xs: 'w-20 focus:w-32',
98
+ sm: 'w-24 focus:w-36',
99
+ md: 'w-32 focus:w-44'
100
+ }[size]);
101
+ </script>
102
+
103
+ <div class="flex items-center gap-1">
104
+ <!-- Sort dropdown -->
105
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
106
+ <div class="dropdown dropdown-end flex-shrink-0" onclick={(e) => e.stopPropagation()}>
107
+ <button
108
+ tabindex="0"
109
+ class="btn {buttonSizeClass} btn-ghost gap-1 font-mono text-[10px] uppercase tracking-wider opacity-70 hover:opacity-100"
110
+ title="Sort"
111
+ >
112
+ <span>{currentIcon}</span>
113
+ <span class="{showLabelOnMobile ? '' : 'hidden sm:inline'}">{currentLabel}</span>
114
+ <span class="text-[9px]">{sortDir === 'asc' ? '▲' : '▼'}</span>
115
+ </button>
116
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
117
+ <ul tabindex="0" class="dropdown-content menu {menuSizeClass} bg-base-200 rounded-box z-40 w-36 p-1 shadow-lg border border-base-300">
118
+ {#each options as opt (opt.value)}
119
+ <li>
120
+ <button
121
+ class="flex items-center gap-2 {sortBy === opt.value ? 'active' : ''}"
122
+ onclick={() => handleSortClick(opt.value)}
123
+ >
124
+ <span>{opt.icon}</span>
125
+ <span class="flex-1">{opt.label}</span>
126
+ {#if sortBy === opt.value}
127
+ <span class="text-[9px] opacity-70">{sortDir === 'asc' ? '▲' : '▼'}</span>
128
+ {/if}
129
+ </button>
130
+ </li>
131
+ {/each}
132
+ </ul>
133
+ </div>
134
+
135
+ <!-- Filter input (optional) -->
136
+ {#if showFilter}
137
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
138
+ <div class="flex-shrink-0" onclick={(e) => e.stopPropagation()}>
139
+ <input
140
+ type="text"
141
+ placeholder={filterPlaceholder}
142
+ value={filterValue}
143
+ oninput={handleFilterInput}
144
+ class="input {inputSizeClass} input-bordered {inputWidthClass} transition-all duration-200 bg-base-200/50"
145
+ />
146
+ </div>
147
+ {/if}
148
+ </div>
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Sparkline — Lightweight D3 bar chart for trend visualization.
4
+ * Animated entrance with staggered bars, configurable color and opacity gradient.
5
+ *
6
+ * Requires `d3` as a peer dependency.
7
+ */
8
+ import * as d3 from 'd3';
9
+
10
+ let {
11
+ values,
12
+ width = 120,
13
+ height = 32,
14
+ color = 'oklch(var(--p))',
15
+ fillOpacity = 0.15,
16
+ }: {
17
+ values: number[];
18
+ width?: number | 'auto';
19
+ height?: number;
20
+ color?: string;
21
+ fillOpacity?: number;
22
+ } = $props();
23
+
24
+ let el: HTMLDivElement | undefined = $state();
25
+ let animated = false;
26
+
27
+ function getEffectiveWidth(): number {
28
+ if (width === 'auto' && el) return el.clientWidth;
29
+ if (typeof width === 'number') return width;
30
+ return 120;
31
+ }
32
+
33
+ $effect(() => {
34
+ if (!el || values.length < 2) return;
35
+
36
+ const w = getEffectiveWidth();
37
+ if (w <= 0) return;
38
+
39
+ d3.select(el).select('svg').remove();
40
+
41
+ const pad = 1;
42
+ const innerW = w - pad * 2;
43
+ const innerH = height - pad * 2;
44
+
45
+ if (innerW <= 0 || innerH <= 0) return;
46
+
47
+ const svg = d3
48
+ .select(el)
49
+ .append('svg')
50
+ .attr('width', w)
51
+ .attr('height', height);
52
+
53
+ const g = svg.append('g').attr('transform', `translate(${pad},${pad})`);
54
+
55
+ const x = d3
56
+ .scaleBand<number>()
57
+ .domain(values.map((_, i) => i))
58
+ .range([0, innerW])
59
+ .padding(0.15);
60
+
61
+ const yMin = d3.min(values)! * 0.95;
62
+ const yMax = d3.max(values)! * 1.05;
63
+ const y = d3.scaleLinear().domain([yMin, yMax]).range([innerH, 0]);
64
+
65
+ const n = Math.max(1, values.length - 1);
66
+ const shouldAnimate = !animated;
67
+ animated = true;
68
+
69
+ const bars = g
70
+ .selectAll('.bar')
71
+ .data(values)
72
+ .join('rect')
73
+ .attr('x', (_, i) => x(i)!)
74
+ .attr('width', x.bandwidth())
75
+ .attr('rx', Math.min(1, x.bandwidth() / 4))
76
+ .style('fill', color)
77
+ .style('fill-opacity', (_, i) => fillOpacity + (1 - fillOpacity) * (i / n));
78
+
79
+ if (shouldAnimate) {
80
+ bars
81
+ .attr('y', innerH)
82
+ .attr('height', 0)
83
+ .transition()
84
+ .duration(400)
85
+ .delay((_, i) => i * 15)
86
+ .ease(d3.easeCubicOut)
87
+ .attr('y', (d) => y(d))
88
+ .attr('height', (d) => innerH - y(d));
89
+ } else {
90
+ bars.attr('y', (d) => y(d)).attr('height', (d) => innerH - y(d));
91
+ }
92
+ });
93
+ </script>
94
+
95
+ {#if values.length >= 2}
96
+ <div
97
+ bind:this={el}
98
+ style="{width === 'auto' ? 'width: 100%' : `width: ${width}px`}; height: {height}px"
99
+ ></div>
100
+ {:else}
101
+ <div
102
+ class="flex items-center justify-center text-base-content/30 text-xs"
103
+ style="{width === 'auto' ? 'width: 100%' : `width: ${width}px`}; height: {height}px"
104
+ >
105
+ --
106
+ </div>
107
+ {/if}