@invopop/popui 0.1.99 → 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,20 +1,12 @@
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
- function portal(node: HTMLElement) {
9
- const target = typeof document !== 'undefined' ? document.body : null
10
- if (target) target.appendChild(node)
11
- return {
12
- destroy() {
13
- if (node.parentNode) node.parentNode.removeChild(node)
14
- }
15
- }
16
- }
17
-
18
10
  let {
19
11
  ref = $bindable(null),
20
12
  class: className,
@@ -29,50 +21,77 @@
29
21
  let dragMoved = false
30
22
  let dragDirection: 1 | -1 = 1
31
23
 
32
- let isHovering = $state(false)
33
24
  let isDragging = $state(false)
34
- let tooltipVisible = $derived(isHovering && !isDragging)
35
- let tooltipX = $state(0)
36
- let tooltipY = $state(0)
37
- let tooltipWidth = $state(0)
38
- let tooltipHeight = $state(0)
25
+ let tooltipOpen = $state(false)
26
+ let cursorX = $state(0)
27
+ let cursorY = $state(0)
39
28
 
29
+ const TOOLTIP_HOVER_DELAY_MS = 700
40
30
  const TOOLTIP_CURSOR_OFFSET = 12
41
- const TOOLTIP_VIEWPORT_PADDING = 8
42
-
43
- let tooltipLeft = $derived.by(() => {
44
- if (tooltipWidth === 0 || typeof window === 'undefined') return tooltipX
45
- const desired = tooltipX - tooltipWidth / 2
46
- const minX = TOOLTIP_VIEWPORT_PADDING
47
- const maxX = window.innerWidth - tooltipWidth - TOOLTIP_VIEWPORT_PADDING
48
- return Math.max(minX, Math.min(maxX, desired))
49
- })
50
31
 
51
- let tooltipTop = $derived.by(() => {
52
- const desired = tooltipY + TOOLTIP_CURSOR_OFFSET
53
- if (tooltipHeight === 0 || typeof window === 'undefined') return desired
54
- const maxY = window.innerHeight - tooltipHeight - TOOLTIP_VIEWPORT_PADDING
55
- 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
+ }
56
38
  })
57
39
 
58
- function updateTooltipPosition(e: PointerEvent) {
59
- tooltipX = e.clientX
60
- tooltipY = e.clientY
61
- }
62
-
63
40
  function onPointerEnter(e: PointerEvent) {
64
41
  const sidebarRoot = (e.currentTarget as HTMLElement).closest('[data-slot="sidebar"]')
65
42
  dragDirection = sidebarRoot?.getAttribute('data-side') === 'right' ? -1 : 1
66
- updateTooltipPosition(e)
67
- isHovering = true
43
+ cursorX = e.clientX
44
+ cursorY = e.clientY
68
45
  }
69
46
 
70
- function onPointerLeave() {
71
- 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)
72
91
  }
73
92
 
74
93
  function onPointerDown(e: PointerEvent) {
75
- if (sidebar.state !== 'expanded' || sidebar.isMobile) return
94
+ if (sidebar.isMobile) return
76
95
  const button = e.currentTarget as HTMLButtonElement
77
96
  const sidebarRoot = button.closest('[data-slot="sidebar"]')
78
97
  dragDirection = sidebarRoot?.getAttribute('data-side') === 'right' ? -1 : 1
@@ -86,77 +105,105 @@
86
105
  document.body.style.userSelect = 'none'
87
106
  }
88
107
 
89
- function onPointerMove(e: PointerEvent) {
90
- if (tooltipVisible) updateTooltipPosition(e)
91
- const button = e.currentTarget as HTMLButtonElement
92
- if (!button.hasPointerCapture(e.pointerId)) return
93
- const delta = (e.clientX - dragStartX) * dragDirection
94
- if (Math.abs(delta) > SIDEBAR_DRAG_THRESHOLD_PX) {
95
- dragMoved = true
96
- isDragging = true
97
- }
98
- if (dragMoved) sidebar.setWidth(dragStartWidthPx + delta)
99
- }
100
-
101
108
  function onPointerUp(e: PointerEvent) {
102
109
  const button = e.currentTarget as HTMLButtonElement
103
110
  if (button.hasPointerCapture(e.pointerId)) button.releasePointerCapture(e.pointerId)
104
111
  document.body.style.cursor = ''
105
112
  document.body.style.userSelect = ''
106
113
  isDragging = false
114
+ sidebar.isResizing = false
115
+ if (dragMoved) {
116
+ dragEndTime = Date.now()
117
+ dragMoved = false
118
+ }
107
119
  }
108
120
 
121
+ const DOUBLE_CLICK_DELAY_MS = 300
122
+ let pendingClickTimer: ReturnType<typeof setTimeout> | undefined
123
+
109
124
  function onClick(e: MouseEvent) {
110
- if (dragMoved) {
125
+ if (Date.now() - dragEndTime < POST_DRAG_CLICK_GUARD_MS) {
111
126
  e.preventDefault()
112
127
  e.stopPropagation()
113
- dragMoved = false
114
128
  return
115
129
  }
116
- 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()
117
143
  }
118
144
  </script>
119
145
 
120
- <button
121
- bind:this={ref}
122
- data-sidebar="rail"
123
- data-slot="sidebar-rail"
124
- aria-label="Toggle Sidebar"
125
- tabindex={-1}
126
- type="button"
127
- onpointerenter={onPointerEnter}
128
- onpointerleave={onPointerLeave}
129
- onpointerdown={onPointerDown}
130
- onpointermove={onPointerMove}
131
- onpointerup={onPointerUp}
132
- onpointercancel={onPointerUp}
133
- onclick={onClick}
134
- class={cn(
135
- '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',
136
- 'after:absolute after:inset-y-0 after:left-1/2 after:w-[2px]',
137
- 'hover:after:bg-sidebar-border',
138
- 'group-data-[side=left]:cursor-w-resize group-data-[side=right]:cursor-e-resize',
139
- '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
140
- 'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
141
- '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
142
- '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
143
- className
144
- )}
145
- {...restProps}
146
+ <TooltipPrimitive.Root
147
+ bind:open={tooltipOpen}
148
+ delayDuration={TOOLTIP_HOVER_DELAY_MS}
149
+ disableHoverableContent
146
150
  >
147
- {@render children?.()}
148
- </button>
149
-
150
- {#if tooltipVisible}
151
- <div
152
- use:portal
153
- role="tooltip"
154
- bind:offsetWidth={tooltipWidth}
155
- bind:offsetHeight={tooltipHeight}
156
- 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"
157
- style="left: {tooltipLeft}px; top: {tooltipTop}px; visibility: {tooltipWidth > 0
158
- ? 'visible'
159
- : '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"
160
207
  >
161
208
  <div class="flex flex-col gap-1.5">
162
209
  {#if sidebar.state === 'expanded'}
@@ -172,5 +219,5 @@
172
219
  </div>
173
220
  </div>
174
221
  </div>
175
- </div>
176
- {/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.99",
4
+ "version": "0.1.100",
5
5
  "repository": {
6
6
  "url": "https://github.com/invopop/popui"
7
7
  },