@insymetri/styleguide 0.1.92 → 0.1.94

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.
@@ -9,6 +9,8 @@
9
9
  trigger: Snippet<[{expanded: boolean}]>
10
10
  children: Snippet
11
11
  disabled?: boolean
12
+ /** `card` (default) frames each item; `plain` separates them with a hairline. */
13
+ variant?: 'card' | 'plain'
12
14
  class?: string
13
15
  }
14
16
 
@@ -18,6 +20,7 @@
18
20
  trigger,
19
21
  children,
20
22
  disabled = false,
23
+ variant = 'card',
21
24
  class: className,
22
25
  }: Props = $props()
23
26
 
@@ -28,7 +31,14 @@
28
31
  }
29
32
  </script>
30
33
 
31
- <div class={cn('border border-primary rounded-10 overflow-hidden', className)}>
34
+ <div
35
+ class={cn(
36
+ variant === 'plain'
37
+ ? 'border-b border-subtle last:border-b-0'
38
+ : 'border border-primary rounded-10 overflow-hidden',
39
+ className
40
+ )}
41
+ >
32
42
  <button
33
43
  type="button"
34
44
  class="flex items-center gap-8 w-full bg-transparent border-none px-16 py-12 cursor-default text-left font-inherit hover:bg-background"
@@ -47,7 +57,7 @@
47
57
  </div>
48
58
  </button>
49
59
  {#if expanded}
50
- <div class="border-t border-primary">
60
+ <div class={variant === 'plain' ? '' : 'border-t border-primary'}>
51
61
  {@render children()}
52
62
  </div>
53
63
  {/if}
@@ -7,6 +7,8 @@ type Props = {
7
7
  }]>;
8
8
  children: Snippet;
9
9
  disabled?: boolean;
10
+ /** `card` (default) frames each item; `plain` separates them with a hairline. */
11
+ variant?: 'card' | 'plain';
10
12
  class?: string;
11
13
  };
12
14
  declare const IIAccordion: import("svelte").Component<Props, {}, "expanded">;
@@ -1,18 +1,28 @@
1
1
  <script lang="ts">
2
2
  import type {Snippet} from 'svelte'
3
+ import {cn} from '../utils/cn'
3
4
 
4
5
  type Props = {
5
6
  label?: string
6
7
  id?: string
7
- children: Snippet
8
+ /** Control on the label's own line, right-aligned. Ignored without a `label`. */
9
+ labelAction?: Snippet
10
+ class?: string
8
11
  }
9
12
 
10
- let {label, id, children}: Props = $props()
13
+ let {label, id, labelAction, children, class: className}: Props & {children: Snippet} = $props()
11
14
  </script>
12
15
 
13
- <div class="mb-16 last:mb-0">
16
+ <div class={cn('mb-16 last:mb-0', className)}>
14
17
  {#if label}
15
- <label class="block text-small-emphasis text-body mb-8" for={id}>{label}</label>
18
+ {#if labelAction}
19
+ <div class="mb-8 flex items-center justify-between gap-10">
20
+ <label class="block text-small-emphasis text-body" for={id}>{label}</label>
21
+ {@render labelAction()}
22
+ </div>
23
+ {:else}
24
+ <label class="block text-small-emphasis text-body mb-8" for={id}>{label}</label>
25
+ {/if}
16
26
  {/if}
17
27
  {@render children()}
18
28
  </div>
@@ -2,8 +2,13 @@ import type { Snippet } from 'svelte';
2
2
  type Props = {
3
3
  label?: string;
4
4
  id?: string;
5
+ /** Control on the label's own line, right-aligned. Ignored without a `label`. */
6
+ labelAction?: Snippet;
7
+ class?: string;
8
+ };
9
+ type $$ComponentProps = Props & {
5
10
  children: Snippet;
6
11
  };
7
- declare const IIFormField: import("svelte").Component<Props, {}, "">;
12
+ declare const IIFormField: import("svelte").Component<$$ComponentProps, {}, "">;
8
13
  type IIFormField = ReturnType<typeof IIFormField>;
9
14
  export default IIFormField;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import {untrack} from 'svelte'
2
3
  import {cn} from '../utils/cn'
3
4
  import {useDensity} from '../density'
4
5
 
@@ -52,6 +53,41 @@
52
53
 
53
54
  const activeIndex = $derived(items.findIndex(item => item.value === value))
54
55
 
56
+ /* The thumb stays mounted even when nothing is selected so it can animate in and
57
+ out. `thumbIndex` remembers where it last was, so clearing the value fades it
58
+ out under the segment that was selected instead of yanking it back to the first. */
59
+ let thumbIndex = $state(untrack(() => Math.max(activeIndex, 0)))
60
+ let thumbVisible = $state(untrack(() => activeIndex >= 0))
61
+ let sliding = $state(false)
62
+ /* Seeded with the mount-time index on purpose: the effect below compares against
63
+ it and bails on its first run, so a control that renders already-selected shows
64
+ its thumb in place rather than animating it in on load. */
65
+ let prevIndex = untrack(() => activeIndex)
66
+
67
+ $effect(() => {
68
+ const next = activeIndex
69
+ if (next === prevIndex) return
70
+ const appearing = prevIndex < 0
71
+ prevIndex = next
72
+
73
+ if (next < 0) {
74
+ sliding = false
75
+ thumbVisible = false
76
+ return
77
+ }
78
+
79
+ /* Appearing from nothing must not transition `left`: a thumb that slides in
80
+ from segment 0 reads as "it was always there, just hidden". Dropping the
81
+ slide lets it grow into place under the segment that was actually picked,
82
+ and the scale/opacity transition carries the motion instead. */
83
+ sliding = !appearing
84
+ thumbIndex = next
85
+ thumbVisible = true
86
+ })
87
+
88
+ // Guards against `items` shrinking underneath a remembered index.
89
+ const thumbPosition = $derived(Math.min(thumbIndex, Math.max(items.length - 1, 0)))
90
+
55
91
  function select(v: string) {
56
92
  value = v
57
93
  onValueChange?.(v)
@@ -113,17 +149,19 @@
113
149
  style="padding: {thumbInset[density.value]}px; grid-template-columns: repeat({items.length}, 1fr);"
114
150
  >
115
151
  <!-- Sliding thumb -->
116
- {#if activeIndex >= 0}
152
+ {#if items.length > 0}
117
153
  <div
118
- class="absolute"
154
+ class="ii-seg-thumb absolute"
155
+ class:is-visible={thumbVisible}
156
+ class:is-sliding={sliding}
157
+ aria-hidden="true"
119
158
  style:border-radius="calc(var(--ii-radius-control) - {thumbInset[density.value]}px)"
120
159
  style="
121
160
  top: {thumbInset[density.value]}px;
122
161
  bottom: {thumbInset[density.value]}px;
123
- left: calc({thumbInset[density.value]}px + {activeIndex} * (100% - {thumbInset[density.value] * 2}px) / {items.length});
162
+ left: calc({thumbInset[density.value]}px + {thumbPosition} * (100% - {thumbInset[density.value] * 2}px) / {items.length});
124
163
  width: calc((100% - {thumbInset[density.value] * 2}px) / {items.length});
125
- background: {getThumbColor(items[activeIndex])};
126
- transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1);
164
+ background: {getThumbColor(items[thumbPosition])};
127
165
  "
128
166
  ></div>
129
167
  {/if}
@@ -147,3 +185,52 @@
147
185
  </button>
148
186
  {/each}
149
187
  </div>
188
+
189
+ <style>
190
+ /* Resting state is hidden-and-small, so the thumb has somewhere to grow from.
191
+ Also governs the exit: a transition uses the curve declared on the state it is
192
+ travelling *to*, so this rule is what plays when the selection is cleared —
193
+ short and ease-in, because leaving should not ask for attention. */
194
+ .ii-seg-thumb {
195
+ opacity: 0;
196
+ transform: scale(0.82);
197
+ pointer-events: none;
198
+ transition:
199
+ opacity 120ms ease-in,
200
+ transform 180ms ease-in;
201
+ }
202
+
203
+ /* The entrance. The back-out curve grows the thumb past its resting size — about
204
+ 1.02 at the peak — before settling, which is the gesture iOS and macOS
205
+ segmented controls use to say "this materialised here" rather than "this
206
+ blinked on". At these sizes the overshoot is barely a pixel; it reads as
207
+ liveliness in the deceleration, not as a bounce. Opacity finishes well before
208
+ the scale so the tail of the motion is pure settle, not fade. */
209
+ .ii-seg-thumb.is-visible {
210
+ opacity: 1;
211
+ transform: scale(1);
212
+ transition:
213
+ opacity 140ms ease-out,
214
+ transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1);
215
+ }
216
+
217
+ /* `left` only animates once the thumb is already on screen and moving between
218
+ segments — see the effect above. Must stay after `.is-visible`: equal
219
+ specificity, so source order decides. */
220
+ .ii-seg-thumb.is-sliding {
221
+ transition:
222
+ opacity 140ms ease-out,
223
+ transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
224
+ background-color 200ms ease,
225
+ left 300ms cubic-bezier(0.4, 0, 0.2, 1);
226
+ }
227
+
228
+ @media (prefers-reduced-motion: reduce) {
229
+ .ii-seg-thumb,
230
+ .ii-seg-thumb.is-visible,
231
+ .ii-seg-thumb.is-sliding {
232
+ transform: none;
233
+ transition: opacity 100ms linear;
234
+ }
235
+ }
236
+ </style>
@@ -10,6 +10,8 @@
10
10
 
11
11
  let fullWidthMode = $state('allow')
12
12
  let fullWidthThree = $state('monthly')
13
+
14
+ let emptyStart = $state('')
13
15
  </script>
14
16
 
15
17
  <div class="flex flex-col gap-32">
@@ -151,6 +153,31 @@
151
153
  />
152
154
  </section>
153
155
 
156
+ <!-- Animating in from empty -->
157
+ <section>
158
+ <h2 class="text-default-emphasis text-primary mb-8">Animating in from empty</h2>
159
+ <p class="text-small text-secondary mb-12">
160
+ With no selection there is no thumb to slide, so picking a segment scales the thumb up
161
+ into place with a small overshoot instead of flashing it on. Clearing fades it back out
162
+ under the segment it was on. Moving between segments still slides.
163
+ </p>
164
+ <IISegmentedControl
165
+ items={[
166
+ {label: 'Monthly', value: 'monthly'},
167
+ {label: 'Quarterly', value: 'quarterly'},
168
+ {label: 'Annual', value: 'annual'},
169
+ ]}
170
+ bind:value={emptyStart}
171
+ />
172
+ <button
173
+ type="button"
174
+ class="mt-12 px-12 py-6 bg-button-primary text-button-primary-text rounded-control"
175
+ onclick={() => (emptyStart = '')}
176
+ >
177
+ Clear selection
178
+ </button>
179
+ </section>
180
+
154
181
  <!-- Density-responsive -->
155
182
  <section>
156
183
  <h2 class="text-default-emphasis text-primary mb-8">Density Responsive</h2>
@@ -41,6 +41,8 @@
41
41
  stickyHeader?: boolean
42
42
  /** `sm` → text-small (default), `md` → text-default. */
43
43
  size?: 'sm' | 'md'
44
+ /** `card` (default) frames the table; `plain` drops the frame and header fill for hairline rules. */
45
+ variant?: 'card' | 'plain'
44
46
  class?: string
45
47
  }
46
48
 
@@ -58,31 +60,45 @@
58
60
  isExpanded,
59
61
  stickyHeader = false,
60
62
  size = 'sm',
63
+ variant = 'card',
61
64
  class: className,
62
65
  }: Props = $props()
63
66
 
64
67
  const alignClass = {left: 'text-left', right: 'text-right', center: 'text-center'} as const
65
68
  const cellText = $derived(size === 'sm' ? 'text-small' : 'text-default')
66
69
  const interactive = $derived(!!onRowClick)
70
+ const plain = $derived(variant === 'plain')
71
+
72
+ const containerClass = $derived(
73
+ plain ? 'overflow-x-auto' : 'bg-surface-raised border border-subtle rounded-12 overflow-x-auto overflow-y-hidden'
74
+ )
75
+ const headClass = $derived(plain ? 'border-b border-subtle' : 'bg-background border-b border-primary')
76
+ const headCellClass = $derived(
77
+ plain
78
+ ? 'px-12 pb-8 text-micro text-tertiary uppercase'
79
+ : 'px-14 py-9 text-tiny-emphasis text-tertiary uppercase tracking-wide'
80
+ )
81
+ const bodyCellClass = $derived(plain ? 'px-12 py-11' : 'px-14 py-10')
82
+ const rowRuleClass = $derived(plain ? 'border-subtle' : 'border-primary')
67
83
 
68
84
  function cellClass(col: Column, row: T): string {
69
85
  return typeof col.class === 'function' ? col.class(row) : (col.class ?? '')
70
86
  }
71
87
  </script>
72
88
 
73
- <div class={cn('bg-surface-raised border border-subtle rounded-12 overflow-x-auto overflow-y-hidden', className)}>
89
+ <div class={cn(containerClass, className)}>
74
90
  {#if children}
75
91
  <table class="w-full border-collapse">
76
92
  {@render children()}
77
93
  </table>
78
94
  {:else}
79
95
  <table class="w-full border-collapse">
80
- <thead class={cn('bg-background border-b border-primary', stickyHeader && 'sticky top-0 z-1')}>
96
+ <thead class={cn(headClass, stickyHeader && 'sticky top-0 z-1')}>
81
97
  <tr>
82
98
  {#each columns as col (col.key)}
83
99
  <th
84
100
  class={cn(
85
- 'px-14 py-9 text-tiny-emphasis text-tertiary uppercase tracking-wide',
101
+ headCellClass,
86
102
  alignClass[col.align ?? 'left'],
87
103
  col.width,
88
104
  col.headerClass
@@ -108,7 +124,8 @@
108
124
  {#each rows as row (rowKey?.(row) ?? row)}
109
125
  <tr
110
126
  class={cn(
111
- 'border-b border-primary last:border-b-0',
127
+ 'border-b last:border-b-0',
128
+ rowRuleClass,
112
129
  interactive && 'cursor-pointer hover:bg-background',
113
130
  rowClass?.(row)
114
131
  )}
@@ -119,7 +136,8 @@
119
136
  {#each columns as col (col.key)}
120
137
  <td
121
138
  class={cn(
122
- 'px-14 py-10 text-body',
139
+ bodyCellClass,
140
+ 'text-body',
123
141
  cellText,
124
142
  alignClass[col.align ?? 'left'],
125
143
  col.width,
@@ -131,7 +149,7 @@
131
149
  {/each}
132
150
  </tr>
133
151
  {#if expandedRow && isExpanded?.(row)}
134
- <tr class="border-b border-primary last:border-b-0 bg-background">
152
+ <tr class={cn('border-b last:border-b-0 bg-background', rowRuleClass)}>
135
153
  <td colspan={columns.length} class="px-16 py-12">
136
154
  {@render expandedRow(row)}
137
155
  </td>
@@ -36,6 +36,8 @@ declare function $$render<T>(): {
36
36
  stickyHeader?: boolean;
37
37
  /** `sm` → text-small (default), `md` → text-default. */
38
38
  size?: "sm" | "md";
39
+ /** `card` (default) frames the table; `plain` drops the frame and header fill for hairline rules. */
40
+ variant?: "card" | "plain";
39
41
  class?: string;
40
42
  };
41
43
  exports: {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insymetri/styleguide",
3
- "version": "0.1.92",
3
+ "version": "0.1.94",
4
4
  "description": "Insymetri shared UI component library built with Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {