@invopop/popui 0.1.98 → 0.1.100

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.
@@ -41,7 +41,7 @@
41
41
  let highlight = $state(false)
42
42
  let leaveHoverTimeout: ReturnType<typeof setTimeout> | null = null
43
43
  let rowStyles = $derived(
44
- clsx('flex items-center rounded-lg border border-transparent', {
44
+ clsx('flex items-center rounded-lg', {
45
45
  'py-1 pr-1': !collapsedSidebar,
46
46
  'pl-2': !collapsedSidebar && !imageUrl,
47
47
  'pl-[10px]': !collapsedSidebar && imageUrl,
@@ -165,7 +165,7 @@
165
165
  </span>
166
166
  </button>
167
167
  {#if !collapsedSidebar && action}
168
- <span class="shrink-0" data-menu-item-action>
168
+ <span class="shrink-0 flex items-center" data-menu-item-action>
169
169
  {@render action()}
170
170
  </span>
171
171
  {/if}
@@ -9,11 +9,13 @@ declare class SidebarState {
9
9
  open: boolean;
10
10
  openMobile: boolean;
11
11
  width: string;
12
+ isResizing: boolean;
12
13
  setOpen: SidebarStateProps['setOpen'];
13
14
  state: string;
14
15
  constructor(props: SidebarStateProps);
15
16
  get isMobile(): boolean;
16
17
  setWidth: (px: number) => void;
18
+ resetWidth: () => void;
17
19
  handleShortcutKeydown: (e: KeyboardEvent) => void;
18
20
  setOpenMobile: (value: boolean) => void;
19
21
  toggle: () => boolean | void;
@@ -6,6 +6,7 @@ class SidebarState {
6
6
  open = $derived.by(() => this.props.open());
7
7
  openMobile = $state(false);
8
8
  width = $state(SIDEBAR_WIDTH);
9
+ isResizing = $state(false);
9
10
  setOpen;
10
11
  #isMobile;
11
12
  state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));
@@ -34,6 +35,17 @@ class SidebarState {
34
35
  }
35
36
  }
36
37
  };
38
+ resetWidth = () => {
39
+ this.width = SIDEBAR_WIDTH;
40
+ if (typeof localStorage !== 'undefined') {
41
+ try {
42
+ localStorage.removeItem(SIDEBAR_WIDTH_STORAGE_KEY);
43
+ }
44
+ catch {
45
+ // ignore private-mode errors
46
+ }
47
+ }
48
+ };
37
49
  handleShortcutKeydown = (e) => {
38
50
  if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
39
51
  e.preventDefault();
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
- import type { HTMLAttributes } from 'svelte/elements'
2
+ import type { HTMLAttributes, HTMLButtonAttributes } from 'svelte/elements'
3
+ import { Tooltip as TooltipPrimitive } from 'bits-ui'
3
4
  import ShortcutWrapper from '../ShortcutWrapper.svelte'
5
+ import TooltipContent from '../tooltip/tooltip-content.svelte'
4
6
  import { cn, type WithElementRef } from '../utils.js'
5
- import { SIDEBAR_DRAG_THRESHOLD_PX } from './constants.js'
7
+ import { SIDEBAR_DRAG_THRESHOLD_PX, SIDEBAR_MIN_WIDTH_PX } from './constants.js'
6
8
  import { useSidebar } from './context.svelte.js'
7
9
 
8
10
  let {
@@ -19,50 +21,77 @@
19
21
  let dragMoved = false
20
22
  let dragDirection: 1 | -1 = 1
21
23
 
22
- let isHovering = $state(false)
23
24
  let isDragging = $state(false)
24
- let tooltipVisible = $derived(isHovering && !isDragging)
25
- let tooltipX = $state(0)
26
- let tooltipY = $state(0)
27
- let tooltipWidth = $state(0)
28
- let tooltipHeight = $state(0)
25
+ let tooltipOpen = $state(false)
26
+ let cursorX = $state(0)
27
+ let cursorY = $state(0)
29
28
 
29
+ const TOOLTIP_HOVER_DELAY_MS = 700
30
30
  const TOOLTIP_CURSOR_OFFSET = 12
31
- const TOOLTIP_VIEWPORT_PADDING = 8
32
-
33
- let tooltipLeft = $derived.by(() => {
34
- if (tooltipWidth === 0 || typeof window === 'undefined') return tooltipX
35
- const desired = tooltipX - tooltipWidth / 2
36
- const minX = TOOLTIP_VIEWPORT_PADDING
37
- const maxX = window.innerWidth - tooltipWidth - TOOLTIP_VIEWPORT_PADDING
38
- return Math.max(minX, Math.min(maxX, desired))
39
- })
40
31
 
41
- let tooltipTop = $derived.by(() => {
42
- const desired = tooltipY + TOOLTIP_CURSOR_OFFSET
43
- if (tooltipHeight === 0 || typeof window === 'undefined') return desired
44
- const maxY = window.innerHeight - tooltipHeight - TOOLTIP_VIEWPORT_PADDING
45
- return Math.min(maxY, desired)
32
+ let cursorAnchor = $derived.by(() => {
33
+ const x = cursorX
34
+ const y = cursorY
35
+ return {
36
+ getBoundingClientRect: () => DOMRect.fromRect({ x, y, width: 0, height: 0 })
37
+ }
46
38
  })
47
39
 
48
- function updateTooltipPosition(e: PointerEvent) {
49
- tooltipX = e.clientX
50
- tooltipY = e.clientY
51
- }
52
-
53
40
  function onPointerEnter(e: PointerEvent) {
54
41
  const sidebarRoot = (e.currentTarget as HTMLElement).closest('[data-slot="sidebar"]')
55
42
  dragDirection = sidebarRoot?.getAttribute('data-side') === 'right' ? -1 : 1
56
- updateTooltipPosition(e)
57
- isHovering = true
43
+ cursorX = e.clientX
44
+ cursorY = e.clientY
58
45
  }
59
46
 
60
- function onPointerLeave() {
61
- isHovering = false
47
+ const COLLAPSE_DRAG_OVERSHOOT_PX = 100
48
+ const POST_DRAG_CLICK_GUARD_MS = 250
49
+ let dragEndTime = 0
50
+
51
+ function onPointerMove(e: PointerEvent) {
52
+ cursorX = e.clientX
53
+ cursorY = e.clientY
54
+ const button = e.currentTarget as HTMLButtonElement
55
+ if (!button.hasPointerCapture(e.pointerId)) return
56
+ const delta = (e.clientX - dragStartX) * dragDirection
57
+ if (Math.abs(delta) > SIDEBAR_DRAG_THRESHOLD_PX) {
58
+ dragMoved = true
59
+ isDragging = true
60
+ sidebar.isResizing = true
61
+ tooltipOpen = false
62
+ }
63
+ if (!dragMoved) return
64
+ if (sidebar.state === 'collapsed') {
65
+ if (delta > 0) {
66
+ button.releasePointerCapture(e.pointerId)
67
+ document.body.style.cursor = ''
68
+ document.body.style.userSelect = ''
69
+ isDragging = false
70
+ sidebar.isResizing = false
71
+ dragMoved = false
72
+ dragEndTime = Date.now()
73
+ sidebar.setOpen(true)
74
+ }
75
+ return
76
+ }
77
+ const targetWidth = dragStartWidthPx + delta
78
+ if (targetWidth < SIDEBAR_MIN_WIDTH_PX - COLLAPSE_DRAG_OVERSHOOT_PX) {
79
+ button.releasePointerCapture(e.pointerId)
80
+ document.body.style.cursor = ''
81
+ document.body.style.userSelect = ''
82
+ isDragging = false
83
+ sidebar.isResizing = false
84
+ dragMoved = false
85
+ dragEndTime = Date.now()
86
+ sidebar.resetWidth()
87
+ sidebar.setOpen(false)
88
+ return
89
+ }
90
+ sidebar.setWidth(targetWidth)
62
91
  }
63
92
 
64
93
  function onPointerDown(e: PointerEvent) {
65
- if (sidebar.state !== 'expanded' || sidebar.isMobile) return
94
+ if (sidebar.isMobile) return
66
95
  const button = e.currentTarget as HTMLButtonElement
67
96
  const sidebarRoot = button.closest('[data-slot="sidebar"]')
68
97
  dragDirection = sidebarRoot?.getAttribute('data-side') === 'right' ? -1 : 1
@@ -76,76 +105,105 @@
76
105
  document.body.style.userSelect = 'none'
77
106
  }
78
107
 
79
- function onPointerMove(e: PointerEvent) {
80
- if (tooltipVisible) updateTooltipPosition(e)
81
- const button = e.currentTarget as HTMLButtonElement
82
- if (!button.hasPointerCapture(e.pointerId)) return
83
- const delta = (e.clientX - dragStartX) * dragDirection
84
- if (Math.abs(delta) > SIDEBAR_DRAG_THRESHOLD_PX) {
85
- dragMoved = true
86
- isDragging = true
87
- }
88
- if (dragMoved) sidebar.setWidth(dragStartWidthPx + delta)
89
- }
90
-
91
108
  function onPointerUp(e: PointerEvent) {
92
109
  const button = e.currentTarget as HTMLButtonElement
93
110
  if (button.hasPointerCapture(e.pointerId)) button.releasePointerCapture(e.pointerId)
94
111
  document.body.style.cursor = ''
95
112
  document.body.style.userSelect = ''
96
113
  isDragging = false
114
+ sidebar.isResizing = false
115
+ if (dragMoved) {
116
+ dragEndTime = Date.now()
117
+ dragMoved = false
118
+ }
97
119
  }
98
120
 
121
+ const DOUBLE_CLICK_DELAY_MS = 300
122
+ let pendingClickTimer: ReturnType<typeof setTimeout> | undefined
123
+
99
124
  function onClick(e: MouseEvent) {
100
- if (dragMoved) {
125
+ if (Date.now() - dragEndTime < POST_DRAG_CLICK_GUARD_MS) {
101
126
  e.preventDefault()
102
127
  e.stopPropagation()
103
- dragMoved = false
104
128
  return
105
129
  }
106
- sidebar.toggle()
130
+ clearTimeout(pendingClickTimer)
131
+ pendingClickTimer = setTimeout(() => {
132
+ pendingClickTimer = undefined
133
+ sidebar.toggle()
134
+ }, DOUBLE_CLICK_DELAY_MS)
135
+ }
136
+
137
+ function onDoubleClick() {
138
+ if (sidebar.isMobile) return
139
+ clearTimeout(pendingClickTimer)
140
+ pendingClickTimer = undefined
141
+ sidebar.setOpen(true)
142
+ sidebar.resetWidth()
107
143
  }
108
144
  </script>
109
145
 
110
- <button
111
- bind:this={ref}
112
- data-sidebar="rail"
113
- data-slot="sidebar-rail"
114
- aria-label="Toggle Sidebar"
115
- tabindex={-1}
116
- type="button"
117
- onpointerenter={onPointerEnter}
118
- onpointerleave={onPointerLeave}
119
- onpointerdown={onPointerDown}
120
- onpointermove={onPointerMove}
121
- onpointerup={onPointerUp}
122
- onpointercancel={onPointerUp}
123
- onclick={onClick}
124
- class={cn(
125
- 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
126
- 'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px]',
127
- 'hover:after:bg-sidebar-border',
128
- 'group-data-[side=left]:cursor-w-resize group-data-[side=right]:cursor-e-resize',
129
- '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
130
- 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
131
- '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
132
- '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
133
- className
134
- )}
135
- {...restProps}
146
+ <TooltipPrimitive.Root
147
+ bind:open={tooltipOpen}
148
+ delayDuration={TOOLTIP_HOVER_DELAY_MS}
149
+ disableHoverableContent
136
150
  >
137
- {@render children?.()}
138
- </button>
139
-
140
- {#if tooltipVisible}
141
- <div
142
- role="tooltip"
143
- bind:offsetWidth={tooltipWidth}
144
- bind:offsetHeight={tooltipHeight}
145
- class="fixed z-[1002] pointer-events-none rounded-md border border-border-inverse bg-background-default-negative px-3 py-2 text-sm font-medium text-foreground-inverse leading-5 tracking-tight shadow-md"
146
- style="left: {tooltipLeft}px; top: {tooltipTop}px; visibility: {tooltipWidth > 0
147
- ? 'visible'
148
- : 'hidden'};"
151
+ <TooltipPrimitive.Trigger disabled={isDragging}>
152
+ {#snippet child({ props })}
153
+ {@const buttonProps = props as HTMLButtonAttributes}
154
+ <button
155
+ bind:this={ref}
156
+ {...buttonProps}
157
+ onpointerenter={(e) => {
158
+ buttonProps.onpointerenter?.(e)
159
+ onPointerEnter(e)
160
+ }}
161
+ onpointermove={(e) => {
162
+ buttonProps.onpointermove?.(e)
163
+ onPointerMove(e)
164
+ }}
165
+ onpointerdown={(e) => {
166
+ buttonProps.onpointerdown?.(e)
167
+ onPointerDown(e)
168
+ }}
169
+ onpointerup={(e) => {
170
+ buttonProps.onpointerup?.(e)
171
+ onPointerUp(e)
172
+ }}
173
+ onpointercancel={onPointerUp}
174
+ onclick={(e) => {
175
+ buttonProps.onclick?.(e)
176
+ onClick(e)
177
+ }}
178
+ ondblclick={onDoubleClick}
179
+ data-sidebar="rail"
180
+ data-slot="sidebar-rail"
181
+ aria-label="Toggle Sidebar"
182
+ tabindex={-1}
183
+ type="button"
184
+ class={cn(
185
+ 'absolute inset-y-0 z-50 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
186
+ 'after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:transition-colors after:duration-150',
187
+ 'hover:after:delay-150 hover:after:bg-background-accent-default',
188
+ 'group-data-[side=left]:cursor-w-resize group-data-[side=right]:cursor-e-resize',
189
+ '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
190
+ 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
191
+ '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
192
+ '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
193
+ className
194
+ )}
195
+ {...restProps}
196
+ >
197
+ {@render children?.()}
198
+ </button>
199
+ {/snippet}
200
+ </TooltipPrimitive.Trigger>
201
+ <TooltipContent
202
+ customAnchor={cursorAnchor}
203
+ side="bottom"
204
+ align="center"
205
+ sideOffset={TOOLTIP_CURSOR_OFFSET}
206
+ class="px-3 py-2"
149
207
  >
150
208
  <div class="flex flex-col gap-1.5">
151
209
  {#if sidebar.state === 'expanded'}
@@ -161,5 +219,5 @@
161
219
  </div>
162
220
  </div>
163
221
  </div>
164
- </div>
165
- {/if}
222
+ </TooltipContent>
223
+ </TooltipPrimitive.Root>
@@ -67,12 +67,13 @@
67
67
  data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
68
68
  data-variant={variant}
69
69
  data-side={side}
70
+ data-resizing={sidebar.isResizing ? 'true' : undefined}
70
71
  data-slot="sidebar"
71
72
  >
72
73
  <div
73
74
  data-slot="sidebar-gap"
74
75
  class={cn(
75
- 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
76
+ 'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear group-data-[resizing=true]:transition-none',
76
77
  'group-data-[collapsible=offcanvas]:w-0',
77
78
  'group-data-[side=right]:rotate-180',
78
79
  variant === 'floating' || variant === 'inset'
@@ -83,7 +84,7 @@
83
84
  <div
84
85
  data-slot="sidebar-container"
85
86
  class={cn(
86
- 'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
87
+ 'fixed inset-y-0 z-50 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear group-data-[resizing=true]:transition-none md:flex',
87
88
  side === 'left'
88
89
  ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
89
90
  : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
@@ -8,7 +8,8 @@
8
8
  side = 'top',
9
9
  children,
10
10
  showArrow = false,
11
- arrowClasses
11
+ arrowClasses,
12
+ ...rest
12
13
  }: TooltipPrimitive.ContentProps & {
13
14
  showArrow?: boolean
14
15
  arrowClasses?: string
@@ -21,6 +22,7 @@
21
22
  data-slot="tooltip-content"
22
23
  {sideOffset}
23
24
  {side}
25
+ {...rest}
24
26
  class={cn(
25
27
  'bg-background-default-negative border border-border-inverse z-[1002] rounded-md px-2 py-1 text-sm font-medium text-foreground-inverse leading-5 tracking-tight shadow-md',
26
28
  className
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@invopop/popui",
3
3
  "license": "MIT",
4
- "version": "0.1.98",
4
+ "version": "0.1.100",
5
5
  "repository": {
6
6
  "url": "https://github.com/invopop/popui"
7
7
  },