@joewinke/jatui 0.1.7 → 0.1.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -396,6 +396,7 @@
396
396
  bind:this={editableRef}
397
397
  contenteditable={disabled ? 'false' : 'true'}
398
398
  role="textbox"
399
+ tabindex={disabled ? -1 : 0}
399
400
  aria-multiline="true"
400
401
  class="chip-input-editable w-full rounded-md text-sm"
401
402
  class:chip-input-monospace={monospace}
@@ -34,7 +34,7 @@
34
34
  <div class="modal-box max-w-md">
35
35
  <div class="flex justify-between items-center mb-4">
36
36
  <h3 class="font-bold text-lg">{title}</h3>
37
- <button class="btn btn-ghost btn-sm" onclick={cancel}>
37
+ <button class="btn btn-ghost btn-sm" onclick={cancel} aria-label="Close">
38
38
  <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
39
39
  </button>
40
40
  </div>
@@ -155,6 +155,7 @@
155
155
  <!-- Dropdown Content -->
156
156
  {#if style === 'checkbox'}
157
157
  <!-- Checkbox style: Menu with checkboxes -->
158
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
158
159
  <ul
159
160
  tabindex="0"
160
161
  class="dropdown-content z-40 menu p-2 shadow rounded-box {menuWidth} {maxHeight ? maxHeight + ' overflow-y-auto' : ''} bg-base-200 border border-base-300"
@@ -0,0 +1,354 @@
1
+ <script lang="ts">
2
+ /**
3
+ * FloatingActionBar Component
4
+ * Fixed bottom-center bar for bulk selection actions on tables/lists.
5
+ *
6
+ * Usage:
7
+ * <FloatingActionBar
8
+ * count={selectedTasks.size}
9
+ * {actions}
10
+ * {dropdowns}
11
+ * loading={bulkActionLoading}
12
+ * onClear={clearSelection}
13
+ * />
14
+ */
15
+
16
+ import { fade } from 'svelte/transition';
17
+
18
+ export interface ActionButton {
19
+ /** Unique key */
20
+ id: string;
21
+ /** Button label */
22
+ label: string;
23
+ /** SVG path string for a 24×24 icon (optional) */
24
+ icon?: string;
25
+ /** Color variant */
26
+ variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
27
+ /** Called when clicked */
28
+ onclick: () => void;
29
+ }
30
+
31
+ export interface DropdownItem {
32
+ /** Display label */
33
+ label: string;
34
+ /** Optional colored dot (oklch string) */
35
+ dotColor?: string;
36
+ /** Optional SVG snippet rendered before the label */
37
+ iconSnippet?: import('svelte').Snippet;
38
+ /** Called when this item is clicked */
39
+ onclick: () => void;
40
+ }
41
+
42
+ export interface ActionDropdown {
43
+ /** Unique key */
44
+ id: string;
45
+ /** Button label */
46
+ label: string;
47
+ /** SVG path string for a 24×24 icon (optional) */
48
+ icon?: string;
49
+ /** Color variant */
50
+ variant?: 'default' | 'primary' | 'info' | 'purple';
51
+ /** Items in the dropdown */
52
+ items: DropdownItem[];
53
+ }
54
+
55
+ interface Props {
56
+ /** Number of selected items — bar is hidden when 0 */
57
+ count: number;
58
+ /** Primary action buttons */
59
+ actions?: ActionButton[];
60
+ /** Dropdown menus (open upward from the bar) */
61
+ dropdowns?: ActionDropdown[];
62
+ /** Disables all buttons while true and shows a spinner */
63
+ loading?: boolean;
64
+ /** Called when the "Clear" button is clicked */
65
+ onClear?: () => void;
66
+ /** Label for the selection count (default: "selected") */
67
+ countLabel?: string;
68
+ }
69
+
70
+ let {
71
+ count,
72
+ actions = [],
73
+ dropdowns = [],
74
+ loading = false,
75
+ onClear,
76
+ countLabel = 'selected',
77
+ }: Props = $props();
78
+
79
+ let openDropdownId = $state<string | null>(null);
80
+
81
+ // Close dropdown on outside click
82
+ $effect(() => {
83
+ if (!openDropdownId) return;
84
+ function handleClick(e: MouseEvent) {
85
+ if (!(e.target as HTMLElement).closest('.fab-dropdown-wrapper')) {
86
+ openDropdownId = null;
87
+ }
88
+ }
89
+ const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
90
+ return () => {
91
+ clearTimeout(timer);
92
+ document.removeEventListener('click', handleClick);
93
+ };
94
+ });
95
+
96
+ function variantClass(variant: ActionButton['variant'] | ActionDropdown['variant']): string {
97
+ switch (variant) {
98
+ case 'primary': return 'fab-btn-primary';
99
+ case 'danger': return 'fab-btn-danger';
100
+ case 'warning': return 'fab-btn-warning';
101
+ case 'success': return 'fab-btn-success';
102
+ case 'info': return 'fab-btn-info';
103
+ case 'purple': return 'fab-btn-purple';
104
+ default: return 'fab-btn-default';
105
+ }
106
+ }
107
+ </script>
108
+
109
+ {#if count > 0}
110
+ <div class="fab" transition:fade={{ duration: 150 }}>
111
+ <span class="fab-count">{count} {countLabel}</span>
112
+
113
+ {#if actions.length > 0}
114
+ <div class="fab-divider"></div>
115
+ {#each actions as action}
116
+ <button
117
+ type="button"
118
+ class="fab-btn {variantClass(action.variant)}"
119
+ onclick={action.onclick}
120
+ disabled={loading}
121
+ >
122
+ {#if action.icon}
123
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
124
+ <!-- svelte-ignore a11y_consider_explicit_label -->
125
+ {@html action.icon}
126
+ </svg>
127
+ {/if}
128
+ {action.label}
129
+ </button>
130
+ {/each}
131
+ {/if}
132
+
133
+ {#if dropdowns.length > 0}
134
+ <div class="fab-divider"></div>
135
+ {#each dropdowns as dropdown}
136
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
137
+ <div
138
+ class="fab-dropdown-wrapper"
139
+ role="group"
140
+ onclick={(e) => e.stopPropagation()}
141
+ onkeydown={(e) => e.stopPropagation()}
142
+ >
143
+ <button
144
+ type="button"
145
+ class="fab-btn {variantClass(dropdown.variant)}"
146
+ onclick={() => openDropdownId = openDropdownId === dropdown.id ? null : dropdown.id}
147
+ disabled={loading}
148
+ >
149
+ {#if dropdown.icon}
150
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
151
+ <!-- svelte-ignore a11y_consider_explicit_label -->
152
+ {@html dropdown.icon}
153
+ </svg>
154
+ {/if}
155
+ {dropdown.label}
156
+ </button>
157
+ {#if openDropdownId === dropdown.id}
158
+ <div class="fab-dropdown" transition:fade={{ duration: 100 }}>
159
+ {#each dropdown.items as item}
160
+ <button
161
+ class="fab-dropdown-item"
162
+ onclick={() => { item.onclick(); openDropdownId = null; }}
163
+ >
164
+ {#if item.dotColor}
165
+ <span class="fab-dot" style="background: {item.dotColor};"></span>
166
+ {/if}
167
+ {#if item.iconSnippet}
168
+ {@render item.iconSnippet()}
169
+ {/if}
170
+ {item.label}
171
+ </button>
172
+ {/each}
173
+ </div>
174
+ {/if}
175
+ </div>
176
+ {/each}
177
+ {/if}
178
+
179
+ {#if onClear}
180
+ <div class="fab-divider"></div>
181
+ <button type="button" class="fab-btn fab-btn-clear" onclick={onClear} disabled={loading}>
182
+ Clear
183
+ </button>
184
+ {/if}
185
+
186
+ {#if loading}
187
+ <div class="fab-spinner"></div>
188
+ {/if}
189
+ </div>
190
+ {/if}
191
+
192
+ <style>
193
+ .fab {
194
+ position: fixed;
195
+ bottom: 1.5rem;
196
+ left: 50%;
197
+ transform: translateX(-50%);
198
+ z-index: 50;
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 0.5rem;
202
+ padding: 0.5rem 0.75rem;
203
+ background: oklch(0.16 0.02 250);
204
+ border: 1px solid oklch(0.30 0.02 250);
205
+ border-radius: 0.75rem;
206
+ box-shadow: 0 8px 32px oklch(0.05 0 0 / 0.6), 0 2px 8px oklch(0.05 0 0 / 0.3);
207
+ }
208
+
209
+ .fab-count {
210
+ font-size: 0.8125rem;
211
+ font-weight: 600;
212
+ color: oklch(0.85 0.02 250);
213
+ white-space: nowrap;
214
+ }
215
+
216
+ .fab-divider {
217
+ width: 1px;
218
+ height: 1.25rem;
219
+ background: oklch(0.30 0.02 250);
220
+ }
221
+
222
+ .fab-btn {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 0.375rem;
226
+ padding: 0.375rem 0.625rem;
227
+ border: 1px solid transparent;
228
+ border-radius: 0.375rem;
229
+ font-size: 0.75rem;
230
+ font-weight: 500;
231
+ cursor: pointer;
232
+ transition: all 0.15s;
233
+ white-space: nowrap;
234
+ }
235
+ .fab-btn:disabled {
236
+ opacity: 0.5;
237
+ pointer-events: none;
238
+ }
239
+
240
+ /* Variants */
241
+ .fab-btn-default {
242
+ background: oklch(0.25 0.02 250 / 0.5);
243
+ color: oklch(0.75 0.02 250);
244
+ border-color: oklch(0.35 0.02 250);
245
+ }
246
+ .fab-btn-default:hover { background: oklch(0.30 0.02 250); }
247
+
248
+ .fab-btn-primary {
249
+ background: oklch(0.65 0.18 250 / 0.15);
250
+ color: oklch(0.82 0.14 250);
251
+ border-color: oklch(0.65 0.18 250 / 0.3);
252
+ }
253
+ .fab-btn-primary:hover { background: oklch(0.65 0.18 250 / 0.25); }
254
+
255
+ .fab-btn-danger {
256
+ background: oklch(0.55 0.18 30 / 0.15);
257
+ color: oklch(0.80 0.15 30);
258
+ border-color: oklch(0.55 0.18 30 / 0.3);
259
+ }
260
+ .fab-btn-danger:hover { background: oklch(0.55 0.18 30 / 0.25); }
261
+
262
+ .fab-btn-warning {
263
+ background: oklch(0.75 0.15 85 / 0.12);
264
+ color: oklch(0.80 0.12 85);
265
+ border-color: oklch(0.75 0.15 85 / 0.25);
266
+ }
267
+ .fab-btn-warning:hover { background: oklch(0.75 0.15 85 / 0.22); }
268
+
269
+ .fab-btn-success {
270
+ background: oklch(0.55 0.15 145 / 0.15);
271
+ color: oklch(0.80 0.12 145);
272
+ border-color: oklch(0.55 0.15 145 / 0.3);
273
+ }
274
+ .fab-btn-success:hover { background: oklch(0.55 0.15 145 / 0.25); }
275
+
276
+ .fab-btn-info {
277
+ background: oklch(0.55 0.15 200 / 0.15);
278
+ color: oklch(0.80 0.12 200);
279
+ border-color: oklch(0.55 0.15 200 / 0.3);
280
+ }
281
+ .fab-btn-info:hover { background: oklch(0.55 0.15 200 / 0.25); }
282
+
283
+ .fab-btn-purple {
284
+ background: oklch(0.55 0.15 300 / 0.15);
285
+ color: oklch(0.80 0.12 300);
286
+ border-color: oklch(0.55 0.15 300 / 0.3);
287
+ }
288
+ .fab-btn-purple:hover { background: oklch(0.55 0.15 300 / 0.25); }
289
+
290
+ .fab-btn-clear {
291
+ background: transparent;
292
+ color: oklch(0.65 0.02 250);
293
+ border-color: transparent;
294
+ }
295
+ .fab-btn-clear:hover {
296
+ background: oklch(0.25 0.02 250);
297
+ color: oklch(0.80 0.02 250);
298
+ }
299
+
300
+ .fab-spinner {
301
+ width: 14px;
302
+ height: 14px;
303
+ border: 2px solid oklch(0.35 0.02 250);
304
+ border-top-color: oklch(0.70 0.15 240);
305
+ border-radius: 50%;
306
+ animation: fab-spin 0.6s linear infinite;
307
+ flex-shrink: 0;
308
+ }
309
+ @keyframes fab-spin {
310
+ to { transform: rotate(360deg); }
311
+ }
312
+
313
+ /* Dropdown */
314
+ .fab-dropdown-wrapper {
315
+ position: relative;
316
+ }
317
+ .fab-dropdown {
318
+ position: absolute;
319
+ bottom: calc(100% + 8px);
320
+ left: 50%;
321
+ transform: translateX(-50%);
322
+ min-width: 160px;
323
+ background: oklch(0.18 0.02 250);
324
+ border: 1px solid oklch(0.30 0.02 250);
325
+ border-radius: 0.5rem;
326
+ padding: 0.375rem;
327
+ box-shadow: 0 8px 24px oklch(0.05 0 0 / 0.5);
328
+ z-index: 60;
329
+ }
330
+ .fab-dropdown-item {
331
+ display: flex;
332
+ align-items: center;
333
+ gap: 0.5rem;
334
+ width: 100%;
335
+ padding: 0.375rem 0.625rem;
336
+ border: none;
337
+ background: transparent;
338
+ color: oklch(0.80 0.02 250);
339
+ font-size: 0.75rem;
340
+ text-align: left;
341
+ border-radius: 0.375rem;
342
+ cursor: pointer;
343
+ transition: background 0.1s;
344
+ white-space: nowrap;
345
+ }
346
+ .fab-dropdown-item:hover { background: oklch(0.25 0.02 250); }
347
+
348
+ .fab-dot {
349
+ width: 8px;
350
+ height: 8px;
351
+ border-radius: 50%;
352
+ flex-shrink: 0;
353
+ }
354
+ </style>
@@ -65,6 +65,7 @@
65
65
  class="btn btn-sm btn-circle absolute top-2 right-2 bg-base-100/80"
66
66
  onclick={clearImage}
67
67
  {disabled}
68
+ aria-label="Remove image"
68
69
  >
69
70
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
70
71
  </button>
@@ -1,14 +1,21 @@
1
1
  <script lang="ts">
2
2
  import type { Milestone, MilestoneStatus } from '../types/milestone'
3
3
 
4
+ interface LinkedTask {
5
+ id: string
6
+ title: string
7
+ status: string
8
+ }
9
+
4
10
  interface Props {
5
11
  milestone: Milestone
12
+ tasks?: LinkedTask[]
6
13
  isCurrent?: boolean
7
14
  currency?: string
8
15
  onAcceptAndPay?: (milestone: Milestone) => void
9
16
  }
10
17
 
11
- let { milestone, isCurrent = false, currency = 'USD', onAcceptAndPay }: Props = $props()
18
+ let { milestone, tasks = [], isCurrent = false, currency = 'USD', onAcceptAndPay }: Props = $props()
12
19
 
13
20
  const statusConfig: Record<MilestoneStatus, { label: string; class: string; icon: string }> = {
14
21
  pending: { label: 'Pending', class: 'badge-ghost', icon: '○' },
@@ -59,6 +66,17 @@
59
66
  </div>
60
67
  {/if}
61
68
 
69
+ {#if tasks.length > 0}
70
+ <div class="flex flex-wrap gap-1 mt-2">
71
+ {#each tasks as task}
72
+ <span class="badge badge-xs badge-outline gap-1">
73
+ <span class="w-1.5 h-1.5 rounded-full {task.status === 'deployed' || task.status === 'accepted' || task.status === 'closed' ? 'bg-success' : task.status === 'in_progress' || task.status === 'submitted' ? 'bg-warning' : 'bg-base-300'}"></span>
74
+ {task.title}
75
+ </span>
76
+ {/each}
77
+ </div>
78
+ {/if}
79
+
62
80
  <div class="flex items-center gap-3 text-xs text-base-content/40 mt-1">
63
81
  <span>{milestone.percentage}% of total</span>
64
82
  {#if milestone.delivered_at}
@@ -2,13 +2,20 @@
2
2
  import type { Milestone } from '../types/milestone'
3
3
  import MilestoneCard from './MilestoneCard.svelte'
4
4
 
5
+ interface LinkedTask {
6
+ id: string
7
+ title: string
8
+ status: string
9
+ }
10
+
5
11
  interface Props {
6
12
  milestones: Milestone[]
13
+ linkedTasks?: Record<string, LinkedTask[]>
7
14
  currency?: string
8
15
  onAcceptAndPay?: (milestone: Milestone) => void
9
16
  }
10
17
 
11
- let { milestones, currency = 'USD', onAcceptAndPay }: Props = $props()
18
+ let { milestones, linkedTasks = {}, currency = 'USD', onAcceptAndPay }: Props = $props()
12
19
 
13
20
  const sorted = $derived([...milestones].sort((a, b) => a.sort_order - b.sort_order))
14
21
 
@@ -72,6 +79,7 @@
72
79
  <div class="flex-1 pb-4">
73
80
  <MilestoneCard
74
81
  {milestone}
82
+ tasks={linkedTasks[milestone.id] || []}
75
83
  isCurrent={i === currentIndex}
76
84
  {currency}
77
85
  {onAcceptAndPay}
@@ -121,6 +121,8 @@
121
121
  const isExpanded = $derived(isDragging || isHovering || isNearby);
122
122
  </script>
123
123
 
124
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
125
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
124
126
  <div
125
127
  bind:this={dividerElement}
126
128
  class="divider-container flex items-center justify-center select-none {className} {isCollapsed ? 'cursor-pointer divider-collapsed' : 'cursor-row-resize'} {isDragging ? 'bg-primary/25' : isExpanded && isCollapsed ? 'bg-primary/40' : isExpanded ? 'bg-primary/15' : ''}"
@@ -128,7 +130,7 @@
128
130
  class:dragging={isDragging}
129
131
  role="separator"
130
132
  aria-orientation="horizontal"
131
- aria-expanded={!isCollapsed}
133
+ aria-label={isCollapsed ? 'Expand panel' : 'Resize panel'}
132
134
  tabindex="0"
133
135
  onmousedown={handleMouseDown}
134
136
  onmouseenter={handleMouseEnter}
@@ -3,6 +3,7 @@
3
3
  value: string;
4
4
  label: string;
5
5
  icon?: string;
6
+ image?: string; // URL for avatar/thumbnail image
6
7
  };
7
8
 
8
9
  export type SearchDropdownGroup = {
@@ -53,6 +54,7 @@
53
54
 
54
55
  const triggerLabel = $derived(displayValue || selectedOption?.label || value || placeholder);
55
56
  const triggerIcon = $derived(selectedOption?.icon || '');
57
+ const triggerImage = $derived(selectedOption?.image || '');
56
58
  const activeColor = $derived(colorFn ? colorFn(value) : undefined);
57
59
 
58
60
  const filteredGroups = $derived.by(() => {
@@ -68,6 +70,13 @@
68
70
  return result;
69
71
  });
70
72
 
73
+ // Generate a deterministic hue from a string for initials avatars
74
+ function nameHue(s: string): number {
75
+ let h = 0;
76
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) & 0xffff;
77
+ return h % 360;
78
+ }
79
+
71
80
  function select(optionValue: string) {
72
81
  open = false;
73
82
  searchQuery = '';
@@ -100,7 +109,15 @@
100
109
  {disabled}
101
110
  >
102
111
  <span class="sd-trigger-label">
103
- {#if triggerIcon}<span class="sd-trigger-icon">{triggerIcon}</span>{/if}
112
+ {#if triggerImage}
113
+ <img src={triggerImage} alt="" class="sd-avatar" />
114
+ {:else if triggerIcon}
115
+ <span class="sd-trigger-icon">{triggerIcon}</span>
116
+ {:else if selectedOption && selectedOption.value}
117
+ <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(selectedOption.label)})">
118
+ {selectedOption.label.charAt(0).toUpperCase()}
119
+ </span>
120
+ {/if}
104
121
  <span class="truncate">{triggerLabel}</span>
105
122
  </span>
106
123
  <svg class="sd-chevron" class:sd-chevron-open={open} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -131,7 +148,7 @@
131
148
  autocomplete="off"
132
149
  />
133
150
  {#if searchQuery}
134
- <button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear">
151
+ <button type="button" onclick={() => { searchQuery = ''; searchInput?.focus(); }} class="sd-search-clear" aria-label="Clear search">
135
152
  <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
136
153
  </button>
137
154
  {/if}
@@ -154,7 +171,15 @@
154
171
  class:sd-option-selected={value === option.value}
155
172
  style={colorFn && colorFn(option.value) ? `border-left-color: ${colorFn(option.value)}; color: ${colorFn(option.value)};` : ''}
156
173
  >
157
- {#if option.icon}<span class="sd-option-icon">{option.icon}</span>{/if}
174
+ {#if option.image}
175
+ <img src={option.image} alt="" class="sd-avatar" />
176
+ {:else if option.icon}
177
+ <span class="sd-option-icon">{option.icon}</span>
178
+ {:else if option.value}
179
+ <span class="sd-avatar sd-avatar-initials" style="background:oklch(0.55 0.15 {nameHue(option.label)})">
180
+ {option.label.charAt(0).toUpperCase()}
181
+ </span>
182
+ {/if}
158
183
  <span class="truncate">{option.label}</span>
159
184
  {#if value === option.value}
160
185
  <svg class="sd-check" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
@@ -227,6 +252,22 @@
227
252
  flex-shrink: 0;
228
253
  font-size: 0.75rem;
229
254
  }
255
+ .sd-avatar {
256
+ width: 1.25rem;
257
+ height: 1.25rem;
258
+ border-radius: 50%;
259
+ object-fit: cover;
260
+ flex-shrink: 0;
261
+ }
262
+ .sd-avatar-initials {
263
+ display: inline-flex;
264
+ align-items: center;
265
+ justify-content: center;
266
+ font-size: 0.6rem;
267
+ font-weight: 700;
268
+ color: #fff;
269
+ line-height: 1;
270
+ }
230
271
 
231
272
  .sd-chevron {
232
273
  width: 0.75rem;
@@ -136,12 +136,12 @@
136
136
  <div class="signature-pad-container">
137
137
  {#if label}
138
138
  <div class="flex justify-between items-center mb-2">
139
- <label class="text-sm text-base-content/70">
139
+ <span class="text-sm text-base-content/70">
140
140
  {label}
141
141
  {#if required}
142
142
  <span class="text-error">*</span>
143
143
  {/if}
144
- </label>
144
+ </span>
145
145
  <button type="button" onclick={() => clear()} class="btn btn-ghost btn-xs">
146
146
  Clear
147
147
  </button>
@@ -96,12 +96,12 @@
96
96
  {#if isEditing}
97
97
  <div class="space-y-4">
98
98
  <div>
99
- <label class="label"><span class="label-text font-medium">Name</span></label>
100
- <input type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
99
+ <label for="channel-info-name" class="label"><span class="label-text font-medium">Name</span></label>
100
+ <input id="channel-info-name" type="text" bind:value={editName} class="input input-bordered w-full" maxlength="80" />
101
101
  </div>
102
102
  <div>
103
- <label class="label"><span class="label-text font-medium">Description</span></label>
104
- <textarea bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
103
+ <label for="channel-info-description" class="label"><span class="label-text font-medium">Description</span></label>
104
+ <textarea id="channel-info-description" bind:value={editDescription} class="textarea textarea-bordered w-full" rows="3" maxlength="250"></textarea>
105
105
  </div>
106
106
  <div class="flex gap-2">
107
107
  <button class="btn btn-primary btn-sm" onclick={saveEdit} disabled={saving || !editName.trim()}>
@@ -126,20 +126,20 @@
126
126
  {selectedEmoji}
127
127
  </button>
128
128
  <div class="flex-1">
129
- <label class="label"><span class="label-text font-medium">Channel Name</span></label>
130
- <input type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
129
+ <label for="create-channel-name" class="label"><span class="label-text font-medium">Channel Name</span></label>
130
+ <input id="create-channel-name" type="text" bind:value={channelName} class="input input-bordered w-full" placeholder="e.g., general, announcements" maxlength="80" />
131
131
  </div>
132
132
  </div>
133
133
 
134
134
  <!-- Description -->
135
135
  <div>
136
- <label class="label"><span class="label-text font-medium">Description</span></label>
137
- <textarea bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
136
+ <label for="create-channel-description" class="label"><span class="label-text font-medium">Description</span></label>
137
+ <textarea id="create-channel-description" bind:value={description} class="textarea textarea-bordered w-full" placeholder="What's this channel about?" rows="2" maxlength="250"></textarea>
138
138
  </div>
139
139
 
140
140
  <!-- Channel type -->
141
141
  <div>
142
- <label class="label"><span class="label-text font-medium">Channel Type</span></label>
142
+ <span class="label"><span class="label-text font-medium">Channel Type</span></span>
143
143
  <div class="flex gap-3">
144
144
  <label class="cursor-pointer flex items-center gap-2">
145
145
  <input type="radio" name="channelType" class="radio radio-primary" bind:group={channelType} value="public" />
@@ -154,8 +154,8 @@
154
154
 
155
155
  <!-- Member selection -->
156
156
  <div>
157
- <label class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
158
- <input type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
157
+ <label for="create-channel-member-search" class="label"><span class="label-text font-medium">Add Members ({selectedMembers.length})</span></label>
158
+ <input id="create-channel-member-search" type="text" bind:value={searchQuery} class="input input-bordered input-sm w-full mb-2" placeholder="Search members..." />
159
159
  <div class="max-h-40 overflow-y-auto space-y-1">
160
160
  {#each filteredMembers as member}
161
161
  <button type="button" class="w-full flex items-center gap-3 px-2 py-1.5 rounded hover:bg-base-200 transition-colors" onclick={() => toggleMember(member.id)}>
@@ -165,12 +165,12 @@
165
165
  {#if globalSettings.quiet_hours_enabled}
166
166
  <div class="flex gap-3">
167
167
  <div class="flex-1">
168
- <label class="label"><span class="label-text text-xs">Start</span></label>
169
- <input type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
168
+ <label for="notif-quiet-hours-start" class="label"><span class="label-text text-xs">Start</span></label>
169
+ <input id="notif-quiet-hours-start" type="time" bind:value={globalSettings.quiet_hours_start} class="input input-bordered input-sm w-full" />
170
170
  </div>
171
171
  <div class="flex-1">
172
- <label class="label"><span class="label-text text-xs">End</span></label>
173
- <input type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
172
+ <label for="notif-quiet-hours-end" class="label"><span class="label-text text-xs">End</span></label>
173
+ <input id="notif-quiet-hours-end" type="time" bind:value={globalSettings.quiet_hours_end} class="input input-bordered input-sm w-full" />
174
174
  </div>
175
175
  </div>
176
176
  {/if}
@@ -197,8 +197,8 @@
197
197
  </label>
198
198
  </div>
199
199
  <div>
200
- <label class="label"><span class="label-text font-medium">Notification Level</span></label>
201
- <select bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
200
+ <label for="notif-channel-level" class="label"><span class="label-text font-medium">Notification Level</span></label>
201
+ <select id="notif-channel-level" bind:value={channelPrefs.notification_level} class="select select-bordered w-full">
202
202
  <option value="all">All Messages</option>
203
203
  <option value="mentions">Mentions Only</option>
204
204
  <option value="none">None</option>
package/src/lib/index.ts CHANGED
@@ -10,6 +10,7 @@ export { default as ResizableDivider } from './components/ResizableDivider.svelt
10
10
  export { default as DateRangePicker } from './components/DateRangePicker.svelte';
11
11
  export { default as SortDropdown } from './components/SortDropdown.svelte';
12
12
  export { default as InlineEdit } from './components/InlineEdit.svelte';
13
+ export { default as FloatingActionBar } from './components/FloatingActionBar.svelte';
13
14
 
14
15
  // Components — from Flush
15
16
  export { default as Button } from './components/Button.svelte';
@@ -75,6 +76,7 @@ export type { SearchDropdownOption, SearchDropdownGroup } from './components/Sea
75
76
  export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
76
77
  export type { SortOption } from './components/SortDropdown.svelte';
77
78
  export type { DisplaySegment } from './components/InlineEdit.svelte';
79
+ export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
78
80
 
79
81
  // Component types — Flush
80
82
  export type { SelectOption } from './components/SelectInput.svelte';
@@ -9,14 +9,16 @@
9
9
  */
10
10
  export function statusColor(status: string): string {
11
11
  switch (status) {
12
- case 'submitted': return 'badge-info';
12
+ case 'open': return 'badge-ghost';
13
13
  case 'in_progress': return 'badge-warning';
14
- case 'completed':
15
- case 'accepted': return 'badge-success';
16
- case 'rejected':
17
- case 'wontfix': return 'badge-error';
18
- case 'closed': return 'badge-neutral';
19
- default: return 'badge-ghost';
14
+ case 'waiting': return 'badge-warning badge-outline';
15
+ case 'blocked': return 'badge-error badge-outline';
16
+ case 'submitted': return 'badge-info';
17
+ case 'accepted': return 'badge-success';
18
+ case 'deployed': return 'badge-success badge-outline';
19
+ case 'closed': return 'badge-neutral';
20
+ case 'dev': return 'badge-ghost badge-outline';
21
+ default: return 'badge-ghost';
20
22
  }
21
23
  }
22
24