lutra 0.1.68 → 0.1.70

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 (84) hide show
  1. package/dist/components/AspectRatio.svelte +19 -9
  2. package/dist/components/AspectRatio.svelte.d.ts +2 -1
  3. package/dist/components/Avatar.svelte +5 -8
  4. package/dist/components/Close.svelte +24 -27
  5. package/dist/components/Close.svelte.d.ts +2 -0
  6. package/dist/components/ContextTip.svelte +3 -2
  7. package/dist/components/DataList.svelte +111 -0
  8. package/dist/components/DataList.svelte.d.ts +10 -0
  9. package/dist/components/DataListTypes.d.ts +14 -0
  10. package/dist/components/DataListTypes.js +1 -0
  11. package/dist/components/Dialog.svelte +38 -0
  12. package/dist/components/Icon.svelte +2 -2
  13. package/dist/components/IconButton.svelte +10 -22
  14. package/dist/components/Image.svelte +2 -2
  15. package/dist/components/Indicator.svelte +2 -1
  16. package/dist/components/Inset.svelte +13 -0
  17. package/dist/components/Layout.svelte +7 -3
  18. package/dist/components/Layout.svelte.d.ts +3 -2
  19. package/dist/components/MenuDropdown.svelte +12 -2
  20. package/dist/components/MenuItem.svelte +30 -14
  21. package/dist/components/MenuItem.svelte.d.ts +6 -0
  22. package/dist/components/Modal.svelte +36 -20
  23. package/dist/components/Popover.svelte +43 -13
  24. package/dist/components/TabbedContent.svelte +1 -1
  25. package/dist/components/TabbedContentItem.svelte +14 -0
  26. package/dist/components/TabbedContentItem.svelte.d.ts +4 -0
  27. package/dist/components/Table.svelte +69 -0
  28. package/dist/components/Table.svelte.d.ts +7 -0
  29. package/dist/components/Tabs.svelte +44 -36
  30. package/dist/components/Tag.svelte +53 -13
  31. package/dist/components/Tag.svelte.d.ts +4 -0
  32. package/dist/components/Theme.svelte +121 -94
  33. package/dist/components/Theme.svelte.d.ts +7 -6
  34. package/dist/components/Toast.svelte +11 -8
  35. package/dist/components/Tooltip.svelte +17 -10
  36. package/dist/components/index.d.ts +2 -0
  37. package/dist/components/index.js +2 -0
  38. package/dist/css/1-props.css +197 -163
  39. package/dist/css/2-init.css +519 -0
  40. package/dist/css/{2-base.css → 3-base.css} +42 -131
  41. package/dist/css/{3-typo.css → 4-typo.css} +3 -1
  42. package/dist/css/lutra.css +7 -6
  43. package/dist/css/themes/DefaultTheme.css +26 -4
  44. package/dist/form/Button.svelte +20 -0
  45. package/dist/form/Button.svelte.d.ts +9 -0
  46. package/dist/form/Datepicker.svelte +13 -0
  47. package/dist/form/Datepicker.svelte.d.ts +3 -0
  48. package/dist/form/FieldContent.svelte +20 -11
  49. package/dist/form/FieldError.svelte +1 -1
  50. package/dist/form/FieldGroup.svelte +84 -0
  51. package/dist/form/FieldGroup.svelte.d.ts +20 -0
  52. package/dist/form/Fieldset.svelte +19 -11
  53. package/dist/form/Form.svelte +137 -63
  54. package/dist/form/Form.svelte.d.ts +21 -0
  55. package/dist/form/FormActions.svelte +21 -3
  56. package/dist/form/FormActions.svelte.d.ts +3 -0
  57. package/dist/form/FormSection.svelte +22 -20
  58. package/dist/form/ImageUpload.svelte +50 -30
  59. package/dist/form/ImageUpload.svelte.d.ts +14 -0
  60. package/dist/form/Input.svelte +62 -30
  61. package/dist/form/Input.svelte.d.ts +0 -1
  62. package/dist/form/InputLength.svelte +5 -5
  63. package/dist/form/Label.svelte +6 -6
  64. package/dist/form/LogoUpload.svelte +24 -10
  65. package/dist/form/Select.svelte +23 -10
  66. package/dist/form/Select.svelte.d.ts +6 -6
  67. package/dist/form/Textarea.svelte +11 -1
  68. package/dist/form/Toggle.svelte +162 -0
  69. package/dist/form/Toggle.svelte.d.ts +31 -17
  70. package/dist/form/client.svelte.js +0 -2
  71. package/dist/form/index.d.ts +1 -0
  72. package/dist/form/index.js +1 -0
  73. package/dist/state/Persisted.svelte.d.ts +6 -0
  74. package/dist/state/Persisted.svelte.js +29 -0
  75. package/dist/state/theme.svelte.d.ts +7 -0
  76. package/dist/state/theme.svelte.js +14 -0
  77. package/dist/types.d.ts +6 -23
  78. package/dist/types.js +0 -17
  79. package/dist/util/color.js +2 -2
  80. package/package.json +5 -4
  81. package/dist/config.d.ts +0 -30
  82. package/dist/config.js +0 -18
  83. /package/dist/css/{4-layout.css → 5-layout.css} +0 -0
  84. /package/dist/css/{5-media.css → 6-media.css} +0 -0
@@ -12,6 +12,30 @@
12
12
  import { getIndividualValidators, parseFormIssues } from "./form.js";
13
13
  import { useForm } from "./client.svelte.js";
14
14
 
15
+ /**
16
+ * @description
17
+ * The root form component. Wraps SvelteKit's `enhance` for progressive enhancement,
18
+ * integrates with Zod-based validation via Bodyguard, and provides layout token
19
+ * overrides for all child form sections, fields and actions.
20
+ *
21
+ * @cssprop --form-action-gap -- Gap between action buttons.
22
+ * @cssprop --form-field-gap -- Gap between fields in a section.
23
+ * @cssprop --form-label-gap -- Gap between label and input.
24
+ * @cssprop --form-title-gap -- Gap within a section title.
25
+ * @cssprop --form-section-gap -- Gap between sections.
26
+ * @cssprop --form-padding-block -- Block padding for contained sections.
27
+ * @cssprop --form-padding-inline -- Inline padding for contained sections.
28
+ *
29
+ * @example
30
+ * <Form form={data.form} action="?/save">
31
+ * <FormSection title="Profile">
32
+ * <Input name="name" label="Name" />
33
+ * </FormSection>
34
+ * <FormActions>
35
+ * <Button type="submit">Save</Button>
36
+ * </FormActions>
37
+ * </Form>
38
+ */
15
39
  let {
16
40
  name = 'form',
17
41
  form: _form,
@@ -35,15 +59,25 @@
35
59
  paddingBlock,
36
60
  paddingInline,
37
61
  }: {
62
+ /** The key under which the form data is returned in the action result. */
38
63
  name?: string;
64
+ /** The Lutra form object from the server load function. */
39
65
  form?: LutraForm<any>;
66
+ /** Bindable reference to the underlying `<form>` element. */
40
67
  formEl?: HTMLFormElement | null;
68
+ /** The form action URL. */
41
69
  action?: string;
70
+ /** The form encoding type. */
42
71
  enctype?: "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain";
72
+ /** The HTTP method. */
43
73
  method?: "GET" | "POST";
74
+ /** Callback invoked before the form is submitted. Can cancel submission. */
44
75
  beforesubmit?: BeforeSubmitFn;
76
+ /** Whether the form should span the full width. */
45
77
  fullWidth?: boolean;
78
+ /** Whether to reset fields after a successful update. */
46
79
  resetOnUpdate?: boolean;
80
+ /** Callback invoked with the action result after submission. */
47
81
  onresult?: (args: {
48
82
  formData: FormData;
49
83
  formElement: HTMLFormElement;
@@ -54,16 +88,27 @@
54
88
  invalidateAll?: boolean | undefined;
55
89
  } | undefined) => void;
56
90
  }) => void;
91
+ /** Form content. */
57
92
  children: Snippet;
93
+ /** Whether the form sections should be contained (bordered). */
58
94
  contained?: boolean;
95
+ /** General spacing override. */
59
96
  spacing?: string;
97
+ /** Override for `--form-action-gap`. */
60
98
  actionGap?: string;
99
+ /** Override for `--form-field-gap`. */
61
100
  fieldGap?: string;
101
+ /** Override for `--form-label-gap`. */
62
102
  labelGap?: string;
103
+ /** Override for `--form-title-gap`. */
63
104
  titleGap?: string;
105
+ /** Override for `--form-section-gap`. */
64
106
  sectionGap?: string;
107
+ /** Shorthand override for both `--form-padding-block` and `--form-padding-inline`. */
65
108
  padding?: string;
109
+ /** Override for `--form-padding-block`. */
66
110
  paddingBlock?: string;
111
+ /** Override for `--form-padding-inline`. */
67
112
  paddingInline?: string;
68
113
  } = $props();
69
114
 
@@ -85,7 +130,6 @@
85
130
  const bodyguard = new Bodyguard();
86
131
 
87
132
  function setFormIssuesAndFields(issues: any, fields: any) {
88
- console.log('setFormIssuesAndFields', issues, fields)
89
133
  if(form) {
90
134
  form.issues = issues;
91
135
  form.fields = fields;
@@ -126,6 +170,7 @@
126
170
  };
127
171
 
128
172
  const style = `
173
+ --fcc: ${contained ? 1 : 0};
129
174
  ${actionGap ? `--form-action-gap: ${actionGap};` : ''}
130
175
  ${fieldGap ? `--form-field-gap: ${fieldGap};` : ''}
131
176
  ${labelGap ? `--form-label-gap: ${labelGap};` : ''}
@@ -146,75 +191,104 @@
146
191
  </script>
147
192
 
148
193
  <UiContent>
149
- <form
150
- {method}
151
- {action}
152
- {enctype}
153
- style={style}
154
- bind:this={formEl}
155
- onchange={validate}
156
- use:enhance={async ({ formElement, formData, action, cancel, submitter }) => {
157
- // `formElement` is this `<form>` element
158
- // `formData` is its `FormData` object that's about to be submitted
159
- // `action` is the URL to which the form is posted
160
- // calling `cancel()` will prevent the submission
161
- // `submitter` is the `HTMLElement` that caused the form to be submitted
162
- if(form) form.state = 'loading';
163
- //await Promise.resolve(beforesubmit(form));
164
- if(beforesubmit) await Promise.resolve(beforesubmit({ form: formElement, data: formData, cancel: () => {
165
- if(form) form.state = 'error';
166
- cancel();
167
- }}));
168
- for(const { id, fn } of beforesubmitFunctions) {
169
- await Promise.resolve(fn({ form: formElement, data: formData, cancel: () => {
194
+ <div class="FormContainer">
195
+ <form
196
+ {method}
197
+ {action}
198
+ {enctype}
199
+ style={style}
200
+ bind:this={formEl}
201
+ onchange={validate}
202
+ use:enhance={async ({ formElement, formData, action, cancel, submitter }) => {
203
+ // `formElement` is this `<form>` element
204
+ // `formData` is its `FormData` object that's about to be submitted
205
+ // `action` is the URL to which the form is posted
206
+ // calling `cancel()` will prevent the submission
207
+ // `submitter` is the `HTMLElement` that caused the form to be submitted
208
+ if(form) form.state = 'loading';
209
+ //await Promise.resolve(beforesubmit(form));
210
+ if(beforesubmit) await Promise.resolve(beforesubmit({ form: formElement, data: formData, cancel: () => {
170
211
  if(form) form.state = 'error';
171
212
  cancel();
172
213
  }}));
173
- }
174
- console.log('form state', form?.state);
175
- return async (opts) => {
176
- const { result, update } = opts;
177
- // `result` is an `ActionResult` object
178
- // `update` is a function which triggers the default logic that would be triggered if this callback wasn't set
179
- console.log('form result', opts);
180
- if(onresult) {
181
- console.log('calling onresult', opts);
182
- onresult(opts);
214
+ for(const { id, fn } of beforesubmitFunctions) {
215
+ await Promise.resolve(fn({ form: formElement, data: formData, cancel: () => {
216
+ if(form) form.state = 'error';
217
+ cancel();
218
+ }}));
183
219
  }
184
- const resultForm = result.type !== "redirect" && result.type !== "error" ? result?.data![name] : null;
185
- console.log('resultForm', resultForm, form);
186
- if(result.type === "success") {
187
- if(resultForm && form) {
188
- form.valid = Object.assign({ valid: false }, resultForm).valid ?? false;
220
+ return async (opts) => {
221
+ const { result, update } = opts;
222
+ if(onresult) {
223
+ onresult(opts);
189
224
  }
190
- if(form) form.state = 'success';
191
- console.log('form state', form?.state, resetOnUpdate, opts);
192
- update({ reset: !!resetOnUpdate });
193
- } else if(result.type === "failure") {
194
- console.log('FAILURE', opts);
195
- if(resultForm && form) {
196
- console.log('setting form issues and fields')
197
- setFormIssuesAndFields(
198
- Object.assign({ issues: [] }, resultForm).issues, // Have to assign to avoid type error as we cant use `as` here
199
- Object.assign({ fields: [] }, resultForm).fields,
200
- );
225
+ const resultForm = result.type !== "redirect" && result.type !== "error" ? result?.data![name] : null;
226
+ if(result.type === "success") {
227
+ if(resultForm && form) {
228
+ form.valid = Object.assign({ valid: false }, resultForm).valid ?? false;
229
+ }
230
+ if(form) form.state = 'success';
231
+ update({ reset: !!resetOnUpdate });
232
+ } else if(result.type === "failure") {
233
+ if(resultForm && form) {
234
+ setFormIssuesAndFields(
235
+ Object.assign({ issues: [] }, resultForm).issues, // Have to assign to avoid type error as we cant use `as` here
236
+ Object.assign({ fields: [] }, resultForm).fields,
237
+ );
238
+ }
239
+ if(form) form.state = 'error';
240
+ } else if(result.type === "error") {
241
+ console.error('[lutra] Error from form enhance call', result.error, opts);
242
+ if(form) form.state = 'error';
243
+ } else if(result.type === "redirect") {
244
+ if(form) form.state = 'success';
245
+ await goto(result.location);
201
246
  }
202
- if(form) form.state = 'error';
203
- } else if(result.type === "error") {
204
- console.error('[lutra] Error from form enhance call', result.error, opts);
205
- if(form) form.state = 'error';
206
- } else if(result.type === "redirect") {
207
- console.log('redirect', opts);
208
- //window.location.href = result.location;
209
- if(form) form.state = 'success';
210
- await goto(result.location);
211
- }
212
- };
213
- }}
214
- >
215
- {@render children()}
216
- </form>
247
+ };
248
+ }}
249
+ >
250
+ {@render children()}
251
+ </form>
252
+ </div>
217
253
  </UiContent>
218
254
 
219
255
  <style>
256
+ .FormContainer {
257
+ container-type: inline-size;
258
+ }
259
+ form {
260
+ display: grid;
261
+ grid-template-columns: fit-content(20%) 1fr;
262
+ border: calc(var(--fcc) * var(--form-border-size)) var(--form-border-style) var(--form-border-color);
263
+ border-radius: calc(var(--fcc) * var(--form-border-radius));
264
+ --form-padding-block: var(--space-md);
265
+ --form-padding-inline: var(--space-md);
266
+ --form-section-gap: var(--space-md);
267
+ }
268
+ @container (min-width: 600px) {
269
+ form {
270
+ --form-padding-block: var(--space-lg);
271
+ --form-padding-inline: var(--space-lg);
272
+ --form-section-gap: var(--space-xl);
273
+ }
274
+ }
275
+ @container (min-width: 1000px) {
276
+ form {
277
+ --form-padding-block: var(--space-xl);
278
+ --form-padding-inline: var(--space-xl);
279
+ --form-section-gap: var(--space-xxl);
280
+ }
281
+ }
282
+ @container (min-width: 1400px) {
283
+ form {
284
+ --form-padding-block: var(--space-xxl);
285
+ --form-padding-inline: var(--space-xxl);
286
+ --form-section-gap: var(--space-xxxl);
287
+ }
288
+ }
289
+ @container (max-width: 800px) {
290
+ form {
291
+ grid-template-columns: 1fr;
292
+ }
293
+ }
220
294
  </style>
@@ -2,15 +2,25 @@ import type { ActionResult } from "@sveltejs/kit";
2
2
  import type { Snippet } from "svelte";
3
3
  import type { BeforeSubmitFn, LutraForm } from "./types.js";
4
4
  type $$ComponentProps = {
5
+ /** The key under which the form data is returned in the action result. */
5
6
  name?: string;
7
+ /** The Lutra form object from the server load function. */
6
8
  form?: LutraForm<any>;
9
+ /** Bindable reference to the underlying `<form>` element. */
7
10
  formEl?: HTMLFormElement | null;
11
+ /** The form action URL. */
8
12
  action?: string;
13
+ /** The form encoding type. */
9
14
  enctype?: "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain";
15
+ /** The HTTP method. */
10
16
  method?: "GET" | "POST";
17
+ /** Callback invoked before the form is submitted. Can cancel submission. */
11
18
  beforesubmit?: BeforeSubmitFn;
19
+ /** Whether the form should span the full width. */
12
20
  fullWidth?: boolean;
21
+ /** Whether to reset fields after a successful update. */
13
22
  resetOnUpdate?: boolean;
23
+ /** Callback invoked with the action result after submission. */
14
24
  onresult?: (args: {
15
25
  formData: FormData;
16
26
  formElement: HTMLFormElement;
@@ -21,16 +31,27 @@ type $$ComponentProps = {
21
31
  invalidateAll?: boolean | undefined;
22
32
  } | undefined) => void;
23
33
  }) => void;
34
+ /** Form content. */
24
35
  children: Snippet;
36
+ /** Whether the form sections should be contained (bordered). */
25
37
  contained?: boolean;
38
+ /** General spacing override. */
26
39
  spacing?: string;
40
+ /** Override for `--form-action-gap`. */
27
41
  actionGap?: string;
42
+ /** Override for `--form-field-gap`. */
28
43
  fieldGap?: string;
44
+ /** Override for `--form-label-gap`. */
29
45
  labelGap?: string;
46
+ /** Override for `--form-title-gap`. */
30
47
  titleGap?: string;
48
+ /** Override for `--form-section-gap`. */
31
49
  sectionGap?: string;
50
+ /** Shorthand override for both `--form-padding-block` and `--form-padding-inline`. */
32
51
  padding?: string;
52
+ /** Override for `--form-padding-block`. */
33
53
  paddingBlock?: string;
54
+ /** Override for `--form-padding-inline`. */
34
55
  paddingInline?: string;
35
56
  };
36
57
  declare const Form: import("svelte").Component<$$ComponentProps, {}, "formEl">;
@@ -2,20 +2,38 @@
2
2
  import StringOrSnippet from "../util/StringOrSnippet.svelte";
3
3
  import { setContext, type Snippet } from "svelte";
4
4
 
5
+ /**
6
+ * @description
7
+ * Action bar for form buttons. Renders at the bottom of a form with configurable
8
+ * alignment. When the form is contained, inherits background and padding from
9
+ * `--form-background-actions` via the `--fcc` multiplier pattern.
10
+ *
11
+ * @example
12
+ * <FormActions>
13
+ * <Button type="submit">Save</Button>
14
+ * </FormActions>
15
+ * <FormActions align="justified" info="Changes are saved automatically.">
16
+ * <Button type="submit">Save</Button>
17
+ * <Button kind="secondary" href="/cancel">Cancel</Button>
18
+ * </FormActions>
19
+ */
5
20
  let {
6
21
  align = 'end',
7
22
  children,
8
23
  info,
9
24
  }: {
25
+ /** Alignment of the action buttons. */
10
26
  align?: 'justified' | 'start' | 'center' | 'end' | 'full';
27
+ /** Action buttons and other content. */
11
28
  children: Snippet;
29
+ /** Optional info text displayed alongside the action buttons. */
12
30
  info?: string | Snippet;
13
31
  } = $props();
14
32
 
15
33
  setContext('form.actions.align', align);
16
34
  </script>
17
35
 
18
- <div class="FormActions {align}" class:hasInfo={!!info}>
36
+ <div class="FormActions {align}">
19
37
  {#if info}
20
38
  <div class="Info">
21
39
  <StringOrSnippet content={info} />
@@ -29,7 +47,7 @@
29
47
  <style>
30
48
  .FormActions {
31
49
  display: grid;
32
- background: color-mix(in srgb, var(--form-background-actions) calc(var(--fcc) * 100%), transparent);
50
+ background: color-mix(in oklch, var(--form-background-actions) calc(var(--fcc) * 100%), transparent);
33
51
  padding: calc(var(--space-md) * var(--fcc)) calc(var(--space-xl) * var(--fcc));
34
52
  grid-column: 1 / -1;
35
53
  grid-template-columns: subgrid;
@@ -57,7 +75,7 @@
57
75
  justify-content: end;
58
76
  grid-column: 1 / -1;
59
77
  }
60
- .FormActions.hasInfo .Actions {
78
+ .FormActions:has(.Info) .Actions {
61
79
  grid-column: 2 / -1;
62
80
  }
63
81
  .FormActions.full .Actions {
@@ -1,7 +1,10 @@
1
1
  import { type Snippet } from "svelte";
2
2
  type $$ComponentProps = {
3
+ /** Alignment of the action buttons. */
3
4
  align?: 'justified' | 'start' | 'center' | 'end' | 'full';
5
+ /** Action buttons and other content. */
4
6
  children: Snippet;
7
+ /** Optional info text displayed alongside the action buttons. */
5
8
  info?: string | Snippet;
6
9
  };
7
10
  declare const FormActions: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -3,7 +3,21 @@
3
3
 
4
4
  /**
5
5
  * @description
6
- * A field section is a group of related form elements.
6
+ * A section within a form, grouping related fields under an optional title and
7
+ * description. Uses CSS subgrid for alignment with the parent form layout and
8
+ * responds to `@container` queries to collapse into a single column at narrow widths.
9
+ *
10
+ * @cssprop --form-section-gap -- Gap between the title and content areas.
11
+ * @cssprop --form-title-gap -- Gap within the section title (title + description).
12
+ * @cssprop --form-field-gap -- Gap between fields in the content area.
13
+ * @cssprop --form-padding-block -- Block padding when the form is contained.
14
+ * @cssprop --form-padding-inline -- Inline padding when the form is contained.
15
+ *
16
+ * @example
17
+ * <FormSection title="Personal Information" description="Enter your details.">
18
+ * <Input name="name" label="Name" />
19
+ * <Input name="email" type="email" label="Email" />
20
+ * </FormSection>
7
21
  */
8
22
  let {
9
23
  title,
@@ -53,12 +67,11 @@
53
67
  .FormSectionTitle {
54
68
  display: flex;
55
69
  flex-direction: column;
56
- background-color: var(--base);
57
70
  gap: var(--form-title-gap, var(--space-md));
58
71
  text-wrap: balance;
59
72
  }
60
73
  .FormSection:not(:first-child) {
61
- border-top: calc(var(--fcc) * var(--border-size)) var(--border-style) var(--border-color);
74
+ border-top: calc(var(--fcc) * var(--form-border-size)) var(--form-border-style) var(--form-border-color);
62
75
  }
63
76
  .FormSectionContent {
64
77
  display: grid;
@@ -67,27 +80,16 @@
67
80
  .FormSection.noTitle .FormSectionContent {
68
81
  grid-column: 1 / -1;
69
82
  }
70
- @media(max-width: 1280px) {
71
- .FormSection {
72
- padding: var(--space-xl);
73
- gap: var(--space-md);
74
- }
75
- .FormSectionTitle {
76
- gap: var(--space-xs);
77
- padding-block-end: var(--space-md);
78
- }
79
- }
80
- @media(max-width: 640px) {
83
+ @container (max-width: 800px) {
81
84
  .FormSection {
82
- padding: var(--space-xl);
83
- gap: var(--space-md);
85
+ padding-block: calc(var(--fcc) * var(--form-padding-block));
86
+ padding-inline: calc(var(--fcc) * var(--form-padding-inline));
87
+ gap: var(--form-section-gap, var(--space-md));
84
88
  }
85
89
  .FormSectionTitle {
86
- gap: var(--space-xs);
87
- padding-block-end: var(--space-md);
90
+ gap: var(--form-title-gap);
91
+ padding-block-end: var(--form-title-padding-block-end);
88
92
  }
89
- }
90
- @container (max-width: 800px) {
91
93
  .FormSectionTitle,
92
94
  .FormSectionContent {
93
95
  grid-column: 1 / -1;
@@ -9,6 +9,23 @@
9
9
  const imageCompression: ImageCompressionFn = (imageCompressionModule as unknown as { default?: ImageCompressionFn }).default ?? imageCompressionModule as unknown as ImageCompressionFn;
10
10
  import { addToast } from "../components/toasts.svelte.js";
11
11
 
12
+ /**
13
+ * @description
14
+ * An image upload component with client-side compression, progress indicator and
15
+ * optional remove button. Supports square, circle and banner display shapes.
16
+ * Uploads to a presigned URL via `PUT`.
17
+ *
18
+ * @cssprop --max-image-width -- Maximum width of the image preview (used in the grid layout).
19
+ *
20
+ * @example
21
+ * <ImageUpload
22
+ * uploadUrl="/api/upload"
23
+ * name="avatar"
24
+ * button="Upload"
25
+ * shape="circle"
26
+ * src={user.avatarUrl}
27
+ * />
28
+ */
12
29
  let {
13
30
  src,
14
31
  uploadUrl,
@@ -25,19 +42,33 @@
25
42
  onFile,
26
43
  children,
27
44
  }: {
45
+ /** Current image source URL. */
28
46
  src?: string | null;
47
+ /** The presigned URL to upload the image to. */
29
48
  uploadUrl: string;
49
+ /** The form field name for the resulting URL value. */
30
50
  name: string;
51
+ /** HTML id for the file input. */
31
52
  id?: string;
53
+ /** Title used in the alt text of the preview image. */
32
54
  title?: string;
55
+ /** Label for the upload button. */
33
56
  button: string;
57
+ /** Maximum file size in bytes before compression is applied. */
34
58
  maxSize?: number;
59
+ /** Maximum compressed file size in megabytes. */
35
60
  compressSize?: number;
61
+ /** Maximum compressed image width in pixels. */
36
62
  compressMaxWidth?: number;
63
+ /** Accepted MIME types for the file input. */
37
64
  accept?: string;
65
+ /** Whether a remove button is shown. */
38
66
  removable?: boolean;
67
+ /** Display shape of the image preview. */
39
68
  shape?: 'square' | 'circle' | 'banner';
69
+ /** Callback invoked with the selected file before upload. */
40
70
  onFile?: (file: File) => void;
71
+ /** Optional additional content rendered below the actions. */
41
72
  children?: Snippet;
42
73
  } = $props();
43
74
 
@@ -63,8 +94,6 @@
63
94
  useWebWorker: true,
64
95
  onProgress: (progress: number) => resizeProgress = progress,
65
96
  });
66
- console.log('compressedFile instanceof Blob', compressedFile instanceof Blob); // true
67
- console.log(`compressedFile size ${compressedFile.size / 1024 / 1024} MB`); // smaller than maxSizeMB
68
97
  uploadState = 'uploading';
69
98
 
70
99
  const response = await fetch(uploadUrl, {
@@ -79,15 +108,10 @@
79
108
  addToast({ content: 'Something went wrong. Please try again.', autoClose: 3000 })
80
109
  cancel();
81
110
  }
82
- //src = new URL(response.url).href.split('?')[0];
83
- console.log('setting', name, new URL(response.url).href.split('?')[0]);
84
111
  const imgUrl = new URL(response.url).href.split('?')[0];
85
112
  data.set(name, imgUrl);
86
113
  src = imgUrl;
87
- console.log('response', response, response.url);
88
- console.log('data', data.forEach((v, k) => console.log(k, v)));
89
114
  } catch(err) {
90
- console.error('error', err);
91
115
  addToast({ content: 'Something went wrong. Please try again.', autoClose: 3000 })
92
116
  cancel();
93
117
  }
@@ -141,7 +165,7 @@
141
165
  </script>
142
166
 
143
167
  <div class="ImageUpload {shape}">
144
- <div class="Image" class:hasSrc={!!src}>
168
+ <div class="Image">
145
169
  {#if src}
146
170
  <img src={src} alt="{title} logo" />
147
171
  {/if}
@@ -194,16 +218,16 @@
194
218
  display: grid;
195
219
  grid-template-columns: 1fr;
196
220
  align-items: center;
197
- gap: 1rem;
221
+ gap: var(--space-md);
198
222
  container-type: inline-size;
199
223
  }
200
224
  .Image {
201
- border: 1px solid var(--border-light);
225
+ border: var(--border-size-thin) solid var(--border-color-subtle);
202
226
  pointer-events: none;
203
227
  position: relative;
204
228
  display: none;
205
229
  }
206
- .Image.hasSrc {
230
+ .Image:has(img) {
207
231
  display: block;
208
232
  }
209
233
  .Image img {
@@ -229,32 +253,29 @@
229
253
  height: 100%;
230
254
  width: 100%;
231
255
  object-fit: contain;
232
- padding: 1rem;
256
+ padding: var(--space-md);
233
257
  text-align: center;
234
- border: 1px dashed var(--border-color);
235
- border-radius: var(--border-radius);
258
+ border: var(--border-size-thin) dashed var(--border-color);
259
+ border-radius: var(--border-radius-base);
236
260
  }
237
261
  .Loading {
238
262
  position: absolute;
239
- top: 0;
240
- left: 0;
241
- right: 0;
242
- bottom: 0;
263
+ inset: 0;
243
264
  z-index: 5;
244
- backdrop-filter: blur(5px);
245
- background: var(--bg-overlay);
265
+ backdrop-filter: var(--scrim-backdrop-filter);
266
+ background: var(--scrim-background);
246
267
  display: flex;
247
268
  align-items: center;
248
269
  justify-content: center;
249
270
  flex-direction: column;
250
- gap: 1rem;
251
- padding: 1rem;
271
+ gap: var(--space-md);
272
+ padding: var(--space-md);
252
273
  }
253
274
  input {
254
275
  display: none;
255
276
  }
256
277
  .error {
257
- margin-top: 0.75rem;
278
+ margin-block-start: var(--space-sm);
258
279
  }
259
280
  .ImageUpload.banner {
260
281
  grid-template-areas: "image";
@@ -266,7 +287,6 @@
266
287
  overflow: hidden;
267
288
  object-fit: cover;
268
289
  height: 15rem;
269
- aspect-ratio: initial !important;
270
290
  }
271
291
  .ImageUpload.banner .Image img {
272
292
  object-fit: cover;
@@ -276,28 +296,28 @@
276
296
  grid-area: image;
277
297
  flex-direction: column;
278
298
  justify-content: center;
279
- padding: 1rem;
299
+ padding: var(--space-md);
280
300
  }
281
301
  .ImageUpload.banner:hover .ImageUploadActions {
282
- background: var(--bg-overlay);
302
+ background: var(--scrim-background);
283
303
  }
284
304
  .ImageUploadActions {
285
305
  display: grid;
286
306
  grid-template-columns: 1fr 1fr;
287
- gap: 0.75rem;
307
+ gap: var(--space-sm);
288
308
  align-items: start;
289
309
  }
290
310
  @container (min-width: 320px) {
291
311
  .ImageUpload:not(.banner) {
292
312
  grid-template-columns: minmax(auto, var(--max-image-width)) 1fr;
293
- gap: 2rem;
313
+ gap: var(--space-xl);
294
314
  }
295
315
  .ImageUpload.banner .ImageUploadActions {
296
- padding: 3rem;
316
+ padding: var(--space-xxl);
297
317
  }
298
318
  .ImageUploadActions {
299
319
  grid-template-columns: 1fr;
300
- gap: 1rem;
320
+ gap: var(--space-md);
301
321
  }
302
322
  }
303
323
  </style>