@r2digisolutions/components 0.6.0 → 0.6.2

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 (30) hide show
  1. package/dist/components/molecules/Combobox/Combobox.stories.d.ts +6 -0
  2. package/dist/components/molecules/Combobox/Combobox.stories.js +5 -2
  3. package/dist/components/molecules/Combobox/Combobox.svelte +155 -64
  4. package/dist/components/molecules/Combobox/Combobox.svelte.d.ts +15 -0
  5. package/dist/components/molecules/Combobox/ComboboxStory.svelte +25 -12
  6. package/dist/components/molecules/Combobox/ComboboxStory.svelte.d.ts +1 -0
  7. package/dist/components/molecules/Combobox/combobox-context.d.ts +12 -0
  8. package/dist/components/molecules/Combobox/combobox-context.js +10 -0
  9. package/dist/components/molecules/ComboboxItem/ComboboxItem.stories.d.ts +9 -0
  10. package/dist/components/molecules/ComboboxItem/ComboboxItem.stories.js +8 -0
  11. package/dist/components/molecules/ComboboxItem/ComboboxItem.svelte +121 -0
  12. package/dist/components/molecules/ComboboxItem/ComboboxItem.svelte.d.ts +33 -0
  13. package/dist/components/molecules/ComboboxItem/ComboboxItemStory.svelte +54 -0
  14. package/dist/components/molecules/ComboboxItem/ComboboxItemStory.svelte.d.ts +3 -0
  15. package/dist/components/molecules/Form/Form.svelte +36 -10
  16. package/dist/components/molecules/FormField/FormField.svelte +56 -19
  17. package/dist/components/molecules/FormField/FormField.svelte.d.ts +21 -6
  18. package/dist/components/molecules/FormPasswordInput/FormPasswordInput.svelte +38 -9
  19. package/dist/components/molecules/FormPasswordInput/FormPasswordInput.svelte.d.ts +8 -1
  20. package/dist/components/organisms/BlogEditor/BlogEditorStory.svelte +1 -1
  21. package/dist/components/organisms/FileUploader/FileUploader.svelte +7 -3
  22. package/dist/components/organisms/InvoicePage/InvoicePage.svelte +1 -1
  23. package/dist/components/organisms/ProfileHeaderUploader/ProfileHeaderUploader.svelte +30 -10
  24. package/dist/components/organisms/ProfileHeaderUploader/ProfileHeaderUploader.svelte.d.ts +4 -0
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.js +3 -1
  27. package/dist/styles.css +6 -0
  28. package/dist/utils/formContext.d.ts +58 -0
  29. package/dist/utils/formContext.js +79 -1
  30. package/package.json +24 -24
@@ -0,0 +1,33 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface ComboboxItemProps {
3
+ value: string;
4
+ /** Used for default text and query filtering when nested in `Combobox`. */
5
+ label?: string;
6
+ disabled?: boolean;
7
+ /** Extra strings matched against the parent `Combobox` query. */
8
+ keywords?: string[];
9
+ /**
10
+ * When omitted, selection comes from the parent `Combobox`.
11
+ * Pass explicitly when using the item standalone.
12
+ */
13
+ selected?: boolean;
14
+ /**
15
+ * When omitted, highlight comes from the parent `Combobox`.
16
+ * Pass explicitly when using the item standalone.
17
+ */
18
+ highlighted?: boolean;
19
+ /**
20
+ * Register this row for keyboard navigation / query filtering.
21
+ * Internal `Combobox` rows set this to `false` (parent already filters).
22
+ */
23
+ register?: boolean;
24
+ class?: string;
25
+ children?: Snippet;
26
+ leading?: Snippet;
27
+ trailing?: Snippet;
28
+ onclick?: () => void;
29
+ onhighlight?: () => void;
30
+ }
31
+ declare const ComboboxItem: import("svelte").Component<ComboboxItemProps, {}, "">;
32
+ type ComboboxItem = ReturnType<typeof ComboboxItem>;
33
+ export default ComboboxItem;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import ComboboxItem from './ComboboxItem.svelte';
3
+
4
+ let selected = $state('svelte');
5
+ let highlighted = $state('svelte');
6
+
7
+ const frameworks = [
8
+ { value: 'svelte', label: 'Svelte', hint: 'Compiler UI' },
9
+ { value: 'react', label: 'React', hint: 'Component model' },
10
+ { value: 'vue', label: 'Vue', hint: 'Progressive' },
11
+ { value: 'solid', label: 'Solid', hint: 'Fine-grained' },
12
+ { value: 'qwik', label: 'Qwik', hint: 'Resumable', disabled: true }
13
+ ];
14
+ </script>
15
+
16
+ <div class="max-w-sm space-y-3 w-full">
17
+ <div class="space-y-1">
18
+ <p class="text-sm font-semibold text-primary">ComboboxItem</p>
19
+ <p class="text-xs text-secondary">
20
+ Fila de opción para listas de Combobox. Funciona sola o anidada en
21
+ <code class="text-primary">Combobox</code>.
22
+ </p>
23
+ </div>
24
+
25
+ <div class="rounded-xl border-border bg-surface-elevated p-1.5 shadow-sm border">
26
+ {#each frameworks as fw (fw.value)}
27
+ <ComboboxItem
28
+ value={fw.value}
29
+ label={fw.label}
30
+ disabled={fw.disabled}
31
+ selected={selected === fw.value}
32
+ highlighted={highlighted === fw.value}
33
+ onclick={() => (selected = fw.value)}
34
+ onhighlight={() => (highlighted = fw.value)}
35
+ >
36
+ <span class="min-w-0 flex flex-col">
37
+ <span class="truncate">{fw.label}</span>
38
+ <span
39
+ class={[
40
+ 'truncate text-[11px]',
41
+ selected === fw.value || highlighted === fw.value ? 'text-white/80' : 'text-muted'
42
+ ]}
43
+ >
44
+ {fw.hint}
45
+ </span>
46
+ </span>
47
+ </ComboboxItem>
48
+ {/each}
49
+ </div>
50
+
51
+ <p class="text-xs text-muted">
52
+ Value: <span class="text-primary">{selected}</span>
53
+ </p>
54
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const ComboboxItemStory: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ComboboxItemStory = ReturnType<typeof ComboboxItemStory>;
3
+ export default ComboboxItemStory;
@@ -3,11 +3,12 @@
3
3
  generics="TData extends FormDataValues = FormDataValues, TInput extends RemoteFormInput | void = RemoteFormInput, TOutput = unknown"
4
4
  >
5
5
  import type { Snippet } from 'svelte';
6
- import { setContext } from 'svelte';
6
+ import { setContext, untrack } from 'svelte';
7
7
  import type { RemoteFormInput } from '@sveltejs/kit';
8
8
  import Alert from '../Alert/Alert.svelte';
9
9
  import {
10
10
  FORM_CONTEXT_KEY,
11
+ getRemoteFormId,
11
12
  isRemoteForm,
12
13
  remoteIssuesToErrors,
13
14
  type FormContext,
@@ -79,6 +80,9 @@
79
80
  const isRemote = $derived(remote != null);
80
81
  const remotePending = $derived(Boolean(kitForm?.pending));
81
82
  const busy = $derived(loading || remotePending || disabled);
83
+ // Prefer page-provided `inputName` on fields; optional inferred id is set once below.
84
+ let remoteFormId = $state<string | null>(null);
85
+ let didInitRemoteFormId = false;
82
86
 
83
87
  const errorEntries = $derived(Object.entries(errors).filter(([, msg]) => Boolean(msg)));
84
88
  const hasErrors = $derived(errorEntries.length > 0);
@@ -135,6 +139,12 @@
135
139
  get result() {
136
140
  return (result ?? kitForm?.result) as TOutput | undefined;
137
141
  },
142
+ get remote() {
143
+ return kitForm as FormContext<TData, TOutput>['remote'];
144
+ },
145
+ get remoteFormId() {
146
+ return remoteFormId;
147
+ },
138
148
  setError,
139
149
  clearError,
140
150
  clearErrors,
@@ -143,19 +153,31 @@
143
153
  getData
144
154
  } satisfies FormContext<TData, TOutput>);
145
155
 
146
- $effect(() => {
147
- if (!kitForm || !syncRemoteIssues) return;
148
- errors = remoteIssuesToErrors(kitForm.fields.allIssues());
149
- });
156
+ if (kitForm && !didInitRemoteFormId) {
157
+ didInitRemoteFormId = true;
158
+ remoteFormId = untrack(() => getRemoteFormId(kitForm.action));
159
+ }
150
160
 
151
- $effect(() => {
152
- if (!kitForm) return;
153
- if (kitForm.result !== undefined) result = kitForm.result;
154
- });
161
+ function syncRemoteIssuesNow() {
162
+ if (!kitForm || !syncRemoteIssues) return;
163
+ const next = remoteIssuesToErrors(kitForm.fields.allIssues());
164
+ errors = next;
165
+ if (Object.keys(next).length > 0) submitted = true;
166
+ }
155
167
 
156
168
  function handleSubmit(e: SubmitEvent) {
157
169
  if (isRemote) {
158
170
  submitted = true;
171
+ queueMicrotask(() => {
172
+ const check = () => {
173
+ if (kitForm && kitForm.pending > 0) {
174
+ requestAnimationFrame(check);
175
+ return;
176
+ }
177
+ syncRemoteIssuesNow();
178
+ };
179
+ check();
180
+ });
159
181
  return;
160
182
  }
161
183
  e.preventDefault();
@@ -189,7 +211,11 @@
189
211
  <ul class="mt-1 list-disc space-y-0.5 pl-4 text-sm">
190
212
  {#each errorEntries as [field, message] (field)}
191
213
  <li>
192
- <span class="font-medium capitalize">{field}</span>: {message}
214
+ {#if field === '_form'}
215
+ {message}
216
+ {:else}
217
+ <span class="font-medium capitalize">{field}</span>: {message}
218
+ {/if}
193
219
  </li>
194
220
  {/each}
195
221
  </ul>
@@ -4,6 +4,8 @@
4
4
  import {
5
5
  getFormContext,
6
6
  resolveFormFieldState,
7
+ resolveRemoteInputProps,
8
+ parseRemoteFieldName,
7
9
  applyFormDataSync,
8
10
  type FormFieldStatus
9
11
  } from '../../../utils/formContext.js';
@@ -22,7 +24,18 @@
22
24
 
23
25
  /**
24
26
  * Form-bound text input (email, tel, url, search, …).
25
- * There is no separate `FormTextInput` — use this component.
27
+ *
28
+ * Kit remote: pass the encoded `name` (and `type`) from `.as()`, **not** the full
29
+ * spread — Kit’s `value` get/set fights `$bindable` and can loop.
30
+ *
31
+ * @example
32
+ * ```svelte
33
+ * <FormField
34
+ * name={login_user.fields.email.as('email').name}
35
+ * type="email"
36
+ * label="Email"
37
+ * />
38
+ * ```
26
39
  */
27
40
  interface FormFieldProps {
28
41
  id?: string;
@@ -41,19 +54,23 @@
41
54
  size?: 'sm' | 'md' | 'lg';
42
55
  /**
43
56
  * When true and inside `<Form>`, keep `value` in sync with `form.data[name]`.
44
- * Requires `name`.
57
+ * Uses the logical field name (strips Kit `/formId` when present).
45
58
  */
46
59
  bindData?: boolean;
47
- leadIcon?: Snippet;
48
- trailIcon?: Snippet;
49
60
  /**
50
- * Custom control instead of the default `<Input>`.
51
- * Receives resolved status / disabled / helpers from form context.
61
+ * Override HTML `name` if logical `name` is separate.
62
+ * Prefer passing Kit `.as(...).name` as `name` instead.
52
63
  */
64
+ inputName?: string;
65
+ leadIcon?: Snippet;
66
+ trailIcon?: Snippet;
53
67
  control?: Snippet<[FormFieldControlProps]>;
54
68
  class?: string;
55
69
  oninput?: (e: Event) => void;
56
70
  onchange?: (e: Event) => void;
71
+ /** Absorbed if a Kit `.as()` object is spread by mistake. */
72
+ defaultValue?: unknown;
73
+ 'aria-invalid'?: boolean | 'true' | 'false';
57
74
  }
58
75
 
59
76
  let {
@@ -72,23 +89,43 @@
72
89
  clearable = false,
73
90
  size = 'md',
74
91
  bindData = false,
92
+ inputName,
75
93
  leadIcon,
76
94
  trailIcon,
77
95
  control,
78
96
  class: className = '',
79
97
  oninput,
80
- onchange
98
+ onchange,
99
+ defaultValue: _kitDefaultValue = undefined,
100
+ 'aria-invalid': _ariaInvalid = undefined
81
101
  }: FormFieldProps = $props();
82
102
 
83
103
  const form = getFormContext();
104
+ const parsed = $derived(parseRemoteFieldName(name, form?.remoteFormId));
105
+ const logicalName = $derived(parsed.logicalName);
106
+
84
107
  const resolved = $derived(
85
- resolveFormFieldState({ name, errorMessage, helperText, status, disabled, form })
108
+ resolveFormFieldState({
109
+ name: logicalName,
110
+ errorMessage,
111
+ helperText,
112
+ status,
113
+ disabled,
114
+ form
115
+ })
86
116
  );
87
117
 
118
+ /** HTML `name`: override → encoded as-is → else encode with remoteFormId. */
119
+ const htmlName = $derived.by(() => {
120
+ if (inputName) return inputName;
121
+ if (parsed.isEncoded) return parsed.htmlName;
122
+ return resolveRemoteInputProps(form?.remoteFormId, name, type).name;
123
+ });
124
+
88
125
  $effect(() => {
89
- if (!bindData || !name || !form) return;
126
+ if (!bindData || !logicalName || !form) return;
90
127
  applyFormDataSync({
91
- fromCtx: form.data[name],
128
+ fromCtx: form.data[logicalName],
92
129
  getLocal: () => value,
93
130
  setLocal: (v) => {
94
131
  value = v;
@@ -99,20 +136,20 @@
99
136
 
100
137
  function setValue(next: string) {
101
138
  value = next;
102
- if (bindData && name && form) {
103
- form.setData(name, next);
104
- form.clearError(name);
139
+ if (bindData && logicalName && form) {
140
+ form.setData(logicalName, next);
141
+ form.clearError(logicalName);
105
142
  }
106
143
  }
107
144
 
108
145
  function clearFieldError() {
109
- if (name && form) form.clearError(name);
146
+ if (logicalName && form) form.clearError(logicalName);
110
147
  }
111
148
 
112
149
  function handleInput(e: Event) {
113
- if (bindData && name && form) {
114
- form.setData(name, (e.currentTarget as HTMLInputElement).value);
115
- form.clearError(name);
150
+ if (bindData && logicalName && form) {
151
+ form.setData(logicalName, (e.currentTarget as HTMLInputElement).value);
152
+ form.clearError(logicalName);
116
153
  }
117
154
  oninput?.(e);
118
155
  }
@@ -122,7 +159,7 @@
122
159
  {#if control}
123
160
  {@render control({
124
161
  id,
125
- name,
162
+ name: logicalName,
126
163
  value,
127
164
  status: resolved.status,
128
165
  helperText: resolved.helperText,
@@ -134,7 +171,7 @@
134
171
  {:else}
135
172
  <Input
136
173
  {id}
137
- {name}
174
+ name={htmlName}
138
175
  {label}
139
176
  {placeholder}
140
177
  {type}
@@ -13,7 +13,18 @@ export interface FormFieldControlProps {
13
13
  }
14
14
  /**
15
15
  * Form-bound text input (email, tel, url, search, …).
16
- * There is no separate `FormTextInput` — use this component.
16
+ *
17
+ * Kit remote: pass the encoded `name` (and `type`) from `.as()`, **not** the full
18
+ * spread — Kit’s `value` get/set fights `$bindable` and can loop.
19
+ *
20
+ * @example
21
+ * ```svelte
22
+ * <FormField
23
+ * name={login_user.fields.email.as('email').name}
24
+ * type="email"
25
+ * label="Email"
26
+ * />
27
+ * ```
17
28
  */
18
29
  interface FormFieldProps {
19
30
  id?: string;
@@ -32,19 +43,23 @@ interface FormFieldProps {
32
43
  size?: 'sm' | 'md' | 'lg';
33
44
  /**
34
45
  * When true and inside `<Form>`, keep `value` in sync with `form.data[name]`.
35
- * Requires `name`.
46
+ * Uses the logical field name (strips Kit `/formId` when present).
36
47
  */
37
48
  bindData?: boolean;
38
- leadIcon?: Snippet;
39
- trailIcon?: Snippet;
40
49
  /**
41
- * Custom control instead of the default `<Input>`.
42
- * Receives resolved status / disabled / helpers from form context.
50
+ * Override HTML `name` if logical `name` is separate.
51
+ * Prefer passing Kit `.as(...).name` as `name` instead.
43
52
  */
53
+ inputName?: string;
54
+ leadIcon?: Snippet;
55
+ trailIcon?: Snippet;
44
56
  control?: Snippet<[FormFieldControlProps]>;
45
57
  class?: string;
46
58
  oninput?: (e: Event) => void;
47
59
  onchange?: (e: Event) => void;
60
+ /** Absorbed if a Kit `.as()` object is spread by mistake. */
61
+ defaultValue?: unknown;
62
+ 'aria-invalid'?: boolean | 'true' | 'false';
48
63
  }
49
64
  declare const FormField: import("svelte").Component<FormFieldProps, {}, "value">;
50
65
  type FormField = ReturnType<typeof FormField>;
@@ -3,9 +3,15 @@
3
3
  import {
4
4
  getFormContext,
5
5
  resolveFormFieldState,
6
+ resolveRemoteInputProps,
7
+ parseRemoteFieldName,
6
8
  applyFormDataSync
7
9
  } from '../../../utils/formContext.js';
8
10
 
11
+ /**
12
+ * Kit remote: pass `.as('password').name` as `name`, not the full `.as()` spread
13
+ * (Kit’s `value` get/set fights local binding).
14
+ */
9
15
  interface FormPasswordInputProps {
10
16
  id?: string;
11
17
  name?: string;
@@ -18,11 +24,14 @@
18
24
  disabled?: boolean;
19
25
  required?: boolean;
20
26
  size?: 'sm' | 'md' | 'lg';
21
- /** Sync with `form.data[name]` when inside `<Form>`. */
22
27
  bindData?: boolean;
28
+ inputName?: string;
23
29
  class?: string;
24
30
  oninput?: (e: Event) => void;
25
31
  onchange?: (e: Event) => void;
32
+ defaultValue?: unknown;
33
+ type?: string;
34
+ 'aria-invalid'?: boolean | 'true' | 'false';
26
35
  }
27
36
 
28
37
  let {
@@ -38,20 +47,40 @@
38
47
  required = false,
39
48
  size = 'md',
40
49
  bindData = false,
50
+ inputName,
41
51
  class: className = '',
42
52
  oninput,
43
- onchange
53
+ onchange,
54
+ defaultValue: _kitDefaultValue = undefined,
55
+ type: _kitType = undefined,
56
+ 'aria-invalid': _ariaInvalid = undefined
44
57
  }: FormPasswordInputProps = $props();
45
58
 
46
59
  const form = getFormContext();
60
+ const parsed = $derived(parseRemoteFieldName(name, form?.remoteFormId));
61
+ const logicalName = $derived(parsed.logicalName);
62
+
47
63
  const resolved = $derived(
48
- resolveFormFieldState({ name, errorMessage, helperText, status, disabled, form })
64
+ resolveFormFieldState({
65
+ name: logicalName,
66
+ errorMessage,
67
+ helperText,
68
+ status,
69
+ disabled,
70
+ form
71
+ })
49
72
  );
50
73
 
74
+ const htmlName = $derived.by(() => {
75
+ if (inputName) return inputName;
76
+ if (parsed.isEncoded) return parsed.htmlName;
77
+ return resolveRemoteInputProps(form?.remoteFormId, name, 'password').name;
78
+ });
79
+
51
80
  $effect(() => {
52
- if (!bindData || !name || !form) return;
81
+ if (!bindData || !logicalName || !form) return;
53
82
  applyFormDataSync({
54
- fromCtx: form.data[name],
83
+ fromCtx: form.data[logicalName],
55
84
  getLocal: () => value,
56
85
  setLocal: (v) => {
57
86
  value = v;
@@ -61,9 +90,9 @@
61
90
  });
62
91
 
63
92
  function handleInput(e: Event) {
64
- if (bindData && name && form) {
65
- form.setData(name, (e.currentTarget as HTMLInputElement).value);
66
- form.clearError(name);
93
+ if (bindData && logicalName && form) {
94
+ form.setData(logicalName, (e.currentTarget as HTMLInputElement).value);
95
+ form.clearError(logicalName);
67
96
  }
68
97
  oninput?.(e);
69
98
  }
@@ -72,7 +101,7 @@
72
101
  <div class={['w-full', className]}>
73
102
  <PasswordInput
74
103
  {id}
75
- {name}
104
+ name={htmlName}
76
105
  {label}
77
106
  {placeholder}
78
107
  disabled={resolved.disabled}
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Kit remote: pass `.as('password').name` as `name`, not the full `.as()` spread
3
+ * (Kit’s `value` get/set fights local binding).
4
+ */
1
5
  interface FormPasswordInputProps {
2
6
  id?: string;
3
7
  name?: string;
@@ -10,11 +14,14 @@ interface FormPasswordInputProps {
10
14
  disabled?: boolean;
11
15
  required?: boolean;
12
16
  size?: 'sm' | 'md' | 'lg';
13
- /** Sync with `form.data[name]` when inside `<Form>`. */
14
17
  bindData?: boolean;
18
+ inputName?: string;
15
19
  class?: string;
16
20
  oninput?: (e: Event) => void;
17
21
  onchange?: (e: Event) => void;
22
+ defaultValue?: unknown;
23
+ type?: string;
24
+ 'aria-invalid'?: boolean | 'true' | 'false';
18
25
  }
19
26
  declare const FormPasswordInput: import("svelte").Component<FormPasswordInputProps, {}, "value">;
20
27
  type FormPasswordInput = ReturnType<typeof FormPasswordInput>;
@@ -82,7 +82,7 @@
82
82
  let value = $state<BlogDocument>({
83
83
  ...emptyBlogDocument('Shipping our component library'),
84
84
  description: 'A richer block model with media, layout, and registered components.',
85
- author: 'R2 Digisolutions',
85
+ author: 'Evoteg',
86
86
  publishedAt: '2026-07-28',
87
87
  tags: ['design-system', 'svelte', 'blog'],
88
88
  coverImage: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=1200&q=80',
@@ -525,14 +525,18 @@
525
525
  'focus-visible:ring-2 focus-visible:ring-brand-500/30 focus-visible:border-brand-500',
526
526
  isDragging
527
527
  ? 'border-brand-500 bg-brand-500/10'
528
- : hasFile
528
+ : hasFile || externalSrc
529
529
  ? 'border-transparent'
530
530
  : 'border-border bg-surface-elevated hover:border-border-strong',
531
531
  disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
532
532
  ]}
533
533
  >
534
- {#if primaryFile?.previewUrl}
535
- <img src={primaryFile.previewUrl} alt="" class="h-full w-full object-cover" />
534
+ {#if primaryFile?.previewUrl || externalSrc}
535
+ <img
536
+ src={primaryFile?.previewUrl || externalSrc}
537
+ alt=""
538
+ class="h-full w-full object-cover"
539
+ />
536
540
  {#if !disabled}
537
541
  <div
538
542
  class="absolute inset-0 flex items-center justify-center bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
@@ -52,7 +52,7 @@
52
52
  paidAt,
53
53
  status = 'sent',
54
54
  from = {
55
- name: 'R2 Digi Solutions',
55
+ name: 'Evoteg',
56
56
  email: 'billing@r2digi.com',
57
57
  addressLines: ['Calle Mayor 12', '28013 Madrid, Spain'],
58
58
  taxId: 'ES B12345678'
@@ -17,6 +17,10 @@
17
17
  label?: string;
18
18
  coverHelperText?: string;
19
19
  avatarHelperText?: string;
20
+ /** Existing remote cover URL (shown when no local File is selected). */
21
+ coverSrc?: string;
22
+ /** Existing remote avatar URL (shown when no local File is selected). */
23
+ avatarSrc?: string;
20
24
  accept?: string;
21
25
  maxCoverMb?: number;
22
26
  maxAvatarMb?: number;
@@ -30,6 +34,8 @@
30
34
  label = 'Profile media',
31
35
  coverHelperText = 'Cover · PNG or JPG · max 10MB',
32
36
  avatarHelperText = 'Avatar · square image recommended',
37
+ coverSrc = '',
38
+ avatarSrc = '',
33
39
  accept = 'image/*',
34
40
  maxCoverMb = 10,
35
41
  maxAvatarMb = 5,
@@ -52,6 +58,12 @@
52
58
  const coverInputId = $derived(`${id}-cover`);
53
59
  const avatarInputId = $derived(`${id}-avatar`);
54
60
 
61
+ const coverDisplayUrl = $derived(cover?.previewUrl || coverSrc || '');
62
+ const avatarDisplayUrl = $derived(avatar?.previewUrl || avatarSrc || '');
63
+ const hasCoverMedia = $derived(Boolean(coverDisplayUrl));
64
+ const hasAvatarMedia = $derived(Boolean(avatarDisplayUrl));
65
+ const hasLocalMedia = $derived(Boolean(cover || avatar));
66
+
55
67
  function revoke(media: ProfileMedia | null) {
56
68
  if (media?.previewUrl) URL.revokeObjectURL(media.previewUrl);
57
69
  }
@@ -171,7 +183,7 @@
171
183
  />
172
184
 
173
185
  <div
174
- class="relative overflow-hidden rounded-xl border border-border bg-surface-elevated shadow-sm"
186
+ class="relative rounded-xl border border-border bg-surface-elevated shadow-sm"
175
187
  aria-labelledby={label ? labelId : undefined}
176
188
  aria-describedby={[coverHelperText || avatarHelperText ? helperId : '', errorMessage ? errorId : '']
177
189
  .filter(Boolean)
@@ -182,7 +194,11 @@
182
194
  <div
183
195
  role="button"
184
196
  tabindex={disabled ? -1 : 0}
185
- aria-label={cover ? `Change cover photo, ${cover.name}` : 'Upload cover photo'}
197
+ aria-label={hasCoverMedia
198
+ ? cover
199
+ ? `Change cover photo, ${cover.name}`
200
+ : 'Change cover photo'
201
+ : 'Upload cover photo'}
186
202
  aria-disabled={disabled || undefined}
187
203
  ondragenter={(e) => {
188
204
  e.preventDefault();
@@ -200,14 +216,14 @@
200
216
  }}
201
217
  onclick={() => !disabled && coverInput?.click()}
202
218
  class={[
203
- 'group relative aspect-3/1 min-h-36 w-full overflow-hidden outline-none transition-colors',
219
+ 'group relative aspect-3/1 min-h-36 w-full overflow-hidden rounded-t-xl outline-none transition-colors',
204
220
  'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand-500/40',
205
221
  coverDragging ? 'bg-brand-500/10' : 'bg-surface-overlay',
206
222
  disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
207
223
  ]}
208
224
  >
209
- {#if cover}
210
- <img src={cover.previewUrl} alt="" class="h-full w-full object-cover" />
225
+ {#if hasCoverMedia}
226
+ <img src={coverDisplayUrl} alt="" class="h-full w-full object-cover" />
211
227
  {#if !disabled}
212
228
  <div
213
229
  class="absolute inset-0 flex items-start justify-end bg-linear-to-b from-black/35 via-transparent to-transparent p-3 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
@@ -267,7 +283,11 @@
267
283
  <div
268
284
  role="button"
269
285
  tabindex={disabled ? -1 : 0}
270
- aria-label={avatar ? `Change profile photo, ${avatar.name}` : 'Upload profile photo'}
286
+ aria-label={hasAvatarMedia
287
+ ? avatar
288
+ ? `Change profile photo, ${avatar.name}`
289
+ : 'Change profile photo'
290
+ : 'Upload profile photo'}
271
291
  aria-disabled={disabled || undefined}
272
292
  ondragenter={(e) => {
273
293
  e.preventDefault();
@@ -298,14 +318,14 @@
298
318
  if (!disabled) avatarInput?.click();
299
319
  }}
300
320
  class={[
301
- 'group relative h-20 w-20 shrink-0 overflow-hidden rounded-full border-4 border-surface-elevated bg-surface-overlay shadow-md outline-none transition sm:h-24 sm:w-24',
321
+ 'group relative z-10 h-20 w-20 shrink-0 overflow-hidden rounded-full border-4 border-surface-elevated bg-surface-overlay shadow-md outline-none transition sm:h-24 sm:w-24',
302
322
  'focus-visible:ring-2 focus-visible:ring-brand-500/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-elevated',
303
323
  avatarDragging ? 'ring-2 ring-brand-500' : '',
304
324
  disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
305
325
  ]}
306
326
  >
307
- {#if avatar}
308
- <img src={avatar.previewUrl} alt="" class="h-full w-full object-cover" />
327
+ {#if hasAvatarMedia}
328
+ <img src={avatarDisplayUrl} alt="" class="h-full w-full object-cover" />
309
329
  {#if !disabled}
310
330
  <div
311
331
  class="absolute inset-0 flex items-center justify-center bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
@@ -339,7 +359,7 @@
339
359
  </div>
340
360
 
341
361
  <div class="min-w-0 flex-1 pb-1">
342
- {#if avatar || cover}
362
+ {#if hasLocalMedia}
343
363
  <div class="flex flex-wrap items-center gap-2">
344
364
  {#if cover && !disabled}
345
365
  <button
@@ -7,6 +7,10 @@ interface ProfileHeaderUploaderProps {
7
7
  label?: string;
8
8
  coverHelperText?: string;
9
9
  avatarHelperText?: string;
10
+ /** Existing remote cover URL (shown when no local File is selected). */
11
+ coverSrc?: string;
12
+ /** Existing remote avatar URL (shown when no local File is selected). */
13
+ avatarSrc?: string;
10
14
  accept?: string;
11
15
  maxCoverMb?: number;
12
16
  maxAvatarMb?: number;