@makolabs/ripple 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/forms/DateRange.svelte +85 -460
- package/dist/forms/Textarea.svelte +2 -0
- package/dist/forms/calendar/Calendar.svelte +369 -164
- package/dist/forms/calendar/calendar-types.d.ts +15 -5
- package/dist/forms/date-picker/DatePicker.svelte +0 -1
- package/dist/forms/form-types.d.ts +6 -0
- package/dist/forms/month-picker/MonthPicker.svelte +119 -40
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/layout/comment-composer/CommentComposer.svelte +111 -0
- package/dist/layout/comment-composer/CommentComposer.svelte.d.ts +4 -0
- package/dist/layout/comment-composer/comment-composer-types.d.ts +99 -0
- package/dist/layout/comment-composer/comment-composer-types.js +1 -0
- package/dist/layout/comment-composer/comment-composer.d.ts +76 -0
- package/dist/layout/comment-composer/comment-composer.js +47 -0
- package/package.json +1 -1
|
@@ -28,7 +28,7 @@ export type CalendarProps = {
|
|
|
28
28
|
* Day the week starts on. `0` = Sunday, `1` = Monday. @default 1
|
|
29
29
|
*/
|
|
30
30
|
weekStartsOn?: 0 | 1;
|
|
31
|
-
/** Hide the
|
|
31
|
+
/** Hide the weekday header row above the scrolling month list. @default false */
|
|
32
32
|
hideHeader?: boolean;
|
|
33
33
|
/** Disable all interaction. */
|
|
34
34
|
disabled?: boolean;
|
|
@@ -39,6 +39,17 @@ export type CalendarProps = {
|
|
|
39
39
|
* oversized. `2xl` aliases `xl`. @default 'md'
|
|
40
40
|
*/
|
|
41
41
|
size?: VariantSizes;
|
|
42
|
+
/**
|
|
43
|
+
* Distance, in months (fractional allowed), of the open-time
|
|
44
|
+
* scroll-in flourish. The scroll animation starts this far above
|
|
45
|
+
* the anchor month and eases into it. Capped against `minDate`,
|
|
46
|
+
* so dates near the lower bound get a shorter flourish (or none).
|
|
47
|
+
* Set to `0` to disable. The default is shared by embedded
|
|
48
|
+
* Calendar and Calendars-inside-popovers (DatePicker, DateRange)
|
|
49
|
+
* so the open animation feels identical across all date pickers.
|
|
50
|
+
* @default 0.5
|
|
51
|
+
*/
|
|
52
|
+
scrollFlourish?: number;
|
|
42
53
|
/** Wrapper class. */
|
|
43
54
|
class?: ClassValue;
|
|
44
55
|
/**
|
|
@@ -53,11 +64,10 @@ export type CalendarProps = {
|
|
|
53
64
|
/**
|
|
54
65
|
* Test ID prefix. When set, the component emits these selectors:
|
|
55
66
|
* - `{testId}-calendar` — root wrapper
|
|
56
|
-
* - `{testId}-calendar-prev-month` — prev button
|
|
57
|
-
* - `{testId}-calendar-next-month` — next button
|
|
58
|
-
* - `{testId}-calendar-month-label` — month/year header
|
|
59
67
|
* - `{testId}-calendar-day-headers` — weekday row
|
|
60
|
-
* - `{testId}-calendar-
|
|
68
|
+
* - `{testId}-calendar-scroll` — scrollable month list
|
|
69
|
+
* - `{testId}-calendar-month-label-{yearMonthKey}` — sticky month/year header per month
|
|
70
|
+
* - `{testId}-calendar-day-{dayNumber}` — each day cell (repeats across months)
|
|
61
71
|
*/
|
|
62
72
|
testId?: string;
|
|
63
73
|
};
|
|
@@ -468,6 +468,12 @@ export interface TextareaProps {
|
|
|
468
468
|
oninput?: (value: string) => void;
|
|
469
469
|
/** Fires on blur with the current value. */
|
|
470
470
|
onblur?: (value: string) => void;
|
|
471
|
+
/**
|
|
472
|
+
* Raw keydown handler on the underlying `<textarea>`. Useful for
|
|
473
|
+
* keyboard shortcuts like ⌘/Ctrl+Enter to submit. The event is
|
|
474
|
+
* forwarded as-is so callers can `preventDefault()` when needed.
|
|
475
|
+
*/
|
|
476
|
+
onkeydown?: (event: KeyboardEvent) => void;
|
|
471
477
|
testId?: string;
|
|
472
478
|
}
|
|
473
479
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
2
3
|
import { cn } from '../../helper/cls.js';
|
|
3
4
|
import { buildTestId } from '../../helper/testid.js';
|
|
4
5
|
import { Size } from '../../variants.js';
|
|
@@ -25,6 +26,8 @@
|
|
|
25
26
|
|
|
26
27
|
let open = $state(false);
|
|
27
28
|
let listEl = $state<HTMLDivElement | undefined>();
|
|
29
|
+
let topSentinelEl = $state<HTMLDivElement | undefined>();
|
|
30
|
+
let bottomSentinelEl = $state<HTMLDivElement | undefined>();
|
|
28
31
|
const tokens = $derived(formSizeTokens[size]);
|
|
29
32
|
const hasErrors = $derived(errors.length > 0);
|
|
30
33
|
|
|
@@ -60,52 +63,68 @@
|
|
|
60
63
|
|
|
61
64
|
const display = $derived(value ? `${MONTH_NAMES[value.getMonth()]} ${value.getFullYear()}` : '');
|
|
62
65
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Lazy infinite-scroll window over years. The list starts as a small
|
|
67
|
+
// pad around the anchor (selected month or today) and grows toward
|
|
68
|
+
// either edge as IntersectionObserver sentinels enter the viewport,
|
|
69
|
+
// stopping at minDate/maxDate when those are set.
|
|
70
|
+
const INITIAL_PAD = 3;
|
|
71
|
+
const EXTEND_BY = 5;
|
|
67
72
|
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
73
|
+
const anchorYear = $derived((value ?? new Date()).getFullYear());
|
|
74
|
+
const minYear = $derived(minDate ? minDate.getFullYear() : null);
|
|
75
|
+
const maxYear = $derived(maxDate ? maxDate.getFullYear() : null);
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
let firstYear = $state<number>(new Date().getFullYear());
|
|
78
|
+
let lastYear = $state<number>(new Date().getFullYear());
|
|
79
|
+
// `extending` guards against re-entrant extends while a previous
|
|
80
|
+
// extension is still flushing through tick().
|
|
81
|
+
let extending = false;
|
|
82
|
+
|
|
83
|
+
// Initialize / reset the visible window whenever the anchor or bounds
|
|
84
|
+
// change. We deliberately do not depend on firstYear/lastYear here so
|
|
85
|
+
// extensions don't re-trigger this effect.
|
|
86
|
+
$effect(() => {
|
|
87
|
+
const a = anchorYear;
|
|
88
|
+
const lo = minYear;
|
|
89
|
+
const hi = maxYear;
|
|
90
|
+
let f = a - INITIAL_PAD;
|
|
91
|
+
let l = a + INITIAL_PAD;
|
|
92
|
+
if (lo !== null) f = Math.max(f, lo);
|
|
93
|
+
if (hi !== null) l = Math.min(l, hi);
|
|
94
|
+
if (f > l) {
|
|
95
|
+
f = l = a;
|
|
88
96
|
}
|
|
97
|
+
firstYear = f;
|
|
98
|
+
lastYear = l;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const years = $derived.by<number[]>(() => {
|
|
102
|
+
const out: number[] = [];
|
|
103
|
+
for (let y = firstYear; y <= lastYear; y++) out.push(y);
|
|
89
104
|
return out;
|
|
90
105
|
});
|
|
91
106
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
107
|
+
function isMonthDisabled(year: number, month: number): boolean {
|
|
108
|
+
const d = new Date(year, month, 1);
|
|
109
|
+
if (minDate && d < new Date(minDate.getFullYear(), minDate.getMonth(), 1)) return true;
|
|
110
|
+
if (maxDate && d > new Date(maxDate.getFullYear(), maxDate.getMonth(), 1)) return true;
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
95
113
|
|
|
96
|
-
function isSelected(
|
|
97
|
-
return !!value && value.getFullYear() ===
|
|
114
|
+
function isSelected(year: number, month: number): boolean {
|
|
115
|
+
return !!value && value.getFullYear() === year && value.getMonth() === month;
|
|
98
116
|
}
|
|
99
117
|
|
|
100
|
-
function isCurrent(
|
|
118
|
+
function isCurrent(year: number, month: number): boolean {
|
|
101
119
|
const now = new Date();
|
|
102
|
-
return
|
|
120
|
+
return year === now.getFullYear() && month === now.getMonth();
|
|
103
121
|
}
|
|
104
122
|
|
|
105
|
-
function pick(
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
function pick(year: number, month: number) {
|
|
124
|
+
if (isMonthDisabled(year, month)) return;
|
|
125
|
+
const d = new Date(year, month, 1);
|
|
126
|
+
value = d;
|
|
127
|
+
onselect?.(d);
|
|
109
128
|
open = false;
|
|
110
129
|
}
|
|
111
130
|
|
|
@@ -126,6 +145,61 @@
|
|
|
126
145
|
open = false;
|
|
127
146
|
}
|
|
128
147
|
|
|
148
|
+
async function extendBackward() {
|
|
149
|
+
if (extending) return;
|
|
150
|
+
if (minYear !== null && firstYear <= minYear) return;
|
|
151
|
+
const el = listEl;
|
|
152
|
+
if (!el) return;
|
|
153
|
+
extending = true;
|
|
154
|
+
const prevHeight = el.scrollHeight;
|
|
155
|
+
const prevTop = el.scrollTop;
|
|
156
|
+
let next = firstYear - EXTEND_BY;
|
|
157
|
+
if (minYear !== null && next < minYear) next = minYear;
|
|
158
|
+
firstYear = next;
|
|
159
|
+
// Wait for the new year sections to render, then preserve the
|
|
160
|
+
// user's visual scroll position by adding the height delta.
|
|
161
|
+
await tick();
|
|
162
|
+
const newHeight = el.scrollHeight;
|
|
163
|
+
el.scrollTop = prevTop + (newHeight - prevHeight);
|
|
164
|
+
extending = false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function extendForward() {
|
|
168
|
+
if (extending) return;
|
|
169
|
+
if (maxYear !== null && lastYear >= maxYear) return;
|
|
170
|
+
extending = true;
|
|
171
|
+
let next = lastYear + EXTEND_BY;
|
|
172
|
+
if (maxYear !== null && next > maxYear) next = maxYear;
|
|
173
|
+
lastYear = next;
|
|
174
|
+
await tick();
|
|
175
|
+
extending = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Wire IntersectionObserver to the sentinels while the panel is open.
|
|
179
|
+
// Skipped where IO isn't available (SSR / jsdom tests) — the initial
|
|
180
|
+
// pad still renders, just without lazy extension.
|
|
181
|
+
$effect(() => {
|
|
182
|
+
if (!open) return;
|
|
183
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
184
|
+
const root = listEl;
|
|
185
|
+
const top = topSentinelEl;
|
|
186
|
+
const bottom = bottomSentinelEl;
|
|
187
|
+
if (!root || !top || !bottom) return;
|
|
188
|
+
const io = new IntersectionObserver(
|
|
189
|
+
(entries) => {
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
if (!entry.isIntersecting) continue;
|
|
192
|
+
if (entry.target === top) extendBackward();
|
|
193
|
+
else if (entry.target === bottom) extendForward();
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
{ root, rootMargin: '200px 0px' }
|
|
197
|
+
);
|
|
198
|
+
io.observe(top);
|
|
199
|
+
io.observe(bottom);
|
|
200
|
+
return () => io.disconnect();
|
|
201
|
+
});
|
|
202
|
+
|
|
129
203
|
// Auto-scroll to the selected (or current) month when the panel opens.
|
|
130
204
|
$effect(() => {
|
|
131
205
|
if (!open || !listEl) return;
|
|
@@ -133,7 +207,11 @@
|
|
|
133
207
|
const target =
|
|
134
208
|
listEl?.querySelector('[data-selected="true"]') ??
|
|
135
209
|
listEl?.querySelector('[data-current="true"]');
|
|
136
|
-
|
|
210
|
+
// jsdom doesn't implement scrollIntoView — guard so unit tests
|
|
211
|
+
// don't surface unhandled errors.
|
|
212
|
+
if (target && typeof (target as HTMLElement).scrollIntoView === 'function') {
|
|
213
|
+
(target as HTMLElement).scrollIntoView({ block: 'center' });
|
|
214
|
+
}
|
|
137
215
|
});
|
|
138
216
|
});
|
|
139
217
|
</script>
|
|
@@ -243,6 +321,7 @@
|
|
|
243
321
|
bind:this={listEl}
|
|
244
322
|
class="max-h-72 overflow-y-auto [scrollbar-width:none] sm:max-h-72 [&::-webkit-scrollbar]:hidden"
|
|
245
323
|
>
|
|
324
|
+
<div bind:this={topSentinelEl} aria-hidden="true" class="h-px"></div>
|
|
246
325
|
{#each years as year (year)}
|
|
247
326
|
{@const yearSelected = value?.getFullYear() === year}
|
|
248
327
|
<div
|
|
@@ -257,17 +336,16 @@
|
|
|
257
336
|
</div>
|
|
258
337
|
<div class="grid grid-cols-4 gap-0.5 p-1.5">
|
|
259
338
|
{#each MONTH_SHORT as monthLabel, m (m)}
|
|
260
|
-
{@const
|
|
261
|
-
{@const
|
|
262
|
-
{@const
|
|
263
|
-
{@const dis = entry?.disabled ?? true}
|
|
339
|
+
{@const sel = isSelected(year, m)}
|
|
340
|
+
{@const cur = isCurrent(year, m)}
|
|
341
|
+
{@const dis = isMonthDisabled(year, m)}
|
|
264
342
|
<button
|
|
265
343
|
type="button"
|
|
266
344
|
data-month={m}
|
|
267
345
|
data-testid={buildTestId('month-picker', 'month', testId, `${year}-${m}`)}
|
|
268
346
|
data-selected={sel || undefined}
|
|
269
347
|
data-current={cur || undefined}
|
|
270
|
-
onclick={() =>
|
|
348
|
+
onclick={() => pick(year, m)}
|
|
271
349
|
disabled={dis}
|
|
272
350
|
class={cn(
|
|
273
351
|
'cursor-pointer rounded px-1 py-2 text-xs font-medium transition-colors sm:py-1',
|
|
@@ -284,6 +362,7 @@
|
|
|
284
362
|
{/each}
|
|
285
363
|
</div>
|
|
286
364
|
{/each}
|
|
365
|
+
<div bind:this={bottomSentinelEl} aria-hidden="true" class="h-px"></div>
|
|
287
366
|
</div>
|
|
288
367
|
|
|
289
368
|
<div class="border-default-200 flex items-center justify-between border-t px-3 py-2">
|
package/dist/index.d.ts
CHANGED
|
@@ -58,6 +58,7 @@ export type { FilterTab, FilterGroup, FilterSelectionValue, DatePreset, DateRang
|
|
|
58
58
|
export { defaultDatePresets, toIsoDate, fromIsoDate } from './filters/date-presets.js';
|
|
59
59
|
export type { StepperProps, StepperStep, StepState, StepperOrientation } from './layout/stepper/stepper-types.js';
|
|
60
60
|
export type { ActivityItemBadge, ActivityItemAction, ActivityItem, ActivityListProps, ActivityListSize } from './layout/activity-list/activity-list-types.js';
|
|
61
|
+
export type { CommentComposerProps, CommentComposerSize } from './layout/comment-composer/comment-composer-types.js';
|
|
61
62
|
export type { FileUploadProps, FileUploadSize, FilePreviewProps, UploadedFile, StagedFile } from './elements/file-upload/file-upload-types.js';
|
|
62
63
|
export type { ChatMessageType, StreamingCallback, ChatAction, ChatMessage, ChatResponse, QuickAction, FileBrowserProps } from './ai/ai-types.js';
|
|
63
64
|
export type { GetUsersOptions, GetUsersResult, UserEmail, UserPhone, User, Permission, Role, UserTableProps, UserModalProps, UserModalSavePayload, UserViewModalProps, UserApproveModalProps, UserManagementAdapter, UserManagementProps, FormErrors } from './user-management/user-management-types.js';
|
|
@@ -90,6 +91,7 @@ export { default as Sidebar } from './layout/sidebar/Sidebar.svelte';
|
|
|
90
91
|
export { default as NavItem } from './layout/sidebar/NavItem.svelte';
|
|
91
92
|
export { default as NavGroup } from './layout/sidebar/NavGroup.svelte';
|
|
92
93
|
export { default as ActivityList } from './layout/activity-list/ActivityList.svelte';
|
|
94
|
+
export { default as CommentComposer } from './layout/comment-composer/CommentComposer.svelte';
|
|
93
95
|
export { default as Stepper } from './layout/stepper/Stepper.svelte';
|
|
94
96
|
export { default as Progress } from './elements/progress/Progress.svelte';
|
|
95
97
|
export { default as Spinner } from './elements/spinner/Spinner.svelte';
|
package/dist/index.js
CHANGED
|
@@ -62,6 +62,8 @@ export { default as NavItem } from './layout/sidebar/NavItem.svelte';
|
|
|
62
62
|
export { default as NavGroup } from './layout/sidebar/NavGroup.svelte';
|
|
63
63
|
// Elements - ActivityList
|
|
64
64
|
export { default as ActivityList } from './layout/activity-list/ActivityList.svelte';
|
|
65
|
+
// Elements - CommentComposer
|
|
66
|
+
export { default as CommentComposer } from './layout/comment-composer/CommentComposer.svelte';
|
|
65
67
|
// Elements - Stepper
|
|
66
68
|
export { default as Stepper } from './layout/stepper/Stepper.svelte';
|
|
67
69
|
// Elements - Progress
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../../helper/cls.js';
|
|
3
|
+
import { buildTestId } from '../../helper/testid.js';
|
|
4
|
+
import { commentComposer, avatarCircleClasses } from './comment-composer.js';
|
|
5
|
+
import Card from '../card/Card.svelte';
|
|
6
|
+
import Textarea from '../../forms/Textarea.svelte';
|
|
7
|
+
import Button from '../../button/Button.svelte';
|
|
8
|
+
import { Color, Size } from '../../variants.js';
|
|
9
|
+
import type { CommentComposerProps } from '../../index.js';
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
value = $bindable(''),
|
|
13
|
+
placeholder = 'Add a comment…',
|
|
14
|
+
submitLabel = 'Post',
|
|
15
|
+
avatar,
|
|
16
|
+
avatarInitials,
|
|
17
|
+
avatarColor = Color.DEFAULT,
|
|
18
|
+
disabled = false,
|
|
19
|
+
loading = false,
|
|
20
|
+
size = 'md',
|
|
21
|
+
rows = 3,
|
|
22
|
+
maxLength,
|
|
23
|
+
showCount = false,
|
|
24
|
+
class: className = '',
|
|
25
|
+
testId,
|
|
26
|
+
onsubmit
|
|
27
|
+
}: CommentComposerProps = $props();
|
|
28
|
+
|
|
29
|
+
const slots = $derived(commentComposer({ size }));
|
|
30
|
+
const cardSize = $derived(size === 'sm' ? Size.SM : Size.MD);
|
|
31
|
+
const textareaSize = $derived(size === 'sm' ? Size.SM : Size.MD);
|
|
32
|
+
const buttonSize = $derived(size === 'sm' ? Size.XS : Size.SM);
|
|
33
|
+
|
|
34
|
+
const trimmed = $derived((value ?? '').trim());
|
|
35
|
+
const canSubmit = $derived(trimmed.length > 0 && !disabled && !loading);
|
|
36
|
+
const avatarTint = $derived(avatarCircleClasses[avatarColor] ?? avatarCircleClasses.default);
|
|
37
|
+
|
|
38
|
+
function submit() {
|
|
39
|
+
if (!canSubmit) return;
|
|
40
|
+
onsubmit?.(trimmed);
|
|
41
|
+
value = '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
45
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
submit();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<Card size={cardSize} bodyClass="p-0" class={cn(className)} {testId}>
|
|
53
|
+
<div
|
|
54
|
+
class={slots.body()}
|
|
55
|
+
role="group"
|
|
56
|
+
aria-label="Comment composer"
|
|
57
|
+
data-testid={buildTestId('comment-composer', undefined, testId)}
|
|
58
|
+
>
|
|
59
|
+
<div
|
|
60
|
+
class={cn(slots.avatarWrapper(), !avatar && avatarTint)}
|
|
61
|
+
data-testid={buildTestId('comment-composer', 'avatar', testId)}
|
|
62
|
+
aria-hidden={avatar ? undefined : 'true'}
|
|
63
|
+
>
|
|
64
|
+
{#if avatar}
|
|
65
|
+
{@render avatar()}
|
|
66
|
+
{:else if avatarInitials}
|
|
67
|
+
<span class={slots.avatarInitials()}>{avatarInitials}</span>
|
|
68
|
+
{:else}
|
|
69
|
+
<svg
|
|
70
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
71
|
+
viewBox="0 0 16 16"
|
|
72
|
+
class={size === 'sm' ? 'size-3.5' : 'size-4'}
|
|
73
|
+
fill="currentColor"
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
>
|
|
76
|
+
<path
|
|
77
|
+
d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.5 6.5c0-2.485 2.462-4.5 5.5-4.5s5.5 2.015 5.5 4.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5Z"
|
|
78
|
+
/>
|
|
79
|
+
</svg>
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class={slots.main()}>
|
|
84
|
+
<Textarea
|
|
85
|
+
name={buildTestId('comment-composer', 'field', testId)}
|
|
86
|
+
bind:value
|
|
87
|
+
{placeholder}
|
|
88
|
+
{rows}
|
|
89
|
+
{maxLength}
|
|
90
|
+
{showCount}
|
|
91
|
+
disabled={disabled || loading}
|
|
92
|
+
size={textareaSize}
|
|
93
|
+
onkeydown={handleKeydown}
|
|
94
|
+
testId={buildTestId('comment-composer', undefined, testId)}
|
|
95
|
+
/>
|
|
96
|
+
<div class={slots.footer()}>
|
|
97
|
+
<Button
|
|
98
|
+
size={buttonSize}
|
|
99
|
+
color={Color.PRIMARY}
|
|
100
|
+
variant="solid"
|
|
101
|
+
disabled={!canSubmit}
|
|
102
|
+
{loading}
|
|
103
|
+
onclick={submit}
|
|
104
|
+
testId={buildTestId('comment-composer', 'submit', testId)}
|
|
105
|
+
>
|
|
106
|
+
{submitLabel}
|
|
107
|
+
</Button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</Card>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ClassValue } from 'tailwind-variants';
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { VariantColors } from '../../index.js';
|
|
4
|
+
/** Density variants for `<CommentComposer>`. Mirrors `ActivityListSize`. */
|
|
5
|
+
export type CommentComposerSize = 'sm' | 'md';
|
|
6
|
+
/**
|
|
7
|
+
* Props for `<CommentComposer>` — a comment input surface meant to sit
|
|
8
|
+
* directly under an `<ActivityList>` (or any feed/timeline). Composes
|
|
9
|
+
* Card + Textarea + Button so the styling matches the rest of ripple.
|
|
10
|
+
*
|
|
11
|
+
* State is self-managed by default (`value` is bindable and clears
|
|
12
|
+
* after a successful `onsubmit`). Bind `value` externally if you need
|
|
13
|
+
* full control.
|
|
14
|
+
*
|
|
15
|
+
* Submit triggers: clicking the action button, or pressing
|
|
16
|
+
* ⌘/Ctrl+Enter while the textarea is focused.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```svelte
|
|
20
|
+
* <CommentComposer
|
|
21
|
+
* avatarInitials="BB"
|
|
22
|
+
* avatarColor="primary"
|
|
23
|
+
* onsubmit={(text) => savedComments.push({ text, at: new Date() })}
|
|
24
|
+
* />
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```svelte
|
|
29
|
+
* <!-- Custom avatar (e.g., img or icon component) -->
|
|
30
|
+
* <CommentComposer onsubmit={post}>
|
|
31
|
+
* {#snippet avatar()}
|
|
32
|
+
* <img src={user.photoUrl} alt="" class="size-8 rounded-full" />
|
|
33
|
+
* {/snippet}
|
|
34
|
+
* </CommentComposer>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export type CommentComposerProps = {
|
|
38
|
+
/** Bindable comment text. Cleared after a successful submit unless externally bound. @default '' */
|
|
39
|
+
value?: string;
|
|
40
|
+
/** Placeholder shown in the textarea. @default 'Add a comment…' */
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
/** Label for the submit button. @default 'Post' */
|
|
43
|
+
submitLabel?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Custom avatar slot. When provided, it wins over `avatarInitials`.
|
|
46
|
+
* Use this to render an `<img>`, an icon component, or any markup.
|
|
47
|
+
* The composer reserves a 32px (md) / 24px (sm) square slot — render
|
|
48
|
+
* accordingly.
|
|
49
|
+
*/
|
|
50
|
+
avatar?: Snippet;
|
|
51
|
+
/**
|
|
52
|
+
* Fallback initials shown in a colored circle when no `avatar`
|
|
53
|
+
* snippet is provided. Typically 1–2 characters.
|
|
54
|
+
*/
|
|
55
|
+
avatarInitials?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Tints the initials circle. Has no effect when `avatar` is
|
|
58
|
+
* provided. @default 'default'
|
|
59
|
+
*/
|
|
60
|
+
avatarColor?: VariantColors;
|
|
61
|
+
/** Disables both the textarea and the submit button. */
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Marks the composer as in-flight. Disables interaction and renders
|
|
65
|
+
* the submit button in its loading state (ripple's `Button` already
|
|
66
|
+
* supports this).
|
|
67
|
+
*/
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
/** Visual density. @default 'md' */
|
|
70
|
+
size?: CommentComposerSize;
|
|
71
|
+
/** Initial textarea row count. @default 3 */
|
|
72
|
+
rows?: number;
|
|
73
|
+
/** Optional max character length, passed through to the underlying `<Textarea>`. */
|
|
74
|
+
maxLength?: number;
|
|
75
|
+
/** Show a "n / max" counter. Requires `maxLength`. @default false */
|
|
76
|
+
showCount?: boolean;
|
|
77
|
+
class?: ClassValue;
|
|
78
|
+
/**
|
|
79
|
+
* Test ID prefix. When set, the component emits these selectors
|
|
80
|
+
* (all built via `buildTestId`):
|
|
81
|
+
* - `{testId}-card` — root card wrapper (from `<Card>`)
|
|
82
|
+
* - `{testId}-comment-composer` — composer body
|
|
83
|
+
* - `{testId}-comment-composer-avatar` — avatar slot
|
|
84
|
+
* - `{testId}-comment-composer-textarea` — `<textarea>` element
|
|
85
|
+
* - `{testId}-comment-composer-textarea-wrapper` — textarea wrapper div
|
|
86
|
+
* - `{testId}-comment-composer-submit-button` — submit button
|
|
87
|
+
*
|
|
88
|
+
* When `testId` is unset the same selectors emit without the prefix
|
|
89
|
+
* (e.g. `comment-composer-submit-button`). Pass a unique `testId`
|
|
90
|
+
* if you render multiple composers on the same page.
|
|
91
|
+
*/
|
|
92
|
+
testId?: string;
|
|
93
|
+
/**
|
|
94
|
+
* Fires when the user submits a non-empty trimmed comment, either
|
|
95
|
+
* via the submit button or ⌘/Ctrl+Enter. The composer auto-clears
|
|
96
|
+
* `value` after this fires (unless externally bound).
|
|
97
|
+
*/
|
|
98
|
+
onsubmit?: (text: string) => void;
|
|
99
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export declare const commentComposer: import("tailwind-variants").TVReturnType<{
|
|
2
|
+
size: {
|
|
3
|
+
md: {
|
|
4
|
+
body: string;
|
|
5
|
+
avatarWrapper: string;
|
|
6
|
+
main: string;
|
|
7
|
+
footer: string;
|
|
8
|
+
};
|
|
9
|
+
sm: {
|
|
10
|
+
body: string;
|
|
11
|
+
avatarWrapper: string;
|
|
12
|
+
avatarInitials: string;
|
|
13
|
+
main: string;
|
|
14
|
+
footer: string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
}, {
|
|
18
|
+
body: string;
|
|
19
|
+
avatarWrapper: string;
|
|
20
|
+
avatarInitials: string;
|
|
21
|
+
main: string;
|
|
22
|
+
textareaSlot: string;
|
|
23
|
+
footer: string;
|
|
24
|
+
}, undefined, {
|
|
25
|
+
size: {
|
|
26
|
+
md: {
|
|
27
|
+
body: string;
|
|
28
|
+
avatarWrapper: string;
|
|
29
|
+
main: string;
|
|
30
|
+
footer: string;
|
|
31
|
+
};
|
|
32
|
+
sm: {
|
|
33
|
+
body: string;
|
|
34
|
+
avatarWrapper: string;
|
|
35
|
+
avatarInitials: string;
|
|
36
|
+
main: string;
|
|
37
|
+
footer: string;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}, {
|
|
41
|
+
body: string;
|
|
42
|
+
avatarWrapper: string;
|
|
43
|
+
avatarInitials: string;
|
|
44
|
+
main: string;
|
|
45
|
+
textareaSlot: string;
|
|
46
|
+
footer: string;
|
|
47
|
+
}, import("tailwind-variants").TVReturnType<{
|
|
48
|
+
size: {
|
|
49
|
+
md: {
|
|
50
|
+
body: string;
|
|
51
|
+
avatarWrapper: string;
|
|
52
|
+
main: string;
|
|
53
|
+
footer: string;
|
|
54
|
+
};
|
|
55
|
+
sm: {
|
|
56
|
+
body: string;
|
|
57
|
+
avatarWrapper: string;
|
|
58
|
+
avatarInitials: string;
|
|
59
|
+
main: string;
|
|
60
|
+
footer: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}, {
|
|
64
|
+
body: string;
|
|
65
|
+
avatarWrapper: string;
|
|
66
|
+
avatarInitials: string;
|
|
67
|
+
main: string;
|
|
68
|
+
textareaSlot: string;
|
|
69
|
+
footer: string;
|
|
70
|
+
}, undefined, unknown, unknown, undefined>>;
|
|
71
|
+
/**
|
|
72
|
+
* Tailwind classes for the avatar fallback circle, keyed by ripple
|
|
73
|
+
* VariantColor. Mirrors `iconCircleClasses` in activity-list.ts so
|
|
74
|
+
* a composer placed under an ActivityList visually matches its rows.
|
|
75
|
+
*/
|
|
76
|
+
export declare const avatarCircleClasses: Record<string, string>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { tv } from 'tailwind-variants';
|
|
2
|
+
// Item-level slots only. The card chrome (border, radius, shadow) is
|
|
3
|
+
// delegated to <Card>, matching how ActivityList composes Card.
|
|
4
|
+
export const commentComposer = tv({
|
|
5
|
+
slots: {
|
|
6
|
+
body: 'flex',
|
|
7
|
+
avatarWrapper: 'shrink-0 inline-flex items-center justify-center rounded-full ring-4 ring-white',
|
|
8
|
+
avatarInitials: 'text-xs font-medium leading-none uppercase select-none',
|
|
9
|
+
main: 'flex-1 min-w-0',
|
|
10
|
+
textareaSlot: '',
|
|
11
|
+
footer: 'flex items-center justify-end'
|
|
12
|
+
},
|
|
13
|
+
variants: {
|
|
14
|
+
size: {
|
|
15
|
+
md: {
|
|
16
|
+
body: 'gap-3 p-4',
|
|
17
|
+
avatarWrapper: 'size-8 mt-1',
|
|
18
|
+
main: 'space-y-3',
|
|
19
|
+
footer: 'gap-2'
|
|
20
|
+
},
|
|
21
|
+
sm: {
|
|
22
|
+
body: 'gap-2 p-3',
|
|
23
|
+
avatarWrapper: 'size-6 mt-0.5',
|
|
24
|
+
avatarInitials: 'text-[10px]',
|
|
25
|
+
main: 'space-y-2',
|
|
26
|
+
footer: 'gap-1.5'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
size: 'md'
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Tailwind classes for the avatar fallback circle, keyed by ripple
|
|
36
|
+
* VariantColor. Mirrors `iconCircleClasses` in activity-list.ts so
|
|
37
|
+
* a composer placed under an ActivityList visually matches its rows.
|
|
38
|
+
*/
|
|
39
|
+
export const avatarCircleClasses = {
|
|
40
|
+
default: 'bg-default-100 text-default-600',
|
|
41
|
+
primary: 'bg-primary-100 text-primary-600',
|
|
42
|
+
secondary: 'bg-secondary-100 text-secondary-600',
|
|
43
|
+
info: 'bg-info-100 text-info-600',
|
|
44
|
+
success: 'bg-success-100 text-success-700',
|
|
45
|
+
warning: 'bg-warning-100 text-warning-700',
|
|
46
|
+
danger: 'bg-danger-100 text-danger-700'
|
|
47
|
+
};
|