@insymetri/styleguide 0.1.92 → 0.1.93

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.
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insymetri/styleguide",
3
- "version": "0.1.92",
3
+ "version": "0.1.93",
4
4
  "description": "Insymetri shared UI component library built with Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {