@hina-ui/vue 1.4.0 → 1.5.0

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.
Files changed (45) hide show
  1. package/dist/index.js +330 -148
  2. package/dist/index.js.map +1 -1
  3. package/dist/types/components/dialog/Dialog.vue.d.ts +3 -1
  4. package/dist/types/components/dialog/Dialog.vue.d.ts.map +1 -1
  5. package/dist/types/components/drawer/Drawer.vue.d.ts +21 -14
  6. package/dist/types/components/drawer/Drawer.vue.d.ts.map +1 -1
  7. package/dist/types/components/drawer/drawer.variants.d.ts +10 -1
  8. package/dist/types/components/drawer/drawer.variants.d.ts.map +1 -1
  9. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts +4 -1
  10. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts.map +1 -1
  11. package/dist/types/components/hover-card/HoverCard.vue.d.ts +4 -1
  12. package/dist/types/components/hover-card/HoverCard.vue.d.ts.map +1 -1
  13. package/dist/types/components/hover-card/HoverCardAnchor.vue.d.ts +6 -4
  14. package/dist/types/components/hover-card/HoverCardAnchor.vue.d.ts.map +1 -1
  15. package/dist/types/components/hover-card/composables/useHoverCardAnchor.d.ts +9 -4
  16. package/dist/types/components/hover-card/composables/useHoverCardAnchor.d.ts.map +1 -1
  17. package/dist/types/components/popover/Popover.vue.d.ts +4 -1
  18. package/dist/types/components/popover/Popover.vue.d.ts.map +1 -1
  19. package/dist/types/components/sheet/Sheet.vue.d.ts +21 -14
  20. package/dist/types/components/sheet/Sheet.vue.d.ts.map +1 -1
  21. package/dist/types/components/sheet/sheet.variants.d.ts +54 -3
  22. package/dist/types/components/sheet/sheet.variants.d.ts.map +1 -1
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/index.d.ts.map +1 -1
  25. package/dist/types/lib/anchored-overlay.d.ts +12 -2
  26. package/dist/types/lib/anchored-overlay.d.ts.map +1 -1
  27. package/dist/types/lib/overlay-anchor.d.ts +16 -0
  28. package/dist/types/lib/overlay-anchor.d.ts.map +1 -0
  29. package/dist/types/lib/scroll-viewport.d.ts +9 -0
  30. package/dist/types/lib/scroll-viewport.d.ts.map +1 -0
  31. package/package.json +1 -1
  32. package/src/components/dialog/Dialog.vue +5 -1
  33. package/src/components/drawer/Drawer.vue +69 -32
  34. package/src/components/drawer/drawer.variants.ts +6 -2
  35. package/src/components/dropdown-menu/DropdownMenu.vue +18 -5
  36. package/src/components/hover-card/HoverCard.vue +6 -1
  37. package/src/components/hover-card/HoverCardAnchor.vue +3 -1
  38. package/src/components/hover-card/composables/useHoverCardAnchor.ts +33 -42
  39. package/src/components/popover/Popover.vue +17 -7
  40. package/src/components/sheet/Sheet.vue +67 -24
  41. package/src/components/sheet/sheet.variants.ts +17 -4
  42. package/src/index.ts +1 -0
  43. package/src/lib/anchored-overlay.ts +28 -19
  44. package/src/lib/overlay-anchor.ts +65 -0
  45. package/src/lib/scroll-viewport.ts +8 -0
@@ -14,8 +14,9 @@
14
14
  import ScrollArea from '../scroll-area/ScrollArea.vue'
15
15
  import Heading from '../heading/Heading.vue'
16
16
  import Text from '../text/Text.vue'
17
+ import { useOverlayPortal } from '../../lib/overlay-portal'
18
+ import { useScrollViewport } from '../../lib/scroll-viewport'
17
19
  import { cn } from '../../lib/cn'
18
- import { useUiLocale } from '../../locale'
19
20
  import { drawerCard } from './drawer.variants'
20
21
 
21
22
  defineOptions({ name: 'HnDrawer' })
@@ -26,18 +27,32 @@
26
27
  description?: string
27
28
  side?: 'start' | 'end'
28
29
  size?: 'sm' | 'md' | 'lg'
30
+ header?: boolean
31
+ closable?: boolean
29
32
  locked?: boolean
30
33
  class?: string
31
34
  }>(),
32
- { side: 'end', size: 'md', locked: false },
35
+ { side: 'end', size: 'md', header: true, closable: true, locked: false },
33
36
  )
34
37
 
38
+ const slots = defineSlots<{
39
+ default?(): unknown
40
+ icon?(): unknown
41
+ title?(): unknown
42
+ body?(props: { close: () => void }): unknown
43
+ content?(props: { close: () => void }): unknown
44
+ footer?(props: { close: () => void }): unknown
45
+ }>()
46
+
35
47
  function guard(e: Event) {
36
48
  if (props.locked) e.preventDefault()
37
49
  }
38
50
 
51
+ const { scrollArea, viewport } = useScrollViewport()
52
+ defineExpose({ viewport })
53
+
39
54
  const open = defineModel<boolean>('open')
40
- const t = useUiLocale()
55
+ const { content, present } = useOverlayPortal(open)
41
56
 
42
57
  function close() {
43
58
  open.value = false
@@ -49,45 +64,67 @@
49
64
  <DialogTrigger v-if="$slots.default" as-child>
50
65
  <slot />
51
66
  </DialogTrigger>
52
- <DialogPortal>
67
+ <DialogPortal v-if="present">
53
68
  <DialogOverlay class="hn-scrim" />
54
69
  <DialogContent as-child @escape-key-down="guard" @interact-outside="guard">
55
70
  <Card
71
+ ref="content"
72
+ v-bind="props.description ? {} : { 'aria-describedby': undefined }"
56
73
  :data-hn-side="props.side"
57
74
  :padded="false"
58
75
  :class="
59
- cn(
60
- 'hn-anim-drawer pointer-events-auto fixed inset-y-0 z-(--hn-z-overlay) flex flex-col gap-4',
61
- 'max-w-[calc(100vw-3rem)] rounded-none py-(--hn-panel-p) shadow-lg outline-none',
62
- drawerCard({ side: props.side, size: props.size }),
63
- props.class,
64
- )
76
+ cn(drawerCard({ side: props.side, size: props.size, padded: !slots.body }), props.class)
65
77
  "
66
78
  >
67
- <div class="flex shrink-0 items-start justify-between gap-4 px-(--hn-panel-p)">
68
- <div class="flex min-w-0 flex-col gap-1.5">
69
- <DialogTitle as-child>
70
- <Heading :level="2" size="lg">{{ props.title }}</Heading>
71
- </DialogTitle>
72
- <DialogDescription v-if="props.description" as-child>
73
- <Text tone="muted">{{ props.description }}</Text>
74
- </DialogDescription>
79
+ <template v-if="!props.header || slots.body">
80
+ <DialogTitle as-child>
81
+ <Heading :level="2" class="sr-only">{{ props.title }}</Heading>
82
+ </DialogTitle>
83
+ <DialogDescription v-if="props.description" class="sr-only">
84
+ {{ props.description }}
85
+ </DialogDescription>
86
+ </template>
87
+ <slot v-if="slots.body" name="body" :close="close" />
88
+ <template v-else>
89
+ <div
90
+ v-if="props.header"
91
+ class="flex shrink-0 items-start justify-between gap-4 px-(--hn-panel-p)"
92
+ >
93
+ <div class="flex min-w-0 flex-col gap-1.5">
94
+ <div class="flex min-w-0 items-center gap-2">
95
+ <span
96
+ v-if="slots.icon"
97
+ class="text-muted flex shrink-0 [&_svg]:size-5"
98
+ aria-hidden="true"
99
+ >
100
+ <slot name="icon" />
101
+ </span>
102
+ <DialogTitle as-child>
103
+ <Heading :level="2" size="lg" class="min-w-0">
104
+ <slot name="title">{{ props.title }}</slot>
105
+ </Heading>
106
+ </DialogTitle>
107
+ </div>
108
+ <DialogDescription v-if="props.description" as-child>
109
+ <Text tone="muted">{{ props.description }}</Text>
110
+ </DialogDescription>
111
+ </div>
112
+ <DialogClose v-if="props.closable" as-child>
113
+ <CloseButton :disabled="props.locked" class="-mt-1.5 -me-1.5 shrink-0" />
114
+ </DialogClose>
75
115
  </div>
76
- <DialogClose as-child>
77
- <CloseButton :disabled="props.locked" class="-mt-1.5 -me-1.5 shrink-0" />
78
- </DialogClose>
79
- </div>
80
- <ScrollArea v-if="$slots.content" class="min-h-0 grow">
81
- <div class="px-(--hn-panel-p) py-1">
82
- <slot name="content" :close="close" />
116
+ <ScrollArea v-if="$slots.content" ref="scrollArea" class="min-h-0 grow">
117
+ <div class="px-(--hn-panel-p) py-1">
118
+ <slot name="content" :close="close" />
119
+ </div>
120
+ </ScrollArea>
121
+ <div
122
+ v-if="$slots.footer"
123
+ class="flex shrink-0 justify-end gap-(--hn-inline-gap) px-(--hn-panel-p)"
124
+ >
125
+ <slot name="footer" :close="close" />
83
126
  </div>
84
- </ScrollArea>
85
- <div
86
- v-if="$slots.footer"
87
- class="flex shrink-0 justify-end gap-(--hn-inline-gap) px-(--hn-panel-p)"
88
- >
89
- <slot name="footer" :close="close" />
90
- </div>
127
+ </template>
91
128
  </Card>
92
129
  </DialogContent>
93
130
  </DialogPortal>
@@ -1,8 +1,12 @@
1
1
  import { tv, type VariantProps } from '../../lib/tv'
2
2
 
3
3
  export const drawerCard = tv({
4
- base: '',
4
+ base: [
5
+ 'hn-anim-drawer pointer-events-auto fixed inset-y-0 z-(--hn-z-overlay) flex flex-col',
6
+ 'max-w-[calc(100vw-3rem)] rounded-none shadow-lg outline-none',
7
+ ],
5
8
  variants: {
9
+ padded: { true: 'gap-4 py-(--hn-panel-p)' },
6
10
  side: {
7
11
  start: 'start-0',
8
12
  end: 'end-0',
@@ -13,7 +17,7 @@ export const drawerCard = tv({
13
17
  lg: 'w-120',
14
18
  },
15
19
  },
16
- defaultVariants: { side: 'end', size: 'md' },
20
+ defaultVariants: { side: 'end', size: 'md', padded: true },
17
21
  })
18
22
 
19
23
  export type DrawerVariants = VariantProps<typeof drawerCard>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import type { OverlayAnchor, OverlayPositionStrategy } from '../../lib/overlay-anchor'
2
3
  import { computed } from 'vue'
3
4
  import {
4
5
  DropdownMenuRoot,
@@ -16,7 +17,8 @@
16
17
  const props = withDefaults(
17
18
  defineProps<{
18
19
  label?: string
19
- anchor?: HTMLElement | null
20
+ anchor?: OverlayAnchor | null
21
+ updatePositionStrategy?: OverlayPositionStrategy
20
22
  modal?: boolean
21
23
  dir?: 'ltr' | 'rtl'
22
24
  side?: 'top' | 'right' | 'bottom' | 'left'
@@ -24,13 +26,23 @@
24
26
  sideOffset?: number
25
27
  class?: string
26
28
  }>(),
27
- { side: 'bottom', align: 'center', sideOffset: 8, modal: true },
29
+ {
30
+ updatePositionStrategy: 'optimized',
31
+ side: 'bottom',
32
+ align: 'center',
33
+ sideOffset: 8,
34
+ modal: true,
35
+ },
28
36
  )
29
37
 
30
38
  const open = defineModel<boolean>('open')
31
39
  const emit = defineEmits<DropdownMenuContentEmits>()
32
40
  defineSlots<{ default?(): unknown; content?(): unknown }>()
33
- const { trigger, reference, visible, events } = useAnchoredOverlay(props, open, emit)
41
+ const { trigger, content, present, reference, visible, events } = useAnchoredOverlay(
42
+ props,
43
+ open,
44
+ emit,
45
+ )
34
46
 
35
47
  const pressOrigin = computed(() => {
36
48
  if (props.side === 'left') return 'right'
@@ -52,17 +64,18 @@
52
64
  >
53
65
  <slot />
54
66
  </DropdownMenuTrigger>
55
- <DropdownMenuPortal>
67
+ <DropdownMenuPortal v-if="present">
56
68
  <DropdownMenuContent
57
- v-if="reference"
58
69
  v-bind="{ 'aria-label': props.label, ...$attrs, ...events }"
59
70
  :reference="reference"
71
+ :update-position-strategy="props.updatePositionStrategy"
60
72
  as-child
61
73
  :side="props.side"
62
74
  :align="props.align"
63
75
  :side-offset="props.sideOffset"
64
76
  >
65
77
  <Card
78
+ ref="content"
66
79
  :padded="false"
67
80
  :class="
68
81
  cn(
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import type { OverlayAnchor, OverlayPositionStrategy } from '../../lib/overlay-anchor'
2
3
  import { HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger } from 'reka-ui'
3
4
  import { cn } from '../../lib/cn'
4
5
  import Card from '../card/Card.vue'
@@ -8,7 +9,8 @@
8
9
 
9
10
  const props = withDefaults(
10
11
  defineProps<{
11
- anchor?: HTMLElement | null
12
+ anchor?: OverlayAnchor | null
13
+ updatePositionStrategy?: OverlayPositionStrategy
12
14
  side?: 'top' | 'right' | 'bottom' | 'left'
13
15
  align?: 'start' | 'center' | 'end'
14
16
  sideOffset?: number
@@ -19,6 +21,7 @@
19
21
  positionerClass?: string
20
22
  }>(),
21
23
  {
24
+ updatePositionStrategy: 'optimized',
22
25
  side: 'bottom',
23
26
  align: 'center',
24
27
  sideOffset: 8,
@@ -41,6 +44,7 @@
41
44
  <HoverCardAnchor
42
45
  v-slot="{ reference, contentRef, present }"
43
46
  :anchor="props.anchor"
47
+ :update-position-strategy="props.updatePositionStrategy"
44
48
  :external="!$slots.default"
45
49
  :close-delay="props.closeDelay"
46
50
  :positioner-class="props.positionerClass"
@@ -48,6 +52,7 @@
48
52
  <HoverCardPortal v-if="present">
49
53
  <HoverCardContent
50
54
  :reference="reference"
55
+ :update-position-strategy="props.updatePositionStrategy"
51
56
  as-child
52
57
  :side="props.side"
53
58
  :align="props.align"
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
+ import type { OverlayAnchor, OverlayPositionStrategy } from '../../lib/overlay-anchor'
2
3
  import { useHoverCardAnchor } from './composables/useHoverCardAnchor'
3
4
 
4
5
  defineOptions({ name: 'HnHoverCardAnchor' })
5
6
  const props = defineProps<{
6
- anchor?: HTMLElement | null
7
+ anchor?: OverlayAnchor | null
8
+ updatePositionStrategy: OverlayPositionStrategy
7
9
  external: boolean
8
10
  closeDelay: number
9
11
  positionerClass?: string
@@ -1,12 +1,19 @@
1
- import { computed, onScopeDispose, shallowRef, watch, type ComponentPublicInstance } from 'vue'
1
+ import { computed, onScopeDispose, watch, type ComponentPublicInstance } from 'vue'
2
2
  import { useEventListener, useRafFn } from '@vueuse/core'
3
3
  import { injectHoverCardRootContext } from 'reka-ui'
4
+ import {
5
+ overlayAnchorElement,
6
+ useOverlayAnchor,
7
+ type OverlayAnchor,
8
+ type OverlayPositionStrategy,
9
+ } from '../../../lib/overlay-anchor'
4
10
  import { useOverlayPortal } from '../../../lib/overlay-portal'
5
11
  import { useOverlayPositionerClass } from '../../../lib/overlay-positioner'
6
12
  import { inPointerCorridor } from '../../../lib/pointer-corridor'
7
13
 
8
14
  interface HoverCardAnchorOptions {
9
- anchor?: HTMLElement | null
15
+ anchor?: OverlayAnchor | null
16
+ updatePositionStrategy: OverlayPositionStrategy
10
17
  external: boolean
11
18
  closeDelay: number
12
19
  positionerClass?: string
@@ -16,18 +23,18 @@ export function useHoverCardAnchor(props: HoverCardAnchorOptions) {
16
23
  const root = injectHoverCardRootContext()
17
24
  const original = { onOpen: root.onOpen, onClose: root.onClose, onDismiss: root.onDismiss }
18
25
  const anchor = computed(() => (props.external ? (props.anchor ?? undefined) : undefined))
26
+ const element = computed(() => overlayAnchorElement(anchor.value))
27
+ const virtual = computed(() => !!anchor.value && !element.value)
19
28
  const { content, present } = useOverlayPortal(root.open)
20
29
  useOverlayPositionerClass(content, () => props.positionerClass)
21
30
  const panel = computed(() =>
22
31
  props.external ? (content.value?.$el as HTMLElement | undefined) : undefined,
23
32
  )
24
- const liveAnchor = shallowRef<HTMLElement>()
25
- const retained = shallowRef<{
26
- getBoundingClientRect: () => DOMRect
27
- contextElement: HTMLElement
28
- }>()
29
- const reference = computed(() =>
30
- props.external ? (liveAnchor.value ?? retained.value) : undefined,
33
+ const reference = useOverlayAnchor(
34
+ () => (props.external ? anchor.value : root.triggerElement.value),
35
+ root.open,
36
+ present,
37
+ () => props.updatePositionStrategy,
31
38
  )
32
39
  let timer: ReturnType<typeof setTimeout> | undefined
33
40
  let tracking = false
@@ -45,6 +52,7 @@ export function useHoverCardAnchor(props: HoverCardAnchorOptions) {
45
52
 
46
53
  function close() {
47
54
  if (
55
+ virtual.value ||
48
56
  timer !== undefined ||
49
57
  !root.open.value ||
50
58
  root.hasSelectionRef.value ||
@@ -63,8 +71,8 @@ export function useHoverCardAnchor(props: HoverCardAnchorOptions) {
63
71
  const pointer = event as PointerEvent
64
72
  if (!root.open.value || !tracking || pointer.pointerType === 'touch') return
65
73
  const target = pointer.target as Node | null
66
- if (target && (anchor.value?.contains(target) || panel.value?.contains(target))) return keep()
67
- const a = anchor.value?.getBoundingClientRect()
74
+ if (target && (element.value?.contains(target) || panel.value?.contains(target))) return keep()
75
+ const a = element.value?.getBoundingClientRect()
68
76
  const b = panel.value?.getBoundingClientRect()
69
77
  if (a && b && inPointerCorridor({ x: pointer.clientX, y: pointer.clientY }, a, b)) cancel()
70
78
  else close()
@@ -72,33 +80,20 @@ export function useHoverCardAnchor(props: HoverCardAnchorOptions) {
72
80
 
73
81
  function leave(event: Event) {
74
82
  const pointer = event as PointerEvent
75
- if (pointer.pointerType === 'touch' || !root.open.value) return
83
+ if (virtual.value || pointer.pointerType === 'touch' || !root.open.value) return
76
84
  tracking = true
77
- const a = anchor.value?.getBoundingClientRect()
85
+ const a = element.value?.getBoundingClientRect()
78
86
  const b = panel.value?.getBoundingClientRect()
79
87
  if (a && b && inPointerCorridor({ x: pointer.clientX, y: pointer.clientY }, a, b)) cancel()
80
88
  else close()
81
89
  }
82
90
 
83
91
  function measure() {
84
- const element = anchor.value
85
- if (!element?.isConnected) {
86
- liveAnchor.value = undefined
92
+ if (!anchor.value || (element.value && !element.value.isConnected)) {
87
93
  if (root.open.value) dismiss()
88
94
  return
89
95
  }
90
- liveAnchor.value = element
91
- const rect = element.getBoundingClientRect()
92
- const previous = retained.value?.getBoundingClientRect()
93
- if (
94
- !previous ||
95
- retained.value?.contextElement !== element ||
96
- rect.x !== previous.x ||
97
- rect.y !== previous.y ||
98
- rect.width !== previous.width ||
99
- rect.height !== previous.height
100
- )
101
- retained.value = { getBoundingClientRect: () => rect, contextElement: element }
96
+ reference.value?.getBoundingClientRect()
102
97
  }
103
98
 
104
99
  const { pause, resume } = useRafFn(measure, { immediate: false })
@@ -112,35 +107,32 @@ export function useHoverCardAnchor(props: HoverCardAnchorOptions) {
112
107
  cancel()
113
108
  tracking = false
114
109
  pause()
115
- if (!props.external || !mounted) {
116
- liveAnchor.value = undefined
117
- return
118
- }
110
+ if (!props.external || !mounted) return
119
111
  if (element !== previous?.[0]) {
120
112
  root.hasSelectionRef.value = false
121
113
  root.isPointerDownOnContentRef.value = false
122
114
  }
123
115
  measure()
124
- resume()
116
+ if (!virtual.value) resume()
125
117
  },
126
118
  { immediate: true, flush: 'post' },
127
119
  )
128
120
 
129
- useEventListener(anchor, 'pointerenter', keep)
130
- useEventListener(anchor, 'pointerleave', leave)
131
- useEventListener(anchor, 'focusin', keep)
132
- useEventListener(anchor, 'focusout', event => {
121
+ useEventListener(element, 'pointerenter', keep)
122
+ useEventListener(element, 'pointerleave', leave)
123
+ useEventListener(element, 'focusin', keep)
124
+ useEventListener(element, 'focusout', event => {
133
125
  const target = (event as FocusEvent).relatedTarget as Node | null
134
- if (!target || (!anchor.value?.contains(target) && !panel.value?.contains(target))) close()
126
+ if (!target || (!element.value?.contains(target) && !panel.value?.contains(target))) close()
135
127
  })
136
128
  useEventListener(panel, 'pointerenter', keep)
137
129
  useEventListener(panel, 'pointerleave', leave)
138
- useEventListener(() => anchor.value?.ownerDocument, 'pointermove', track)
130
+ useEventListener(() => element.value?.ownerDocument, 'pointermove', track)
139
131
  useEventListener(
140
- () => anchor.value?.ownerDocument,
132
+ () => element.value?.ownerDocument,
141
133
  'scroll',
142
134
  event => {
143
- if (root.open.value && (event.target as Node | null)?.contains(anchor.value ?? null))
135
+ if (root.open.value && (event.target as Node | null)?.contains(element.value ?? null))
144
136
  dismiss()
145
137
  },
146
138
  { capture: true, passive: true },
@@ -153,7 +145,6 @@ export function useHoverCardAnchor(props: HoverCardAnchorOptions) {
153
145
 
154
146
  function contentRef(value: Element | ComponentPublicInstance | null) {
155
147
  content.value = value as ComponentPublicInstance | null
156
- if (!value && !root.open.value) retained.value = undefined
157
148
  }
158
149
 
159
150
  return { reference, contentRef, present }
@@ -1,17 +1,18 @@
1
1
  <script setup lang="ts">
2
+ import type { OverlayAnchor, OverlayPositionStrategy } from '../../lib/overlay-anchor'
2
3
  import { computed } from 'vue'
3
4
  import { PopoverRoot, PopoverTrigger, PopoverPortal, type PopoverContentEmits } from 'reka-ui'
4
5
  import PopoverContent from './PopoverContent.vue'
5
6
  import { useAnchoredOverlay } from '../../lib/anchored-overlay'
6
7
  import Card from '../card/Card.vue'
7
- import { useOverlayPortal } from '../../lib/overlay-portal'
8
8
  import { cn } from '../../lib/cn'
9
9
 
10
10
  defineOptions({ name: 'HnPopover', inheritAttrs: false })
11
11
 
12
12
  const props = withDefaults(
13
13
  defineProps<{
14
- anchor?: HTMLElement | null
14
+ anchor?: OverlayAnchor | null
15
+ updatePositionStrategy?: OverlayPositionStrategy
15
16
  modal?: boolean
16
17
  side?: 'top' | 'right' | 'bottom' | 'left'
17
18
  align?: 'start' | 'center' | 'end'
@@ -19,18 +20,26 @@
19
20
  padded?: boolean
20
21
  class?: string
21
22
  }>(),
22
- { side: 'bottom', align: 'center', sideOffset: 8, padded: true, modal: true },
23
+ {
24
+ updatePositionStrategy: 'optimized',
25
+ side: 'bottom',
26
+ align: 'center',
27
+ sideOffset: 8,
28
+ padded: true,
29
+ modal: true,
30
+ },
23
31
  )
24
32
 
25
33
  const open = defineModel<boolean>('open')
26
34
  const emit = defineEmits<PopoverContentEmits>()
27
35
  defineSlots<{ default?(): unknown; content?(): unknown }>()
28
- const { trigger, reference, visible, events } = useAnchoredOverlay(props, open, emit, event =>
29
- emit('openAutoFocus', event),
36
+ const { trigger, content, present, reference, visible, events } = useAnchoredOverlay(
37
+ props,
38
+ open,
39
+ emit,
40
+ event => emit('openAutoFocus', event),
30
41
  )
31
42
 
32
- const { content, present } = useOverlayPortal(visible)
33
-
34
43
  const pressOrigin = computed(() => {
35
44
  if (props.side === 'left') return 'right'
36
45
  if (props.side === 'right') return 'left'
@@ -54,6 +63,7 @@
54
63
  <PopoverContent
55
64
  v-bind="{ ...$attrs, ...events }"
56
65
  :reference="reference"
66
+ :update-position-strategy="props.updatePositionStrategy"
57
67
  as-child
58
68
  :side="props.side"
59
69
  :align="props.align"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, shallowRef } from 'vue'
2
+ import { computed } from 'vue'
3
3
  import {
4
4
  DialogClose,
5
5
  DialogContent,
@@ -10,8 +10,9 @@
10
10
  DialogTitle,
11
11
  DialogTrigger,
12
12
  } from 'reka-ui'
13
+ import { useOverlayPortal } from '../../lib/overlay-portal'
14
+ import { useScrollViewport } from '../../lib/scroll-viewport'
13
15
  import { cn } from '../../lib/cn'
14
- import { useUiLocale } from '../../locale'
15
16
  import Card from '../card/Card.vue'
16
17
  import CloseButton from '../close-button/CloseButton.vue'
17
18
  import Heading from '../heading/Heading.vue'
@@ -26,16 +27,29 @@
26
27
  defineProps<{
27
28
  title: string
28
29
  description?: string
30
+ header?: boolean
31
+ closable?: boolean
29
32
  handle?: boolean
30
33
  locked?: boolean
31
34
  class?: string
32
35
  }>(),
33
- { handle: true, locked: false },
36
+ { header: true, closable: true, handle: true, locked: false },
34
37
  )
35
38
 
39
+ const slots = defineSlots<{
40
+ default?(): unknown
41
+ icon?(): unknown
42
+ title?(): unknown
43
+ body?(props: { close: () => void }): unknown
44
+ content?(props: { close: () => void }): unknown
45
+ footer?(props: { close: () => void }): unknown
46
+ }>()
47
+
48
+ const { scrollArea, viewport } = useScrollViewport()
49
+ defineExpose({ viewport })
50
+
36
51
  const open = defineModel<boolean>('open')
37
- const t = useUiLocale()
38
- const panel = shallowRef<{ $el: HTMLElement } | null>(null)
52
+ const { content: panel, present } = useOverlayPortal(open)
39
53
 
40
54
  const { dragging, offset, onPointerDown } = useDragToDismiss(() => panel.value?.$el ?? null, {
41
55
  enabled: () => !props.locked,
@@ -63,54 +77,83 @@
63
77
  <DialogTrigger v-if="$slots.default" as-child>
64
78
  <slot />
65
79
  </DialogTrigger>
66
- <DialogPortal>
80
+ <DialogPortal v-if="present">
67
81
  <DialogOverlay class="hn-scrim" />
68
82
  <DialogContent as-child @escape-key-down="guard" @interact-outside="guard">
69
83
  <Card
70
84
  ref="panel"
85
+ v-bind="props.description ? {} : { 'aria-describedby': undefined }"
71
86
  data-hn-sheet
72
87
  :data-dragging="dragging ? '' : undefined"
73
88
  :padded="false"
74
89
  :style="style"
75
- :class="cn(sheetPanel(), props.class)"
90
+ :class="
91
+ cn(sheetPanel({ grip: props.header || props.handle, padded: !slots.body }), props.class)
92
+ "
76
93
  >
94
+ <template v-if="!props.header || slots.body">
95
+ <DialogTitle as-child>
96
+ <Heading :level="2" class="sr-only">{{ props.title }}</Heading>
97
+ </DialogTitle>
98
+ <DialogDescription v-if="props.description" class="sr-only">
99
+ {{ props.description }}
100
+ </DialogDescription>
101
+ </template>
77
102
  <div
103
+ v-if="props.handle || (props.header && !slots.body)"
78
104
  data-hn-sheet-grip
79
105
  :data-dragging="dragging ? '' : undefined"
80
- :class="sheetGrip()"
106
+ :class="sheetGrip({ standalone: !!slots.body })"
81
107
  @pointerdown="onPointerDown"
82
108
  >
83
109
  <div
84
110
  v-if="props.handle"
85
111
  aria-hidden="true"
86
112
  :data-disabled="props.locked ? '' : undefined"
87
- :class="sheetHandle()"
113
+ :class="sheetHandle({ header: props.header && !slots.body })"
88
114
  />
89
- <div class="flex items-start justify-between gap-4 px-(--hn-panel-p)">
115
+ <div
116
+ v-if="props.header && !slots.body"
117
+ class="flex items-start justify-between gap-4 px-(--hn-panel-p)"
118
+ >
90
119
  <div class="flex min-w-0 flex-col gap-1.5">
91
- <DialogTitle as-child>
92
- <Heading :level="2" size="lg">{{ props.title }}</Heading>
93
- </DialogTitle>
120
+ <div class="flex min-w-0 items-center gap-2">
121
+ <span
122
+ v-if="slots.icon"
123
+ class="text-muted flex shrink-0 [&_svg]:size-5"
124
+ aria-hidden="true"
125
+ >
126
+ <slot name="icon" />
127
+ </span>
128
+ <DialogTitle as-child>
129
+ <Heading :level="2" size="lg" class="min-w-0">
130
+ <slot name="title">{{ props.title }}</slot>
131
+ </Heading>
132
+ </DialogTitle>
133
+ </div>
94
134
  <DialogDescription v-if="props.description" as-child>
95
135
  <Text tone="muted">{{ props.description }}</Text>
96
136
  </DialogDescription>
97
137
  </div>
98
- <DialogClose v-if="!props.handle" as-child>
138
+ <DialogClose v-if="!props.handle && props.closable" as-child>
99
139
  <CloseButton :disabled="props.locked" class="-mt-1.5 -me-1.5 shrink-0" />
100
140
  </DialogClose>
101
141
  </div>
102
142
  </div>
103
- <ScrollArea v-if="$slots.content" class="min-h-0 grow">
104
- <div class="px-(--hn-panel-p) py-1">
105
- <slot name="content" :close="close" />
143
+ <slot v-if="slots.body" name="body" :close="close" />
144
+ <template v-else>
145
+ <ScrollArea v-if="$slots.content" ref="scrollArea" class="min-h-0 grow">
146
+ <div class="px-(--hn-panel-p) py-1">
147
+ <slot name="content" :close="close" />
148
+ </div>
149
+ </ScrollArea>
150
+ <div
151
+ v-if="$slots.footer"
152
+ class="flex shrink-0 justify-end gap-(--hn-inline-gap) px-(--hn-panel-p)"
153
+ >
154
+ <slot name="footer" :close="close" />
106
155
  </div>
107
- </ScrollArea>
108
- <div
109
- v-if="$slots.footer"
110
- class="flex shrink-0 justify-end gap-(--hn-inline-gap) px-(--hn-panel-p)"
111
- >
112
- <slot name="footer" :close="close" />
113
- </div>
156
+ </template>
114
157
  </Card>
115
158
  </DialogContent>
116
159
  </DialogPortal>