@insymetri/styleguide 0.1.91 → 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.
- package/dist/IIPopover/IIPopover.svelte +49 -0
- package/dist/IIPopover/IIPopover.svelte.d.ts +21 -0
- package/dist/IIPopover/IIPopoverNestedMenuProof.svelte +32 -0
- package/dist/IIPopover/IIPopoverNestedMenuProof.svelte.d.ts +3 -0
- package/dist/IIPopover/IIPopoverNonDismissableProof.svelte +22 -0
- package/dist/IIPopover/IIPopoverNonDismissableProof.svelte.d.ts +3 -0
- package/dist/IISegmentedControl/IISegmentedControl.svelte +92 -5
- package/dist/IISegmentedControl/IISegmentedControlStories.svelte +27 -0
- package/package.json +1 -1
|
@@ -24,6 +24,27 @@
|
|
|
24
24
|
triggerClass?: string
|
|
25
25
|
/** Strip the default surface (background, border, radius, shadow) — for content that renders its own. */
|
|
26
26
|
unstyled?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Fired when a pointer interaction starts outside the popover. Call
|
|
29
|
+
* `event.preventDefault()` to keep the popover open. Interactions inside one of
|
|
30
|
+
* the styleguide's own portaled menus (IIDropdownInput, IIMenu…) are already
|
|
31
|
+
* kept — see `keepOpenForNestedMenus`.
|
|
32
|
+
*/
|
|
33
|
+
onInteractOutside?: (event: PointerEvent) => void
|
|
34
|
+
/**
|
|
35
|
+
* Position the content against this element (or selector) instead of the
|
|
36
|
+
* trigger — e.g. a settings panel that should sit beside the whole card its gear
|
|
37
|
+
* button lives in. Clicks on the anchor don't count as outside interactions.
|
|
38
|
+
*/
|
|
39
|
+
customAnchor?: HTMLElement | string | null
|
|
40
|
+
/**
|
|
41
|
+
* Whether the popover dismisses itself. Default `true`: a click outside or Escape
|
|
42
|
+
* closes it, as a menu should. Set `false` for a panel that owns the screen for a
|
|
43
|
+
* while and holds work in progress — a live call, a form mid-edit — where a stray
|
|
44
|
+
* click elsewhere in the app should not throw it away. It can then be closed only
|
|
45
|
+
* by its own trigger or by whoever owns `open`.
|
|
46
|
+
*/
|
|
47
|
+
dismissable?: boolean
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
let {
|
|
@@ -38,11 +59,36 @@
|
|
|
38
59
|
class: className,
|
|
39
60
|
triggerClass,
|
|
40
61
|
unstyled = false,
|
|
62
|
+
onInteractOutside,
|
|
63
|
+
customAnchor = null,
|
|
64
|
+
dismissable = true,
|
|
41
65
|
}: Props = $props()
|
|
42
66
|
|
|
43
67
|
const surfaceClasses = unstyled
|
|
44
68
|
? ''
|
|
45
69
|
: 'bg-dropdown-bg border border-dropdown-border rounded-10 shadow-dropdown'
|
|
70
|
+
|
|
71
|
+
// The menu components (IIDropdownInput, IIMenu, …) portal their list to <body>,
|
|
72
|
+
// so to bits-ui a click on one of their options is a click OUTSIDE this popover:
|
|
73
|
+
// it dismissed the popover on pointerdown, unmounting the content — and the
|
|
74
|
+
// option along with it — before the click could land. A dropdown inside a popover
|
|
75
|
+
// therefore never changed its value. Every portaled menu marks its list with
|
|
76
|
+
// `data-menu-content`; an interaction starting there is ours, so keep the popover
|
|
77
|
+
// open and let the option's own click do its job.
|
|
78
|
+
function keepOpenForNestedMenus(event: PointerEvent) {
|
|
79
|
+
const target = event.target
|
|
80
|
+
if (!dismissable || (target instanceof Element && target.closest('[data-menu-content]'))) {
|
|
81
|
+
event.preventDefault()
|
|
82
|
+
}
|
|
83
|
+
onInteractOutside?.(event)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// bits-ui closes on Escape unless the handler defaults it away. A non-dismissable
|
|
87
|
+
// panel has to opt out of that too, or Escape would still throw away the very work
|
|
88
|
+
// the outside-click guard above is protecting.
|
|
89
|
+
function keepOpenOnEscape(event: KeyboardEvent) {
|
|
90
|
+
if (!dismissable) event.preventDefault()
|
|
91
|
+
}
|
|
46
92
|
</script>
|
|
47
93
|
|
|
48
94
|
<Popover.Root bind:open {onOpenChange}>
|
|
@@ -62,6 +108,9 @@
|
|
|
62
108
|
{side}
|
|
63
109
|
{align}
|
|
64
110
|
{sideOffset}
|
|
111
|
+
{customAnchor}
|
|
112
|
+
onInteractOutside={keepOpenForNestedMenus}
|
|
113
|
+
onEscapeKeydown={keepOpenOnEscape}
|
|
65
114
|
class={cn(
|
|
66
115
|
surfaceClasses,
|
|
67
116
|
'z-16 animate-slide-in motion-reduce:animate-none',
|
|
@@ -21,6 +21,27 @@ type Props = {
|
|
|
21
21
|
triggerClass?: string;
|
|
22
22
|
/** Strip the default surface (background, border, radius, shadow) — for content that renders its own. */
|
|
23
23
|
unstyled?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Fired when a pointer interaction starts outside the popover. Call
|
|
26
|
+
* `event.preventDefault()` to keep the popover open. Interactions inside one of
|
|
27
|
+
* the styleguide's own portaled menus (IIDropdownInput, IIMenu…) are already
|
|
28
|
+
* kept — see `keepOpenForNestedMenus`.
|
|
29
|
+
*/
|
|
30
|
+
onInteractOutside?: (event: PointerEvent) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Position the content against this element (or selector) instead of the
|
|
33
|
+
* trigger — e.g. a settings panel that should sit beside the whole card its gear
|
|
34
|
+
* button lives in. Clicks on the anchor don't count as outside interactions.
|
|
35
|
+
*/
|
|
36
|
+
customAnchor?: HTMLElement | string | null;
|
|
37
|
+
/**
|
|
38
|
+
* Whether the popover dismisses itself. Default `true`: a click outside or Escape
|
|
39
|
+
* closes it, as a menu should. Set `false` for a panel that owns the screen for a
|
|
40
|
+
* while and holds work in progress — a live call, a form mid-edit — where a stray
|
|
41
|
+
* click elsewhere in the app should not throw it away. It can then be closed only
|
|
42
|
+
* by its own trigger or by whoever owns `open`.
|
|
43
|
+
*/
|
|
44
|
+
dismissable?: boolean;
|
|
24
45
|
};
|
|
25
46
|
declare const IIPopover: import("svelte").Component<Props, {}, "open">;
|
|
26
47
|
type IIPopover = ReturnType<typeof IIPopover>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import IIPopover from './IIPopover.svelte'
|
|
3
|
+
import {IIButton} from '../IIButton'
|
|
4
|
+
import {IIDropdownInput} from '../IIDropdownInput'
|
|
5
|
+
|
|
6
|
+
// Fixture for the play test: a menu-style input inside a popover. Its option
|
|
7
|
+
// list portals to <body>, so choosing an option used to read as an outside
|
|
8
|
+
// click and dismiss the popover before the choice registered.
|
|
9
|
+
let open = $state(false)
|
|
10
|
+
let device = $state('')
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<IIPopover bind:open>
|
|
14
|
+
{#snippet trigger()}
|
|
15
|
+
<IIButton variant="secondary" size="sm">Audio settings</IIButton>
|
|
16
|
+
{/snippet}
|
|
17
|
+
{#snippet content()}
|
|
18
|
+
<div class="p-12 w-240 flex flex-col gap-8">
|
|
19
|
+
<p class="text-small text-secondary">Speaker</p>
|
|
20
|
+
<IIDropdownInput
|
|
21
|
+
items={[
|
|
22
|
+
{label: 'System Default', value: ''},
|
|
23
|
+
{label: 'Headset', value: 'headset'},
|
|
24
|
+
{label: 'Desk speakers', value: 'desk'},
|
|
25
|
+
]}
|
|
26
|
+
bind:value={device}
|
|
27
|
+
class="w-full"
|
|
28
|
+
/>
|
|
29
|
+
<p class="text-small text-body" data-testid="chosen">Chosen: {device || 'default'}</p>
|
|
30
|
+
</div>
|
|
31
|
+
{/snippet}
|
|
32
|
+
</IIPopover>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import IIPopover from './IIPopover.svelte'
|
|
3
|
+
import {IIButton} from '../IIButton'
|
|
4
|
+
|
|
5
|
+
// Fixture for the play test: a panel that must survive a click elsewhere in the app
|
|
6
|
+
// and an Escape press, and close only from its own trigger.
|
|
7
|
+
let open = $state(false)
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div class="flex flex-col gap-16">
|
|
11
|
+
<IIPopover bind:open dismissable={false}>
|
|
12
|
+
{#snippet trigger()}
|
|
13
|
+
<IIButton variant="secondary" size="sm">{open ? 'Collapse panel' : 'Open panel'}</IIButton>
|
|
14
|
+
{/snippet}
|
|
15
|
+
{#snippet content()}
|
|
16
|
+
<div class="p-12 w-200">
|
|
17
|
+
<p class="text-small text-body">Work in progress.</p>
|
|
18
|
+
</div>
|
|
19
|
+
{/snippet}
|
|
20
|
+
</IIPopover>
|
|
21
|
+
<button type="button" data-testid="elsewhere" class="text-small text-secondary">Somewhere else</button>
|
|
22
|
+
</div>
|
|
@@ -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
|
|
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 + {
|
|
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[
|
|
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>
|