@insymetri/styleguide 0.1.77 → 0.1.79

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.
@@ -117,25 +117,27 @@
117
117
  </DatePicker.Trigger>
118
118
  {/snippet}
119
119
  </DatePicker.Input>
120
- <DatePicker.Content forceMount sideOffset={8}>
121
- {#snippet child({props, wrapperProps, open})}
122
- {#if open}
123
- <div {...wrapperProps}>
124
- <div
125
- {...props}
126
- class="bg-surface border border-primary rounded-10 shadow-dropdown p-12 z-12 min-w-280"
127
- transition:fly={{y: -4, duration: 150}}
128
- >
129
- <DatePicker.Calendar>
130
- {#snippet children({months, weekdays})}
131
- <IICalendarGrid {months} {weekdays} {placeholder} onNavigate={(d) => (placeholder = d)} />
132
- {/snippet}
133
- </DatePicker.Calendar>
120
+ <DatePicker.Portal>
121
+ <DatePicker.Content forceMount sideOffset={8}>
122
+ {#snippet child({props, wrapperProps, open})}
123
+ {#if open}
124
+ <div {...wrapperProps}>
125
+ <div
126
+ {...props}
127
+ class="bg-surface border border-primary rounded-10 shadow-dropdown p-12 z-16 min-w-280"
128
+ transition:fly={{y: -4, duration: 150}}
129
+ >
130
+ <DatePicker.Calendar>
131
+ {#snippet children({months, weekdays})}
132
+ <IICalendarGrid {months} {weekdays} {placeholder} onNavigate={(d) => (placeholder = d)} />
133
+ {/snippet}
134
+ </DatePicker.Calendar>
135
+ </div>
134
136
  </div>
135
- </div>
136
- {/if}
137
- {/snippet}
138
- </DatePicker.Content>
137
+ {/if}
138
+ {/snippet}
139
+ </DatePicker.Content>
140
+ </DatePicker.Portal>
139
141
  </DatePicker.Root>
140
142
  {#if showError && errorMessage}
141
143
  <span class="text-tiny text-error mt-4" data-field-error>{errorMessage}</span>
@@ -0,0 +1,26 @@
1
+ <script lang="ts">
2
+ import IIDateInput from './IIDateInput.svelte'
3
+ import IIButton from '../IIButton/IIButton.svelte'
4
+ import type {DateValue} from '@internationalized/date'
5
+
6
+ let value = $state<DateValue>()
7
+ </script>
8
+
9
+ <!--
10
+ Reproduces the apply-flow layout that previously broke the calendar popup:
11
+ a height-constrained `overflow-y-auto` scroll container (which also clips
12
+ overflow-x on the right) with the Continue button as a *later sibling* of the
13
+ date input. Before the DatePicker.Portal fix the calendar (min-w-280) was
14
+ clipped on the right/bottom by this container and painted *behind* the
15
+ Continue button. With the portal it escapes the container and layers above.
16
+
17
+ To verify: open the calendar and confirm it is fully visible and sits above
18
+ the Continue button.
19
+ -->
20
+ <div
21
+ data-testid="scroll-container"
22
+ style="display: flex; flex-direction: column; gap: 1rem; width: 300px; height: 200px; overflow-y: auto; border: 1px dashed var(--color-border-primary, #cbd5e1); border-radius: 10px; padding: 1rem;"
23
+ >
24
+ <IIDateInput bind:value label="Date of Birth" class="w-full" />
25
+ <IIButton class="w-full">Continue</IIButton>
26
+ </div>
@@ -0,0 +1,3 @@
1
+ declare const IIDateInputClippingStories: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type IIDateInputClippingStories = ReturnType<typeof IIDateInputClippingStories>;
3
+ export default IIDateInputClippingStories;
@@ -111,6 +111,7 @@
111
111
  placement: 'bottom-start',
112
112
  offset: 2,
113
113
  matchWidth: matchTriggerWidth,
114
+ constrainHeight: true,
114
115
  })
115
116
 
116
117
  function handleSelect(item: MenuItem) {
@@ -272,7 +273,7 @@
272
273
  bind:this={floatingEl}
273
274
  role="listbox"
274
275
  data-menu-content
275
- class="min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 pointer-events-auto outline-none"
276
+ class="flex flex-col min-w-100 bg-dropdown-bg border-[0.5px] border-dropdown-border rounded-10 shadow-dropdown p-4 z-16 pointer-events-auto outline-none overflow-hidden"
276
277
  transition:fly={{y: -4, duration: 150}}
277
278
  >
278
279
  {#if searchable}
@@ -283,7 +284,7 @@
283
284
  onkeydown={(e) => search.handleKeydown(e, '[data-menu-content]')}
284
285
  />
285
286
  {/if}
286
- <div class="max-h-300 overflow-y-auto">
287
+ <div class="dd-scroll min-h-0 overflow-y-auto -mr-2">
287
288
  {#each (searchable ? search.filteredItems as MenuItem[] : items) as item, listIndex (item.value)}
288
289
  {@const index = searchable ? search.getItemIndex(item) : listIndex}
289
290
  {@const isHighlighted = searchable
@@ -345,3 +346,26 @@
345
346
  <span class="text-tiny text-error mt-4" data-field-error>{errorMessage}</span>
346
347
  {/if}
347
348
  </div>
349
+
350
+ <style>
351
+ /* Opt back into ::-webkit-scrollbar styling so we can set a custom (narrow)
352
+ thumb width: the global standard scrollbar-width/color (base.css) otherwise
353
+ disables the WebKit pseudo-elements in Chromium. */
354
+ .dd-scroll {
355
+ scrollbar-width: auto;
356
+ scrollbar-color: auto;
357
+ }
358
+ .dd-scroll::-webkit-scrollbar {
359
+ width: 4px;
360
+ }
361
+ .dd-scroll::-webkit-scrollbar-track {
362
+ background: transparent;
363
+ }
364
+ .dd-scroll::-webkit-scrollbar-thumb {
365
+ background-color: var(--ii-gray-300);
366
+ border-radius: 9999px;
367
+ }
368
+ .dd-scroll::-webkit-scrollbar-thumb:hover {
369
+ background-color: var(--ii-gray-400);
370
+ }
371
+ </style>
@@ -15,7 +15,12 @@
15
15
  showCloseButton?: boolean
16
16
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
17
17
  overlay?: 'dark' | 'light' | 'none'
18
- /** Cap the modal height (any CSS value, e.g. "60vh"). Defaults to 90vh. */
18
+ /** Vertically center the modal instead of anchoring it 15% from the top. */
19
+ centered?: boolean
20
+ /** Cap the modal height (any CSS value, e.g. "60vh"). Defaults to 78vh
21
+ * (90vh when `centered`). Note: when anchored (not centered) the modal
22
+ * sits 15% from the top, so a value above ~83vh pushes the bottom edge
23
+ * off-screen. */
19
24
  maxHeight?: string
20
25
  /** Replace the body wrapper's padding/scroll defaults — e.g. for a full-height flex body. */
21
26
  bodyClass?: string
@@ -33,6 +38,7 @@
33
38
  showCloseButton = true,
34
39
  size = 'md',
35
40
  overlay = 'dark',
41
+ centered = false,
36
42
  maxHeight,
37
43
  bodyClass,
38
44
  class: className,
@@ -61,7 +67,7 @@
61
67
  <Dialog.Portal>
62
68
  <Dialog.Overlay
63
69
  class={cn(
64
- 'fixed inset-0 z-14 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out motion-reduce:animate-none',
70
+ 'fixed inset-0 z-14 data-[state=open]:animate-modal-overlay-in data-[state=closed]:animate-modal-overlay-out motion-reduce:animate-none',
65
71
  overlay === 'dark' && 'bg-black/30',
66
72
  overlay === 'light' && 'bg-black/10',
67
73
  overlay === 'none' && 'pointer-events-none'
@@ -78,16 +84,22 @@
78
84
  if (isInsidePortaledMenu(target)) e.preventDefault()
79
85
  }}
80
86
  class={cn(
81
- // Centered vertically (top-1/2 + -translate-y-1/2) so a tall modal stays within the
82
- // viewport: with max-h-[90vh] its edges land at ~5vh/95vh. The previous top-[30%]
83
- // anchor pushed the top edge off-screen once a modal grew past ~60vh tall.
84
- 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface border border-strong rounded-12 w-[calc(100%-20px)] max-h-[90vh] flex flex-col z-15 data-[state=open]:animate-modal-in data-[state=closed]:animate-modal-out motion-reduce:animate-none focus:outline-none',
87
+ // Centering is deliberately kept OFF `transform`: horizontal uses left-0/right-0 +
88
+ // mx-auto, and vertical centering (when `centered`) uses the standalone `translate`
89
+ // property both compose with the open/close slide animation, which owns `transform`
90
+ // (translateY). A transform-based center would be clobbered by the animation and
91
+ // shift the modal for the animation's duration.
92
+ 'fixed left-0 right-0 mx-auto bg-surface border border-strong rounded-12 w-[calc(100%-20px)] flex flex-col z-15 data-[state=open]:animate-modal-in data-[state=closed]:animate-modal-out motion-reduce:animate-none focus:outline-none',
93
+ // Vertical: anchored 15% from the top (default) vs. centered. When anchored, height
94
+ // is capped so a tall modal still clears the bottom (15% + 78vh leaves ~7vh below);
95
+ // centered can safely use the full 90vh since -50% keeps it within the viewport.
96
+ centered ? 'top-1/2 [translate:0_-50%] max-h-[90vh]' : 'top-[15%] max-h-[78vh]',
85
97
  overlay === 'none' ? 'shadow-floating' : 'shadow-modal',
86
98
  size === 'sm' && 'max-w-400',
87
99
  size === 'md' && 'max-w-500',
88
100
  size === 'lg' && 'max-w-700',
89
101
  size === 'xl' && 'max-w-[1100px]',
90
- size === 'full' && 'max-w-[95vw] h-[90vh]',
102
+ size === 'full' && 'max-w-[95vw] h-[78vh]',
91
103
  className
92
104
  )}
93
105
  style={maxHeight ? `max-height: ${maxHeight}` : undefined}
@@ -10,7 +10,12 @@ type Props = {
10
10
  showCloseButton?: boolean;
11
11
  size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
12
12
  overlay?: 'dark' | 'light' | 'none';
13
- /** Cap the modal height (any CSS value, e.g. "60vh"). Defaults to 90vh. */
13
+ /** Vertically center the modal instead of anchoring it 15% from the top. */
14
+ centered?: boolean;
15
+ /** Cap the modal height (any CSS value, e.g. "60vh"). Defaults to 78vh
16
+ * (90vh when `centered`). Note: when anchored (not centered) the modal
17
+ * sits 15% from the top, so a value above ~83vh pushes the bottom edge
18
+ * off-screen. */
14
19
  maxHeight?: string;
15
20
  /** Replace the body wrapper's padding/scroll defaults — e.g. for a full-height flex body. */
16
21
  bodyClass?: string;
@@ -19,14 +19,15 @@
19
19
 
20
20
  <IIButton onclick={() => (open = true)}>Open modal</IIButton>
21
21
 
22
- {#if open}
23
- <IIModal bind:open title="Add a note" onOpenChange={v => (open = v)}>
24
- <IITextarea
25
- bind:ref
26
- bind:value
27
- label="Note"
28
- placeholder="The focus ring should be fully visible on the left, right, and bottom."
29
- rows={4}
30
- />
31
- </IIModal>
32
- {/if}
22
+ <!-- Keep IIModal mounted unconditionally (no {#if open}) so bits-ui's Dialog can
23
+ play its exit animation on close wrapping it in {#if} unmounts the whole
24
+ dialog instantly and skips the modal-out/fade-out transitions. -->
25
+ <IIModal bind:open title="Add a note" onOpenChange={v => (open = v)}>
26
+ <IITextarea
27
+ bind:ref
28
+ bind:value
29
+ label="Note"
30
+ placeholder="The focus ring should be fully visible on the left, right, and bottom."
31
+ rows={4}
32
+ />
33
+ </IIModal>
@@ -25,13 +25,11 @@ export declare const keyframes: {
25
25
  transform: string;
26
26
  };
27
27
  };
28
- modalIn: {
28
+ modalSlideIn: {
29
29
  from: {
30
- opacity: string;
31
30
  transform: string;
32
31
  };
33
32
  to: {
34
- opacity: string;
35
33
  transform: string;
36
34
  };
37
35
  };
@@ -161,6 +159,8 @@ export declare const animation: {
161
159
  skeleton: string;
162
160
  'modal-in': string;
163
161
  'modal-out': string;
162
+ 'modal-overlay-in': string;
163
+ 'modal-overlay-out': string;
164
164
  'fade-out': string;
165
165
  'login-fade-in-up': string;
166
166
  'login-pulse': string;
@@ -12,9 +12,13 @@ export const keyframes = {
12
12
  '0%': { transform: 'translate(-50%, -50%) rotate(0deg) translateX(24px)' },
13
13
  '100%': { transform: 'translate(-50%, -50%) rotate(360deg) translateX(24px)' },
14
14
  },
15
- modalIn: {
16
- from: { opacity: '0', transform: 'scale(0.96)' },
17
- to: { opacity: '1', transform: 'scale(1)' },
15
+ modalSlideIn: {
16
+ // Transform-only slide into place. Opacity is handled by a separate, faster
17
+ // `fadeIn` animation (see modal-in) so the fade can match the overlay while
18
+ // the slide runs on its own long duration. (IIModal centers via mx-auto, not
19
+ // transform, so translateY here is free of the horizontal centering.)
20
+ from: { transform: 'translateY(-10px)' },
21
+ to: { transform: 'translateY(0)' },
18
22
  },
19
23
  spin: {
20
24
  to: { transform: 'rotate(360deg)' },
@@ -60,8 +64,9 @@ export const keyframes = {
60
64
  to: { opacity: '1', transform: 'translateY(0)' },
61
65
  },
62
66
  modalOut: {
63
- from: { opacity: '1', transform: 'scale(1)' },
64
- to: { opacity: '0', transform: 'scale(0.96)' },
67
+ // Fade out while sliding up.
68
+ from: { opacity: '1', transform: 'translateY(0)' },
69
+ to: { opacity: '0', transform: 'translateY(-6px)' },
65
70
  },
66
71
  fadeOut: {
67
72
  from: { opacity: '1' },
@@ -76,8 +81,14 @@ export const animation = {
76
81
  shake: 'shake 0.4s ease-out',
77
82
  shimmer: 'shimmer 1.5s infinite',
78
83
  skeleton: 'skeleton-loading 1.2s ease-in-out infinite',
79
- 'modal-in': 'modalIn 200ms ease-out',
80
- 'modal-out': 'modalOut 150ms ease-in',
84
+ // Two decoupled animations: the opacity fade matches the overlay exactly
85
+ // (fadeIn 130ms ease-out), while the slide glides in slowly over 2500ms.
86
+ 'modal-in': 'fadeIn 130ms ease-out, modalSlideIn 2500ms cubic-bezier(0.1, 1, 0.1, 1)',
87
+ 'modal-out': 'modalOut 90ms ease-in',
88
+ // Modal-specific overlay fades — snappy, to match the modal's timing rather
89
+ // than the slower shared fade-in/fade-out used by menus, tooltips, etc.
90
+ 'modal-overlay-in': 'fadeIn 130ms ease-out',
91
+ 'modal-overlay-out': 'fadeOut 90ms ease-in',
81
92
  'fade-out': 'fadeOut 150ms ease-in',
82
93
  'login-fade-in-up': 'loginFadeInUp 0.6s ease-out',
83
94
  'login-pulse': 'loginPulse 2s ease-in-out infinite',
@@ -442,13 +442,11 @@ declare const _default: {
442
442
  transform: string;
443
443
  };
444
444
  };
445
- modalIn: {
445
+ modalSlideIn: {
446
446
  from: {
447
- opacity: string;
448
447
  transform: string;
449
448
  };
450
449
  to: {
451
- opacity: string;
452
450
  transform: string;
453
451
  };
454
452
  };
@@ -578,6 +576,8 @@ declare const _default: {
578
576
  skeleton: string;
579
577
  'modal-in': string;
580
578
  'modal-out': string;
579
+ 'modal-overlay-in': string;
580
+ 'modal-overlay-out': string;
581
581
  'fade-out': string;
582
582
  'login-fade-in-up': string;
583
583
  'login-pulse': string;
@@ -14,7 +14,7 @@
14
14
  }: Props = $props()
15
15
  </script>
16
16
 
17
- <div class="-mx-4 -mt-4 px-14 mb-4 border-b-[0.5px] border-dropdown-border">
17
+ <div class="shrink-0 -mx-4 -mt-4 px-14 mb-4 border-b-[0.5px] border-dropdown-border">
18
18
  <input
19
19
  bind:this={inputEl}
20
20
  bind:value={searchQuery}
@@ -9,5 +9,9 @@ export type UseFloatingOptions = {
9
9
  padding?: number;
10
10
  };
11
11
  matchWidth?: boolean;
12
+ /** Constrain the floating element's height to the space available in the
13
+ * viewport (via the size middleware). Lets the panel grow to fit its
14
+ * content and only cap/scroll when it would run off-screen. */
15
+ constrainHeight?: boolean;
12
16
  };
13
17
  export declare function useFloating(opts: UseFloatingOptions): void;
@@ -13,12 +13,18 @@ export function useFloating(opts) {
13
13
  const padding = typeof opts.shift === 'object' ? opts.shift.padding : 8;
14
14
  middleware.push(shiftMiddleware({ padding }));
15
15
  }
16
- if (opts.matchWidth) {
16
+ if (opts.matchWidth || opts.constrainHeight) {
17
17
  middleware.push(sizeMiddleware({
18
- apply({ rects }) {
19
- Object.assign(floating.style, {
20
- minWidth: `${rects.reference.width}px`,
21
- });
18
+ padding: 8,
19
+ apply({ rects, availableHeight }) {
20
+ const styles = {};
21
+ if (opts.matchWidth) {
22
+ styles.minWidth = `${rects.reference.width}px`;
23
+ }
24
+ if (opts.constrainHeight) {
25
+ styles.maxHeight = `${availableHeight}px`;
26
+ }
27
+ Object.assign(floating.style, styles);
22
28
  },
23
29
  }));
24
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@insymetri/styleguide",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "description": "Insymetri shared UI component library built with Svelte 5",
5
5
  "type": "module",
6
6
  "scripts": {