lutra 0.1.70 → 0.1.74

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.
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import type { Component, Snippet } from "svelte";
3
+ import Icon from "./Icon.svelte";
4
+
5
+ /**
6
+ * @description
7
+ * A styled callout/banner for inline notices and announcements.
8
+ * Renders children as content with an optional leading icon.
9
+ * Visual appearance is customisable via CSS custom properties.
10
+ *
11
+ * @cssprop --callout-background - Background color of the callout. (Default: var(--theme-surface-subtle))
12
+ * @cssprop --callout-text-color - Text color inside the callout. (Default: var(--text-color-p))
13
+ * @cssprop --callout-link-color - Color of links inside the callout. (Default: var(--link-color))
14
+ * @cssprop --callout-border-radius - Border radius of the callout. Overridden by the shape prop. (Default: var(--border-radius-sm))
15
+ *
16
+ * @example
17
+ * <Callout icon={InfoIcon}>
18
+ * Please <a href="/upgrade">upgrade</a> to the new version.
19
+ * </Callout>
20
+ */
21
+ let {
22
+ icon,
23
+ shape = "rounded",
24
+ scale = "md",
25
+ children,
26
+ }: {
27
+ /** Icon component or string to render before the content. */
28
+ icon?: string | Component;
29
+ /** The shape of the callout. */
30
+ shape?: "rounded" | "pill" | "rectangle";
31
+ /** Size variant controlling font-size. Padding and gap scale accordingly. */
32
+ scale?: "xs" | "sm" | "md" | "lg" | "xl";
33
+ /** Callout content. */
34
+ children: Snippet;
35
+ } = $props();
36
+ </script>
37
+
38
+ <div class="Callout {shape} {scale}" role="note">
39
+ {#if icon}
40
+ <Icon {icon} />
41
+ {/if}
42
+ <div class="Content">
43
+ {@render children()}
44
+ </div>
45
+ </div>
46
+
47
+ <style>
48
+ .Callout {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: var(--space-sm);
52
+ padding-block: var(--space-sm);
53
+ padding-inline: var(--space-md);
54
+ border-radius: var(--callout-border-radius, var(--border-radius-sm));
55
+ background: var(--callout-background, var(--theme-surface-subtle));
56
+ color: var(--callout-text-color, var(--text-color-p));
57
+ line-height: var(--font-line-height);
58
+ }
59
+
60
+ .Callout.pill {
61
+ border-radius: calc(infinity * 1px);
62
+ }
63
+
64
+ .Callout.rectangle {
65
+ border-radius: 0;
66
+ }
67
+
68
+ .Callout :global(a) {
69
+ color: var(--callout-link-color, var(--link-color));
70
+ }
71
+
72
+ .Callout :global(a:hover) {
73
+ color: var(--callout-link-color, var(--link-color-hover));
74
+ }
75
+
76
+ .Content {
77
+ flex: 1;
78
+ min-width: 0;
79
+ }
80
+
81
+ .Callout.xs { font-size: var(--font-size-xs); padding: var(--space-xxs) var(--space-xs); gap: var(--space-xxs); }
82
+ .Callout.sm { font-size: var(--font-size-sm); padding: var(--space-xs) var(--space-sm); gap: var(--space-xs); }
83
+ .Callout.lg { font-size: var(--font-size-h5); padding: var(--space-md) var(--space-lg); gap: var(--space-md); }
84
+ .Callout.xl { font-size: var(--font-size-h4); padding: var(--space-lg) var(--space-xl); gap: var(--space-md); }
85
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { Component, Snippet } from "svelte";
2
+ type $$ComponentProps = {
3
+ /** Icon component or string to render before the content. */
4
+ icon?: string | Component;
5
+ /** The shape of the callout. */
6
+ shape?: "rounded" | "pill" | "rectangle";
7
+ /** Size variant controlling font-size. Padding and gap scale accordingly. */
8
+ scale?: "xs" | "sm" | "md" | "lg" | "xl";
9
+ /** Callout content. */
10
+ children: Snippet;
11
+ };
12
+ declare const Callout: Component<$$ComponentProps, {}, "">;
13
+ type Callout = ReturnType<typeof Callout>;
14
+ export default Callout;
@@ -23,7 +23,8 @@
23
23
  disabled,
24
24
  children,
25
25
  onclick,
26
- mask = true
26
+ mask = true,
27
+ scale = 'md'
27
28
  }: {
28
29
  /** The icon to display. */
29
30
  icon: string | Component;
@@ -34,7 +35,9 @@
34
35
  /** Whether the button is disabled. */
35
36
  disabled?: boolean,
36
37
  /** Whether to mask the content. */
37
- mask?: boolean
38
+ mask?: boolean,
39
+ /** Size variant controlling font-size. Icon and padding scale automatically. */
40
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
38
41
  } = $props();
39
42
 
40
43
  </script>
@@ -55,11 +58,11 @@
55
58
  {/snippet}
56
59
 
57
60
  {#if onclick}
58
- <button type="button" {disabled} class="IconButton" {onclick}>
61
+ <button type="button" {disabled} class="IconButton {scale}" {onclick}>
59
62
  {@render inside()}
60
63
  </button>
61
64
  {:else}
62
- <span class="IconButton">
65
+ <span class="IconButton {scale}">
63
66
  {@render inside()}
64
67
  </span>
65
68
  {/if}
@@ -105,4 +108,9 @@
105
108
  gap: var(--space-xs);
106
109
  align-items: center;
107
110
  }
111
+
112
+ .IconButton.xs { font-size: var(--font-size-xs); }
113
+ .IconButton.sm { font-size: var(--font-size-sm); }
114
+ .IconButton.lg { font-size: var(--font-size-h5); }
115
+ .IconButton.xl { font-size: var(--font-size-h4); }
108
116
  </style>
@@ -10,6 +10,8 @@ type $$ComponentProps = {
10
10
  disabled?: boolean;
11
11
  /** Whether to mask the content. */
12
12
  mask?: boolean;
13
+ /** Size variant controlling font-size. Icon and padding scale automatically. */
14
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
13
15
  };
14
16
  declare const IconButton: Component<$$ComponentProps, {}, "">;
15
17
  type IconButton = ReturnType<typeof IconButton>;
@@ -12,7 +12,7 @@
12
12
  * Includes ToastContainer for toast notifications.
13
13
  */
14
14
  let {
15
- theme = lutra()?.[LutraContext.Theme]?.() ?? "system",
15
+ theme = lutra ? (lutra()?.[LutraContext.Theme]?.() ?? undefined) : undefined,
16
16
  children,
17
17
  }: {
18
18
  /** The theme to use. Leave empty for auto-detection. */
@@ -42,6 +42,7 @@
42
42
  underline,
43
43
  contained,
44
44
  rounded,
45
+ scale = 'md',
45
46
  selected = $bindable<{ label: string, href?: string, index: number } | null>(null)
46
47
  }: {
47
48
  /** Tab items to display. */
@@ -52,6 +53,8 @@
52
53
  contained?: boolean;
53
54
  /** Round the corners of the element if contained. */
54
55
  rounded?: boolean;
56
+ /** Size variant controlling font-size and padding. */
57
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
55
58
  /** The index of the selected tab (bindable). */
56
59
  selected?: { label: string, href?: string, index: number } | null;
57
60
  } = $props();
@@ -111,7 +114,7 @@
111
114
 
112
115
  <svelte:window onresize={() => { loaded = false; loaded = true; }} />
113
116
 
114
- <nav class="Tabs" {id} class:contained class:rounded>
117
+ <nav class="Tabs {scale}" {id} class:contained class:rounded>
115
118
  <menu>
116
119
  {#each items as item, index}
117
120
  <li data-index={index} aria-current={item.active || activeIndex === index ? 'page' : undefined}>
@@ -221,4 +224,13 @@
221
224
  transition: all var(--transition-duration-fast) ease-out;
222
225
  opacity: 0;
223
226
  }
227
+
228
+ .Tabs.xs { font-size: var(--font-size-xs); }
229
+ .Tabs.xs :is(a, button) { padding: var(--space-xxs) var(--space-xxs); }
230
+ .Tabs.sm { font-size: var(--font-size-sm); }
231
+ .Tabs.sm :is(a, button) { padding: var(--space-xs) var(--space-xs); }
232
+ .Tabs.lg { font-size: 1em; }
233
+ .Tabs.lg :is(a, button) { padding: var(--space-md) var(--space-sm); }
234
+ .Tabs.xl { font-size: var(--font-size-h5); }
235
+ .Tabs.xl :is(a, button) { padding: var(--space-md) var(--space-md); }
224
236
  </style>
@@ -8,6 +8,8 @@ type $$ComponentProps = {
8
8
  contained?: boolean;
9
9
  /** Round the corners of the element if contained. */
10
10
  rounded?: boolean;
11
+ /** Size variant controlling font-size and padding. */
12
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
11
13
  /** The index of the selected tab (bindable). */
12
14
  selected?: {
13
15
  label: string;
@@ -24,6 +24,7 @@
24
24
  code,
25
25
  color = "default",
26
26
  shape = "pill",
27
+ scale = "md",
27
28
  onclick,
28
29
  href,
29
30
  target,
@@ -37,6 +38,8 @@
37
38
  color?: StatusColorOrString;
38
39
  /** The shape of the tag. */
39
40
  shape?: "rounded" | "pill" | "rectangle";
41
+ /** Size variant controlling font-size. Padding scales automatically via em units. */
42
+ scale?: "xs" | "sm" | "md" | "lg" | "xl";
40
43
  /** A function to run when the tag is clicked. */
41
44
  onclick?: (event: MouseEvent) => void;
42
45
  /** A URL to link to. */
@@ -84,19 +87,19 @@
84
87
  {/snippet}
85
88
 
86
89
  {#if href}
87
- <a {href} {target} class:code class="Tag Link {shape}" style="--bgColor: {!isSet ? color : 'var(--status-'+color+'-background)'}; --textColor: {!isSet ? color : 'var(--status-'+color+'-color)'};" onclick={onclick}>
90
+ <a {href} {target} class:code class="Tag Link {shape} {scale}" style="--bgColor: {!isSet ? color : 'var(--status-'+color+'-background)'}; --textColor: {!isSet ? color : 'var(--status-'+color+'-color)'};" onclick={onclick}>
88
91
  {@render _prefix()}
89
92
  {@render content()}
90
93
  {@render _suffix()}
91
94
  </a>
92
95
  {:else if onclick}
93
- <button type="button" class:code class="Tag {shape}" style="--bgColor: {!isSet ? color : 'var(--status-'+color+'-background)'}; --textColor: {!isSet ? color : 'var(--status-'+color+'-color)'};" onclick={onclick}>
96
+ <button type="button" class:code class="Tag {shape} {scale}" style="--bgColor: {!isSet ? color : 'var(--status-'+color+'-background)'}; --textColor: {!isSet ? color : 'var(--status-'+color+'-color)'};" onclick={onclick}>
94
97
  {@render _prefix()}
95
98
  {@render content()}
96
99
  {@render _suffix()}
97
100
  </button>
98
101
  {:else}
99
- <span class:code class="Tag {shape}" style="--bgColor: {!isSet ? color : 'var(--status-'+color+'-background)'}; --textColor: {!isSet ? color : 'var(--status-'+color+'-color)'};">
102
+ <span class:code class="Tag {shape} {scale}" style="--bgColor: {!isSet ? color : 'var(--status-'+color+'-background)'}; --textColor: {!isSet ? color : 'var(--status-'+color+'-color)'};">
100
103
  {@render _prefix()}
101
104
  {@render content()}
102
105
  {@render _suffix()}
@@ -157,4 +160,9 @@
157
160
  opacity: 0.65;
158
161
  }
159
162
  }
163
+
164
+ .Tag.xs { font-size: var(--font-size, 0.65em); }
165
+ .Tag.sm { font-size: var(--font-size, 0.75em); }
166
+ .Tag.lg { font-size: var(--font-size, 1em); }
167
+ .Tag.xl { font-size: var(--font-size, 1.15em); }
160
168
  </style>
@@ -7,6 +7,8 @@ type $$ComponentProps = {
7
7
  color?: StatusColorOrString;
8
8
  /** The shape of the tag. */
9
9
  shape?: "rounded" | "pill" | "rectangle";
10
+ /** Size variant controlling font-size. Padding scales automatically via em units. */
11
+ scale?: "xs" | "sm" | "md" | "lg" | "xl";
10
12
  /** A function to run when the tag is clicked. */
11
13
  onclick?: (event: MouseEvent) => void;
12
14
  /** A URL to link to. */
@@ -1,5 +1,6 @@
1
1
  export { default as AspectRatio } from './AspectRatio.svelte';
2
2
  export { default as Avatar } from './Avatar.svelte';
3
+ export { default as Callout } from './Callout.svelte';
3
4
  export { default as Close } from './Close.svelte';
4
5
  export { default as ContextTip } from './ContextTip.svelte';
5
6
  export { default as DataList } from './DataList.svelte';
@@ -1,5 +1,6 @@
1
1
  export { default as AspectRatio } from './AspectRatio.svelte';
2
2
  export { default as Avatar } from './Avatar.svelte';
3
+ export { default as Callout } from './Callout.svelte';
3
4
  export { default as Close } from './Close.svelte';
4
5
  export { default as ContextTip } from './ContextTip.svelte';
5
6
  export { default as DataList } from './DataList.svelte';
@@ -122,7 +122,7 @@
122
122
  @property --link-color-disabled { syntax: "*"; inherits: true; initial-value: #cccccc; }
123
123
  @property --link-color-loading { syntax: "*"; inherits: true; initial-value: #7c3aed; }
124
124
 
125
- @property --link-icon-size { syntax: "<length>"; inherits: true; initial-value: 24px; }
125
+ @property --link-icon-size { syntax: "*"; inherits: true; initial-value: 24px; }
126
126
  @property --link-icon-order { syntax: "<number>"; inherits: true; initial-value: 1; }
127
127
 
128
128
  /**
@@ -231,14 +231,14 @@
231
231
  @property --field-color-loading { syntax: "<color>"; inherits: true; initial-value: #7c3aed; }
232
232
 
233
233
  @property --field-label-color { syntax: "<color>"; inherits: true; initial-value: #111111; }
234
- @property --field-label-font-size { syntax: "<length>"; inherits: true; initial-value: 16px; }
234
+ @property --field-label-font-size { syntax: "*"; inherits: true; initial-value: 16px; }
235
235
  @property --field-label-font-weight { syntax: "<number>"; inherits: true; initial-value: 600; }
236
236
 
237
237
  @property --field-placeholder-color { syntax: "<color>"; inherits: true; initial-value: #999999; }
238
- @property --field-padding-inline { syntax: "<length>"; inherits: true; initial-value: 8px; }
239
- @property --field-padding-block { syntax: "<length>"; inherits: true; initial-value: 8px; }
238
+ @property --field-padding-inline { syntax: "*"; inherits: true; initial-value: 8px; }
239
+ @property --field-padding-block { syntax: "*"; inherits: true; initial-value: 8px; }
240
240
 
241
- @property --field-icon-size { syntax: "<length>"; inherits: true; initial-value: 24px; }
241
+ @property --field-icon-size { syntax: "*"; inherits: true; initial-value: 24px; }
242
242
  @property --field-icon-order { syntax: "<number>"; inherits: true; initial-value: 1; }
243
243
  @property --field-group-gap { syntax: "<length>"; inherits: true; initial-value: 8px; }
244
244
 
@@ -246,8 +246,8 @@
246
246
  * Checkbox and Radio
247
247
  */
248
248
 
249
- @property --checkbox-size { syntax: "<length>"; inherits: true; initial-value: 18px; }
250
- @property --checkbox-border-radius { syntax: "<length>"; inherits: true; initial-value: 4px; }
249
+ @property --checkbox-size { syntax: "*"; inherits: true; initial-value: 18px; }
250
+ @property --checkbox-border-radius { syntax: "*"; inherits: true; initial-value: 4px; }
251
251
  @property --checkbox-background { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
252
252
  @property --checkbox-background-checked { syntax: "<color>"; inherits: true; initial-value: #2563eb; }
253
253
  @property --checkbox-border-color { syntax: "<color>"; inherits: true; initial-value: #d1d5db; }
@@ -258,13 +258,13 @@
258
258
  * Toggle
259
259
  */
260
260
 
261
- @property --toggle-width { syntax: "<length>"; inherits: true; initial-value: 44px; }
262
- @property --toggle-height { syntax: "<length>"; inherits: true; initial-value: 24px; }
261
+ @property --toggle-width { syntax: "*"; inherits: true; initial-value: 44px; }
262
+ @property --toggle-height { syntax: "*"; inherits: true; initial-value: 24px; }
263
263
  @property --toggle-background { syntax: "<color>"; inherits: true; initial-value: #d1d5db; }
264
264
  @property --toggle-background-checked { syntax: "<color>"; inherits: true; initial-value: #2563eb; }
265
265
  @property --toggle-border-color { syntax: "<color>"; inherits: true; initial-value: #d1d5db; }
266
266
  @property --toggle-border-color-checked { syntax: "<color>"; inherits: true; initial-value: #2563eb; }
267
- @property --toggle-thumb-size { syntax: "<length>"; inherits: true; initial-value: 18px; }
267
+ @property --toggle-thumb-size { syntax: "*"; inherits: true; initial-value: 18px; }
268
268
  @property --toggle-thumb-color { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
269
269
  @property --toggle-thumb-shadow { syntax: "*"; inherits: true; initial-value: 0 1px 3px rgba(0, 0, 0, 0.2); }
270
270
 
@@ -356,14 +356,14 @@
356
356
  @property --button-border-style { syntax: "solid | dashed | dotted | double | groove | ridge | inset | outset"; inherits: true; initial-value: solid; }
357
357
  @property --button-border-radius { syntax: "<length>"; inherits: true; initial-value: 8px; }
358
358
 
359
- @property --button-padding-inline { syntax: "<length>"; inherits: true; initial-value: 16px; }
360
- @property --button-padding-block { syntax: "<length>"; inherits: true; initial-value: 8px; }
361
- @property --button-padding { syntax: "<length>"; inherits: true; initial-value: 8px; }
359
+ @property --button-padding-inline { syntax: "*"; inherits: true; initial-value: 16px; }
360
+ @property --button-padding-block { syntax: "*"; inherits: true; initial-value: 8px; }
361
+ @property --button-padding { syntax: "*"; inherits: true; initial-value: 8px; }
362
362
  @property --button-border { syntax: "*"; inherits: true; initial-value: none; }
363
363
 
364
364
  @property --button-font-weight { syntax: "<number>"; inherits: true; initial-value: 500; }
365
365
 
366
- @property --button-icon-size { syntax: "<length>"; inherits: true; initial-value: 24px; }
366
+ @property --button-icon-size { syntax: "*"; inherits: true; initial-value: 24px; }
367
367
  @property --button-icon-order { syntax: "<number>"; inherits: true; initial-value: 1; }
368
368
 
369
369
  /**
@@ -18,7 +18,7 @@
18
18
  href,
19
19
  type = 'button',
20
20
  class: className,
21
- size = 'md',
21
+ scale = 'md',
22
22
  kind = 'default',
23
23
  disabled = false,
24
24
  icon,
@@ -32,7 +32,7 @@
32
32
  /** Visual style variant. */
33
33
  kind?: 'default' | 'outlined' | 'secondary' | 'warn';
34
34
  /** Size variant. */
35
- size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
35
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
36
36
  /** Additional CSS class names. */
37
37
  class?: string;
38
38
  /** Whether the button is disabled. Also disabled automatically when the parent form is loading. */
@@ -51,14 +51,14 @@
51
51
  </script>
52
52
 
53
53
  {#if href}
54
- <a class="Button Link button {size} {kind} {className} {align}" {href} {onclick}>
54
+ <a class="Button Link button {scale} {kind} {className} {align}" {href} {onclick}>
55
55
  {#if icon}
56
56
  <Icon {icon} />
57
57
  {/if}
58
58
  {@render children()}
59
59
  </a>
60
60
  {:else}
61
- <button class="Button button {size} {kind} {className} {align}" type={type} disabled={disabled || form?.state === 'loading'} {onclick}>
61
+ <button class="Button button {scale} {kind} {className} {align}" type={type} disabled={disabled || form?.state === 'loading'} {onclick}>
62
62
  {#if icon}
63
63
  <Icon {icon} />
64
64
  {/if}
@@ -7,7 +7,7 @@ type $$ComponentProps = {
7
7
  /** Visual style variant. */
8
8
  kind?: 'default' | 'outlined' | 'secondary' | 'warn';
9
9
  /** Size variant. */
10
- size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
10
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
11
11
  /** Additional CSS class names. */
12
12
  class?: string;
13
13
  /** Whether the button is disabled. Also disabled automatically when the parent form is loading. */
@@ -27,6 +27,7 @@
27
27
  help,
28
28
  type = "text",
29
29
  direction = 'column', // 'row' | 'column'
30
+ scale = 'md',
30
31
  required,
31
32
  children,
32
33
  field,
@@ -42,6 +43,8 @@
42
43
  labelTip?: string | Snippet;
43
44
  labelHelp?: string | Snippet;
44
45
  direction?: 'row' | 'column';
46
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
47
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
45
48
  required?: boolean;
46
49
  children: Snippet;
47
50
  field?: FormField;
@@ -63,7 +66,7 @@
63
66
  </div>
64
67
  {/snippet}
65
68
 
66
- <div class="FieldContentContainer">
69
+ <div class="FieldContentContainer {scale}">
67
70
  <div class="FieldContent {type} {direction}" class:contained>
68
71
  <Label {label} tip={labelTip} help={labelHelp} {id} {required} />
69
72
  {#if contained}
@@ -184,4 +187,9 @@
184
187
  color: var(--text-color-p-subtle);
185
188
  font-weight: var(--font-weight-light);
186
189
  }
190
+
191
+ .FieldContentContainer.xs { font-size: var(--font-size-xs); }
192
+ .FieldContentContainer.sm { font-size: var(--font-size-sm); }
193
+ .FieldContentContainer.lg { font-size: var(--font-size-h5); }
194
+ .FieldContentContainer.xl { font-size: var(--font-size-h4); }
187
195
  </style>
@@ -11,6 +11,8 @@ type $$ComponentProps = {
11
11
  labelTip?: string | Snippet;
12
12
  labelHelp?: string | Snippet;
13
13
  direction?: 'row' | 'column';
14
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
15
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
14
16
  required?: boolean;
15
17
  children: Snippet;
16
18
  field?: FormField;
@@ -80,7 +80,7 @@
80
80
  .FormSection.noTitle .FormSectionContent {
81
81
  grid-column: 1 / -1;
82
82
  }
83
- @container (max-width: 800px) {
83
+ @container (max-width: 600px) {
84
84
  .FormSection {
85
85
  padding-block: calc(var(--fcc) * var(--form-padding-block));
86
86
  padding-inline: calc(var(--fcc) * var(--form-padding-inline));
@@ -69,6 +69,7 @@
69
69
  readonly,
70
70
  required,
71
71
  shape = 'rounded',
72
+ scale = 'md',
72
73
  size,
73
74
  src,
74
75
  step,
@@ -162,6 +163,8 @@
162
163
  required?: boolean;
163
164
  /** The shape of the input element. */
164
165
  shape?: 'default' | 'rounded' | 'pill' | 'circle';
166
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
167
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
165
168
  /** The size of the input element. */
166
169
  size?: number;
167
170
  /** Source URL for the image type. */
@@ -350,6 +353,7 @@
350
353
  {prefix}
351
354
  {suffix}
352
355
  {required}
356
+ {scale}
353
357
  >
354
358
 
355
359
  {#if type === "checkbox" || type === "radio"}
@@ -83,6 +83,8 @@ type $$ComponentProps = {
83
83
  required?: boolean;
84
84
  /** The shape of the input element. */
85
85
  shape?: 'default' | 'rounded' | 'pill' | 'circle';
86
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
87
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
86
88
  /** The size of the input element. */
87
89
  size?: number;
88
90
  /** Source URL for the image type. */
@@ -0,0 +1,273 @@
1
+ <script lang="ts">
2
+ import { getContext, onMount, type Snippet } from "svelte";
3
+ import { BROWSER } from "esm-env";
4
+ import type { LutraForm } from "./types.js";
5
+ import { fieldChange } from "./client.svelte.js";
6
+ import FieldContent from "./FieldContent.svelte";
7
+ import { getFromObjWithStringPath } from "./form.js";
8
+ import { ZodType } from "zod";
9
+ import type { SegmentedControlOption } from "./SegmentedControlTypes.js";
10
+
11
+ /**
12
+ * @description
13
+ * A segmented control that functions as a styled radio group. Works both as a
14
+ * standalone control (with `onchange` callback and bindable `value`) and as a
15
+ * form-integrated field via the Lutra form system.
16
+ *
17
+ * Options can be passed as `string[]` (each string used as both label and value)
18
+ * or as `SegmentedControlOption[]` for more control.
19
+ *
20
+ * @cssprop --segmented-background - Track background. (Default: var(--tab-background-color, var(--menu-background-color)))
21
+ * @cssprop --segmented-background-active - Selected segment background. (Default: var(--tab-background-color-active, var(--background-selected)))
22
+ * @cssprop --segmented-text-color - Text color. (Default: var(--tab-text-color, var(--menu-text-color)))
23
+ * @cssprop --segmented-text-color-active - Selected text color. (Default: var(--tab-text-color-active, var(--text-color-heading)))
24
+ * @cssprop --segmented-border-size - Border width. (Default: var(--tab-border-size, var(--menu-border-size)))
25
+ * @cssprop --segmented-border-style - Border style. (Default: var(--tab-border-style, var(--menu-border-style)))
26
+ * @cssprop --segmented-border-color - Border color. (Default: var(--tab-border-color, var(--menu-border-color)))
27
+ * @cssprop --segmented-border-radius - Border radius. (Default: var(--tab-border-radius, var(--menu-border-radius)))
28
+ *
29
+ * @example
30
+ * <SegmentedControl name="view" options={['List', 'Grid', 'Board']} />
31
+ * <SegmentedControl name="tab" options={[{ label: 'Inbox', value: 'inbox' }, { label: 'Sent', value: 'sent' }]} />
32
+ */
33
+ let {
34
+ disabled,
35
+ help,
36
+ id = $bindable(crypto.randomUUID()),
37
+ label,
38
+ labelHelp,
39
+ labelTip,
40
+ name,
41
+ onchange,
42
+ options,
43
+ required,
44
+ shape = "rounded",
45
+ scale = "md",
46
+ value = $bindable(""),
47
+ }: {
48
+ /** Whether the entire control is disabled. */
49
+ disabled?: boolean;
50
+ /** Help text to display below the control. */
51
+ help?: string | Snippet;
52
+ /** A random id is generated if not provided. */
53
+ id?: string;
54
+ /** The label for the control. */
55
+ label?: string | Snippet;
56
+ /** Help text to display below the label. */
57
+ labelHelp?: string | Snippet;
58
+ /** Context tooltip for the label. Renders with a questionmark using ContextTip. */
59
+ labelTip?: string | Snippet;
60
+ /** The name of the field (used as the radio group name). */
61
+ name: string;
62
+ /** Change event handler. */
63
+ onchange?: (e: Event) => void;
64
+ /** Segment options. Accepts `string[]` or `SegmentedControlOption[]`. */
65
+ options: string[] | SegmentedControlOption[];
66
+ /** Whether the field is required. */
67
+ required?: boolean;
68
+ /** The shape of the control. */
69
+ shape?: "rounded" | "pill" | "rectangle";
70
+ /** Size variant controlling padding and font-size. */
71
+ scale?: "xs" | "sm" | "md" | "lg" | "xl";
72
+ /** The currently selected value (bindable). */
73
+ value?: string;
74
+ } = $props();
75
+
76
+ /** Normalise string[] options into SegmentedControlOption[]. */
77
+ const normalised = $derived(
78
+ options.map((o): SegmentedControlOption =>
79
+ typeof o === "string" ? { label: o, value: o } : o
80
+ )
81
+ );
82
+
83
+ let els: HTMLInputElement[] = $state([]);
84
+ let segmentEls: HTMLLabelElement[] = $state([]);
85
+ let loaded = $state(false);
86
+
87
+ const form = getContext<LutraForm<any>>("form");
88
+ const field = $derived(form?.fields[name]);
89
+ const issue = $derived(form?.issues?.find((issue) => issue.name === name));
90
+ const validator = getContext<Record<string, ZodType>>("form.validators")?.[name];
91
+ const data = form?.data;
92
+ const originalData = form?.originalData;
93
+
94
+ if (!value) {
95
+ const formValue = form
96
+ ? (getFromObjWithStringPath(Object.assign(originalData ?? {}, data ?? {}), name) as string)
97
+ : undefined;
98
+ if (formValue) value = formValue;
99
+ else if (normalised.length) value = normalised[0].value;
100
+ }
101
+
102
+ const selectedIndex = $derived(
103
+ normalised.findIndex((o) => o.value === value)
104
+ );
105
+
106
+ const indicatorStyle = $derived.by(() => {
107
+ if (!BROWSER || !loaded || selectedIndex < 0) return "opacity: 0;";
108
+ const el = segmentEls[selectedIndex];
109
+ if (!el) return "opacity: 0;";
110
+ return `opacity: 1; width: ${el.offsetWidth}px; translate: ${el.offsetLeft}px 0;`;
111
+ });
112
+
113
+ onMount(async () => {
114
+ if (value) {
115
+ const checkedEl = els.find((el) => el?.value === value);
116
+ if (checkedEl) fieldChange(form, name, () => checkedEl)({} as any);
117
+ }
118
+ await new Promise((resolve) => setTimeout(resolve, 50));
119
+ loaded = true;
120
+ });
121
+
122
+ function handleChange(e: Event, index: number) {
123
+ fieldChange(form, name, () => els[index], validator, onchange)(e);
124
+ }
125
+ </script>
126
+
127
+ <FieldContent
128
+ {id}
129
+ {label}
130
+ {labelHelp}
131
+ {labelTip}
132
+ contained={false}
133
+ {field}
134
+ {issue}
135
+ type="segmented"
136
+ {help}
137
+ {required}
138
+ >
139
+ <div class="SegmentedControl {shape} {scale}" role="radiogroup">
140
+ {#each normalised as option, i}
141
+ <label
142
+ bind:this={segmentEls[i]}
143
+ class="Segment"
144
+ class:selected={value === option.value}
145
+ class:disabled={disabled || option.disabled}
146
+ >
147
+ <input
148
+ type="radio"
149
+ bind:this={els[i]}
150
+ {name}
151
+ value={option.value}
152
+ disabled={disabled || option.disabled}
153
+ required={i === 0 ? (required || field?.required) : undefined}
154
+ bind:group={value}
155
+ onchange={(e) => handleChange(e, i)}
156
+ />
157
+ {option.label}
158
+ </label>
159
+ {/each}
160
+ <div class="Indicator" style={indicatorStyle}></div>
161
+ </div>
162
+ </FieldContent>
163
+
164
+ <svelte:window onresize={() => { loaded = false; loaded = true; }} />
165
+
166
+ <style>
167
+ .SegmentedControl {
168
+ position: relative;
169
+ display: inline-flex;
170
+ background: var(--segmented-background, var(--tab-background-color, var(--menu-background-color)));
171
+ border: var(--segmented-border-size, var(--tab-border-size, var(--menu-border-size)))
172
+ var(--segmented-border-style, var(--tab-border-style, var(--menu-border-style)))
173
+ var(--segmented-border-color, var(--tab-border-color, var(--menu-border-color)));
174
+ border-radius: var(--segmented-border-radius, var(--tab-border-radius, var(--menu-border-radius)));
175
+ overflow: clip;
176
+ }
177
+
178
+ .SegmentedControl.pill {
179
+ border-radius: calc(infinity * 1px);
180
+ }
181
+
182
+ .SegmentedControl.rectangle {
183
+ border-radius: 0;
184
+ }
185
+
186
+ .Segment {
187
+ position: relative;
188
+ z-index: 1;
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ border-radius: inherit;
193
+ cursor: pointer;
194
+ color: var(--segmented-text-color, var(--tab-text-color, var(--menu-text-color)));
195
+ font-weight: var(--font-weight-medium);
196
+ letter-spacing: var(--tab-letter-spacing, -0.05ch);
197
+ user-select: none;
198
+ transition: color var(--transition-duration-fast) var(--transition-timing-function);
199
+ }
200
+
201
+ .Segment:hover:not(.disabled):not(.selected) {
202
+ background: var(--tab-background-color-hover, var(--menu-background-color-hover));
203
+ }
204
+
205
+ .Segment.selected {
206
+ color: var(--segmented-text-color-active, var(--tab-text-color-active, var(--text-color-heading)));
207
+ }
208
+
209
+ .Segment.disabled {
210
+ opacity: 0.5;
211
+ cursor: not-allowed;
212
+ }
213
+
214
+ .Segment:has(input:focus-visible) {
215
+ outline: var(--focus-ring-size) solid var(--focus-ring-color);
216
+ outline-offset: calc(-1 * var(--focus-ring-size));
217
+ z-index: 2;
218
+ }
219
+
220
+ .Segment input {
221
+ position: absolute;
222
+ opacity: 0;
223
+ pointer-events: none;
224
+ }
225
+
226
+ .Indicator {
227
+ position: absolute;
228
+ inset-block: 0;
229
+ inset-inline-start: 0;
230
+ background: var(--segmented-background-active, var(--tab-background-color-active, var(--background-selected)));
231
+ border-radius: inherit;
232
+ transition:
233
+ translate var(--transition-duration-fast) ease-out,
234
+ width var(--transition-duration-fast) ease-out;
235
+ opacity: 0;
236
+ }
237
+
238
+ @media (prefers-reduced-motion: reduce) {
239
+ .Indicator {
240
+ transition: none;
241
+ }
242
+ }
243
+
244
+ /* Size: xs */
245
+ .SegmentedControl.xs .Segment {
246
+ padding: 0.125em 0.375em;
247
+ font-size: var(--font-size-xs);
248
+ }
249
+
250
+ /* Size: sm */
251
+ .SegmentedControl.sm .Segment {
252
+ padding: var(--space-xxs) var(--space-sm);
253
+ font-size: var(--font-size-sm);
254
+ }
255
+
256
+ /* Size: md */
257
+ .SegmentedControl.md .Segment {
258
+ padding: var(--space-xs) var(--space-md);
259
+ font-size: var(--font-size-sm);
260
+ }
261
+
262
+ /* Size: lg */
263
+ .SegmentedControl.lg .Segment {
264
+ padding: var(--space-sm) var(--space-lg);
265
+ font-size: 1em;
266
+ }
267
+
268
+ /* Size: xl */
269
+ .SegmentedControl.xl .Segment {
270
+ padding: var(--space-md) var(--space-xl);
271
+ font-size: var(--font-size-h5);
272
+ }
273
+ </style>
@@ -0,0 +1,33 @@
1
+ import { type Snippet } from "svelte";
2
+ import type { SegmentedControlOption } from "./SegmentedControlTypes.js";
3
+ type $$ComponentProps = {
4
+ /** Whether the entire control is disabled. */
5
+ disabled?: boolean;
6
+ /** Help text to display below the control. */
7
+ help?: string | Snippet;
8
+ /** A random id is generated if not provided. */
9
+ id?: string;
10
+ /** The label for the control. */
11
+ label?: string | Snippet;
12
+ /** Help text to display below the label. */
13
+ labelHelp?: string | Snippet;
14
+ /** Context tooltip for the label. Renders with a questionmark using ContextTip. */
15
+ labelTip?: string | Snippet;
16
+ /** The name of the field (used as the radio group name). */
17
+ name: string;
18
+ /** Change event handler. */
19
+ onchange?: (e: Event) => void;
20
+ /** Segment options. Accepts `string[]` or `SegmentedControlOption[]`. */
21
+ options: string[] | SegmentedControlOption[];
22
+ /** Whether the field is required. */
23
+ required?: boolean;
24
+ /** The shape of the control. */
25
+ shape?: "rounded" | "pill" | "rectangle";
26
+ /** Size variant controlling padding and font-size. */
27
+ scale?: "xs" | "sm" | "md" | "lg" | "xl";
28
+ /** The currently selected value (bindable). */
29
+ value?: string;
30
+ };
31
+ declare const SegmentedControl: import("svelte").Component<$$ComponentProps, {}, "id" | "value">;
32
+ type SegmentedControl = ReturnType<typeof SegmentedControl>;
33
+ export default SegmentedControl;
@@ -0,0 +1,8 @@
1
+ export type SegmentedControlOption = {
2
+ /** Display label for the segment. */
3
+ label: string;
4
+ /** Value submitted when this segment is selected. */
5
+ value: string;
6
+ /** Whether this individual segment is disabled. */
7
+ disabled?: boolean;
8
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -40,6 +40,7 @@
40
40
  required,
41
41
  results,
42
42
  shape = 'default',
43
+ scale = 'md',
43
44
  tabindex,
44
45
  value = $bindable(),
45
46
  }: {
@@ -87,6 +88,8 @@
87
88
  results?: number;
88
89
  /** The shape of the input element. */
89
90
  shape?: 'default' | 'rounded' | 'pill' | 'circle';
91
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
92
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
90
93
  /** Source URL for the image type. */
91
94
  src?: string;
92
95
  /** An integer attribute indicating if the element can take input focus (is focusable), if it should participate to sequential keyboard navigation. */
@@ -120,6 +123,7 @@
120
123
  {field}
121
124
  {issue}
122
125
  {required}
126
+ {scale}
123
127
  contained
124
128
  >
125
129
  <div class="SelectContainer">
@@ -47,6 +47,8 @@ type $$ComponentProps = {
47
47
  results?: number;
48
48
  /** The shape of the input element. */
49
49
  shape?: 'default' | 'rounded' | 'pill' | 'circle';
50
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
51
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
50
52
  /** Source URL for the image type. */
51
53
  src?: string;
52
54
  /** An integer attribute indicating if the element can take input focus (is focusable), if it should participate to sequential keyboard navigation. */
@@ -55,6 +55,7 @@
55
55
  required,
56
56
  resize,
57
57
  shape = 'rounded',
58
+ scale = 'md',
58
59
  step,
59
60
  style,
60
61
  tabindex,
@@ -138,6 +139,8 @@
138
139
  resize?: boolean | 'both' | 'horizontal' | 'vertical' | 'none';
139
140
  /** The shape of the input element. */
140
141
  shape?: 'default' | 'rounded' | 'pill' | 'circle';
142
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
143
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
141
144
  /** Spellcheck the input. */
142
145
  spellcheck?: boolean;
143
146
  /** A number that specifies the granularity that the value must adhere to. Valid for date, month, week, time, datetime-local, number, and range. */
@@ -221,6 +224,7 @@
221
224
  {help}
222
225
  {suffix}
223
226
  {required}
227
+ {scale}
224
228
  >
225
229
  <textarea
226
230
  class="{resizeClass}"
@@ -77,6 +77,8 @@ type $$ComponentProps = {
77
77
  resize?: boolean | 'both' | 'horizontal' | 'vertical' | 'none';
78
78
  /** The shape of the input element. */
79
79
  shape?: 'default' | 'rounded' | 'pill' | 'circle';
80
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
81
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
80
82
  /** Spellcheck the input. */
81
83
  spellcheck?: boolean;
82
84
  /** A number that specifies the granularity that the value must adhere to. Valid for date, month, week, time, datetime-local, number, and range. */
@@ -28,6 +28,7 @@
28
28
  onchange,
29
29
  onfocus,
30
30
  required,
31
+ scale = 'md',
31
32
  title,
32
33
  value = $bindable(false),
33
34
  ...rest
@@ -54,6 +55,8 @@
54
55
  onfocus?: (e: FocusEvent) => void;
55
56
  /** Whether the toggle is required. */
56
57
  required?: boolean;
58
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
59
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
57
60
  /** A string that defines the title of the toggle. */
58
61
  title?: string;
59
62
  /** The checked state of the toggle. */
@@ -88,6 +91,7 @@
88
91
  type="toggle"
89
92
  {help}
90
93
  {required}
94
+ {scale}
91
95
  >
92
96
  <input
93
97
  type="checkbox"
@@ -22,6 +22,8 @@ type $$ComponentProps = {
22
22
  onfocus?: (e: FocusEvent) => void;
23
23
  /** Whether the toggle is required. */
24
24
  required?: boolean;
25
+ /** Size variant controlling font-size. Em-based tokens cascade automatically. */
26
+ scale?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
25
27
  /** A string that defines the title of the toggle. */
26
28
  title?: string;
27
29
  /** The checked state of the toggle. */
@@ -10,9 +10,11 @@ export { default as Input } from './Input.svelte';
10
10
  export { default as InputLength } from './InputLength.svelte';
11
11
  export { default as Label } from './Label.svelte';
12
12
  export { default as LogoUpload } from './LogoUpload.svelte';
13
+ export { default as SegmentedControl } from './SegmentedControl.svelte';
13
14
  export { default as Select } from './Select.svelte';
14
15
  export { default as Textarea } from './Textarea.svelte';
15
16
  export { default as Toggle } from './Toggle.svelte';
17
+ export * from './SegmentedControlTypes.js';
16
18
  export * from './types.js';
17
19
  export * from './form.js';
18
20
  export * from './client.svelte.js';
@@ -10,9 +10,11 @@ export { default as Input } from './Input.svelte';
10
10
  export { default as InputLength } from './InputLength.svelte';
11
11
  export { default as Label } from './Label.svelte';
12
12
  export { default as LogoUpload } from './LogoUpload.svelte';
13
+ export { default as SegmentedControl } from './SegmentedControl.svelte';
13
14
  export { default as Select } from './Select.svelte';
14
15
  export { default as Textarea } from './Textarea.svelte';
15
16
  export { default as Toggle } from './Toggle.svelte';
17
+ export * from './SegmentedControlTypes.js';
16
18
  export * from './types.js';
17
19
  export * from './form.js';
18
20
  export * from './client.svelte.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lutra",
3
- "version": "0.1.70",
3
+ "version": "0.1.74",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "bump-and-publish:patch": "pnpm version:patch && pnpm build && npm publish",