@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.
@@ -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 month/year header and navigation buttons. @default false */
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-day-{dayNumber}` — each day cell
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
  };
@@ -38,7 +38,6 @@
38
38
 
39
39
  const display = $derived(value ? formatDate(value) : '');
40
40
  const hasErrors = $derived(errors.length > 0);
41
-
42
41
  const tokens = $derived(formSizeTokens[size]);
43
42
 
44
43
  function clear(e: MouseEvent) {
@@ -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
- // Build a flat list of months spanning a range of years. Default:
64
- // 5 years before → 5 years after the anchor (selected or today).
65
- const RANGE_YEARS = 5;
66
- type MonthEntry = { year: number; month: number; date: Date; label: string; disabled: boolean };
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 anchor = $derived(value ?? new Date());
69
- const startYear = $derived(minDate ? minDate.getFullYear() : anchor.getFullYear() - RANGE_YEARS);
70
- const endYear = $derived(maxDate ? maxDate.getFullYear() : anchor.getFullYear() + RANGE_YEARS);
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
- const months = $derived.by<MonthEntry[]>(() => {
73
- const out: MonthEntry[] = [];
74
- for (let y = startYear; y <= endYear; y++) {
75
- for (let m = 0; m < 12; m++) {
76
- const d = new Date(y, m, 1);
77
- let disabled = false;
78
- if (minDate && d < new Date(minDate.getFullYear(), minDate.getMonth(), 1)) disabled = true;
79
- if (maxDate && d > new Date(maxDate.getFullYear(), maxDate.getMonth(), 1)) disabled = true;
80
- out.push({
81
- year: y,
82
- month: m,
83
- date: d,
84
- label: MONTH_NAMES[m],
85
- disabled
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
- // Unique sorted years and a fast lookup by year*12+month key.
93
- const years = $derived([...new Set(months.map((e) => e.year))]);
94
- const monthMap = $derived(new Map(months.map((e) => [e.year * 12 + e.month, e])));
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(entry: MonthEntry): boolean {
97
- return !!value && value.getFullYear() === entry.year && value.getMonth() === entry.month;
114
+ function isSelected(year: number, month: number): boolean {
115
+ return !!value && value.getFullYear() === year && value.getMonth() === month;
98
116
  }
99
117
 
100
- function isCurrent(entry: MonthEntry): boolean {
118
+ function isCurrent(year: number, month: number): boolean {
101
119
  const now = new Date();
102
- return entry.year === now.getFullYear() && entry.month === now.getMonth();
120
+ return year === now.getFullYear() && month === now.getMonth();
103
121
  }
104
122
 
105
- function pick(entry: MonthEntry) {
106
- if (entry.disabled) return;
107
- value = entry.date;
108
- onselect?.(entry.date);
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
- target?.scrollIntoView({ block: 'center' });
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 entry = monthMap.get(year * 12 + m)}
261
- {@const sel = entry ? isSelected(entry) : false}
262
- {@const cur = entry ? isCurrent(entry) : false}
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={() => entry && pick(entry)}
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,4 @@
1
+ import type { CommentComposerProps } from '../../index.js';
2
+ declare const CommentComposer: import("svelte").Component<CommentComposerProps, {}, "value">;
3
+ type CommentComposer = ReturnType<typeof CommentComposer>;
4
+ export default CommentComposer;
@@ -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,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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makolabs/ripple",
3
- "version": "3.3.0",
3
+ "version": "3.4.1",
4
4
  "description": "Simple Svelte 5 powered component library ✨",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {