@hina-ui/vue 1.2.0 → 1.3.1

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 (99) hide show
  1. package/dist/index.js +880 -347
  2. package/dist/index.js.map +1 -1
  3. package/dist/types/components/alert-dialog/AlertDialog.vue.d.ts +6 -2
  4. package/dist/types/components/alert-dialog/AlertDialog.vue.d.ts.map +1 -1
  5. package/dist/types/components/alert-dialog/alert-dialog.variants.d.ts +5 -0
  6. package/dist/types/components/alert-dialog/alert-dialog.variants.d.ts.map +1 -0
  7. package/dist/types/components/alert-dialog/composables/useAlertDialogConfirm.d.ts +9 -0
  8. package/dist/types/components/alert-dialog/composables/useAlertDialogConfirm.d.ts.map +1 -0
  9. package/dist/types/components/alert-dialog/composables/useConfirmDelay.d.ts +3 -0
  10. package/dist/types/components/alert-dialog/composables/useConfirmDelay.d.ts.map +1 -0
  11. package/dist/types/components/anchor/Anchor.vue.d.ts.map +1 -1
  12. package/dist/types/components/code-block/CodeBlock.vue.d.ts.map +1 -1
  13. package/dist/types/components/code-block/code-block.variants.d.ts +6 -0
  14. package/dist/types/components/code-block/code-block.variants.d.ts.map +1 -0
  15. package/dist/types/components/code-block/composables/useCodeHighlight.d.ts +7 -0
  16. package/dist/types/components/code-block/composables/useCodeHighlight.d.ts.map +1 -0
  17. package/dist/types/components/dialog/Dialog.vue.d.ts.map +1 -1
  18. package/dist/types/components/dialog/ModalContent.vue.d.ts +21 -0
  19. package/dist/types/components/dialog/ModalContent.vue.d.ts.map +1 -0
  20. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts +18 -6
  21. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts.map +1 -1
  22. package/dist/types/components/file-upload/FileUpload.vue.d.ts +1 -1
  23. package/dist/types/components/file-upload/FileUpload.vue.d.ts.map +1 -1
  24. package/dist/types/components/highlight/Highlight.vue.d.ts +2 -0
  25. package/dist/types/components/highlight/Highlight.vue.d.ts.map +1 -1
  26. package/dist/types/components/highlight/composables/useHighlightMotion.d.ts +10 -0
  27. package/dist/types/components/highlight/composables/useHighlightMotion.d.ts.map +1 -0
  28. package/dist/types/components/image/Image.vue.d.ts +5 -2
  29. package/dist/types/components/image/Image.vue.d.ts.map +1 -1
  30. package/dist/types/components/image/composables/useImage.d.ts.map +1 -1
  31. package/dist/types/components/image/image.variants.d.ts +23 -1
  32. package/dist/types/components/image/image.variants.d.ts.map +1 -1
  33. package/dist/types/components/lightbox/LightboxThumbs.vue.d.ts.map +1 -1
  34. package/dist/types/components/lightbox/composables/useLightboxMotion.d.ts.map +1 -1
  35. package/dist/types/components/lightbox/composables/useLightboxPose.d.ts.map +1 -1
  36. package/dist/types/components/lightbox/utils/clip.d.ts +16 -0
  37. package/dist/types/components/lightbox/utils/clip.d.ts.map +1 -0
  38. package/dist/types/components/lightbox/utils/pose.d.ts +2 -2
  39. package/dist/types/components/lightbox/utils/pose.d.ts.map +1 -1
  40. package/dist/types/components/popover/Popover.vue.d.ts +19 -6
  41. package/dist/types/components/popover/Popover.vue.d.ts.map +1 -1
  42. package/dist/types/components/popover/PopoverContent.vue.d.ts +29 -0
  43. package/dist/types/components/popover/PopoverContent.vue.d.ts.map +1 -0
  44. package/dist/types/components/popover/composables/usePopoverCloseFocus.d.ts +2 -0
  45. package/dist/types/components/popover/composables/usePopoverCloseFocus.d.ts.map +1 -0
  46. package/dist/types/components/scroll-area/ScrollArea.vue.d.ts.map +1 -1
  47. package/dist/types/components/scroll-area/composables/useLayerLock.d.ts.map +1 -1
  48. package/dist/types/components/scroll-area/composables/useScrollFocus.d.ts +17 -0
  49. package/dist/types/components/scroll-area/composables/useScrollFocus.d.ts.map +1 -0
  50. package/dist/types/components/segmented-control/SegmentedControl.vue.d.ts.map +1 -1
  51. package/dist/types/components/tabs/TabsTrigger.vue.d.ts.map +1 -1
  52. package/dist/types/index.d.ts +2 -0
  53. package/dist/types/index.d.ts.map +1 -1
  54. package/dist/types/lib/anchored-overlay.d.ts +19 -0
  55. package/dist/types/lib/anchored-overlay.d.ts.map +1 -0
  56. package/dist/types/lib/dialog-focus.d.ts +2 -0
  57. package/dist/types/lib/dialog-focus.d.ts.map +1 -0
  58. package/dist/types/lib/overlay-portal.d.ts +6 -0
  59. package/dist/types/lib/overlay-portal.d.ts.map +1 -0
  60. package/package.json +1 -1
  61. package/src/components/alert-dialog/AlertDialog.vue +31 -40
  62. package/src/components/alert-dialog/alert-dialog.variants.ts +17 -0
  63. package/src/components/alert-dialog/composables/useAlertDialogConfirm.ts +29 -0
  64. package/src/components/alert-dialog/composables/useConfirmDelay.ts +37 -0
  65. package/src/components/anchor/Anchor.vue +1 -0
  66. package/src/components/code-block/CodeBlock.vue +15 -30
  67. package/src/components/code-block/code-block.variants.ts +21 -0
  68. package/src/components/code-block/composables/useCodeHighlight.ts +30 -0
  69. package/src/components/dialog/Dialog.vue +7 -4
  70. package/src/components/dialog/ModalContent.vue +19 -0
  71. package/src/components/dropdown-menu/DropdownMenu.vue +16 -4
  72. package/src/components/highlight/Highlight.vue +5 -4
  73. package/src/components/highlight/composables/useHighlightMotion.ts +22 -0
  74. package/src/components/image/Image.vue +9 -12
  75. package/src/components/image/composables/useImage.ts +6 -3
  76. package/src/components/image/image.variants.ts +13 -1
  77. package/src/components/lightbox/LightboxThumbs.vue +1 -0
  78. package/src/components/lightbox/composables/useLightboxMotion.ts +3 -1
  79. package/src/components/lightbox/composables/useLightboxPose.ts +5 -2
  80. package/src/components/lightbox/utils/clip.ts +117 -0
  81. package/src/components/lightbox/utils/pose.ts +23 -13
  82. package/src/components/popover/Popover.vue +26 -6
  83. package/src/components/popover/PopoverContent.vue +25 -0
  84. package/src/components/popover/composables/usePopoverCloseFocus.ts +17 -0
  85. package/src/components/scroll-area/ScrollArea.vue +7 -5
  86. package/src/components/scroll-area/composables/useLayerLock.ts +8 -9
  87. package/src/components/scroll-area/composables/useScrollFocus.ts +39 -0
  88. package/src/components/scroll-area/composables/useWheelRedirect.ts +1 -1
  89. package/src/components/segmented-control/SegmentedControl.vue +6 -1
  90. package/src/components/skeleton/LICENSE +24 -0
  91. package/src/components/tabs/TabsTrigger.vue +6 -1
  92. package/src/index.ts +2 -0
  93. package/src/lib/anchored-overlay.ts +107 -0
  94. package/src/lib/dialog-focus.ts +13 -0
  95. package/src/lib/overlay-portal.ts +8 -0
  96. package/src/styles/interaction.css +26 -3
  97. package/src/styles/motion.css +14 -2
  98. package/src/styles/semantic.css +5 -1
  99. package/src/styles/typography.css +9 -4
@@ -0,0 +1,22 @@
1
+ import { computed } from 'vue'
2
+ import type { MotionProps } from 'motion-v'
3
+ import { TRANSITION, prefersReducedMotion } from '../../../motion'
4
+
5
+ export function useHighlightMotion(axis: () => 'x' | 'y' | 'both') {
6
+ const transition = computed(() => (prefersReducedMotion() ? { duration: 0 } : TRANSITION.layout))
7
+ const transformTemplate = computed<MotionProps['transformTemplate']>(() => {
8
+ const direction = axis()
9
+ if (direction === 'both') return undefined
10
+
11
+ return (_, generated) => {
12
+ if (!generated || generated === 'none' || typeof DOMMatrix === 'undefined') return generated
13
+
14
+ const matrix = new DOMMatrix(generated)
15
+ if (direction === 'x') matrix.m42 = 0
16
+ else matrix.m41 = 0
17
+ return matrix.toString()
18
+ }
19
+ })
20
+
21
+ return { transition, transformTemplate }
22
+ }
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { computed, onBeforeUnmount, shallowRef, useId, watch } from 'vue'
2
+ import { computed, onBeforeUnmount, shallowRef, useId, watch, type StyleValue } from 'vue'
3
3
  import { Primitive } from 'reka-ui'
4
4
  import { cn } from '../../lib/cn'
5
5
  import { useRequiredLabel } from '../../lib/a11y'
6
6
  import Skeleton from '../skeleton/Skeleton.vue'
7
7
  import Lightbox from '../lightbox/Lightbox.vue'
8
8
  import type { LightboxItem } from '../lightbox/types'
9
- import { image, type ImageVariants } from './image.variants'
9
+ import { image, imageRoot, type ImageVariants } from './image.variants'
10
10
  import { useImage } from './composables/useImage'
11
11
  import { useImageGroup } from './context'
12
12
  import { useImageResolver } from './resolver'
@@ -27,7 +27,9 @@
27
27
  preview?: boolean | string
28
28
  draggable?: boolean
29
29
  class?: string
30
+ style?: StyleValue
30
31
  imageClass?: string
32
+ imageStyle?: StyleValue
31
33
  }>(),
32
34
  {
33
35
  alt: '',
@@ -93,20 +95,15 @@
93
95
  ref="rootEl"
94
96
  :as="props.preview ? 'button' : 'span'"
95
97
  :type="props.preview ? 'button' : undefined"
96
- :class="
97
- cn(
98
- 'relative block overflow-hidden',
99
- props.preview && 'hn-focus-ring cursor-zoom-in',
100
- props.class,
101
- )
102
- "
103
- :style="props.ratio ? { aspectRatio: String(props.ratio) } : undefined"
98
+ :class="cn(imageRoot({ preview: !!props.preview }), props.class)"
99
+ :style="[props.ratio ? { aspectRatio: String(props.ratio) } : undefined, props.style]"
104
100
  @click="openPreview"
105
101
  >
106
102
  <img
107
103
  v-if="showImage"
108
104
  ref="imageEl"
109
105
  v-bind="$attrs"
106
+ :style="props.imageStyle"
110
107
  :src="src"
111
108
  :alt="props.alt"
112
109
  :decoding="props.eager ? 'sync' : 'async'"
@@ -114,8 +111,8 @@
114
111
  :draggable="props.draggable"
115
112
  :class="
116
113
  cn(
117
- image({ fit: props.fit }),
118
- props.lazy ? !revealed && 'opacity-0' : 'relative z-10',
114
+ image({ fit: props.fit, lazy: props.lazy }),
115
+ props.lazy && !revealed && 'opacity-0',
119
116
  props.imageClass,
120
117
  )
121
118
  "
@@ -75,7 +75,7 @@ export function useImage(
75
75
  const style = getComputedStyle(el)
76
76
  try {
77
77
  return el.animate(frames, {
78
- duration: Number.parseFloat(style.getPropertyValue('--hn-duration-base')) || 0,
78
+ duration: Number.parseFloat(style.getPropertyValue('--hn-duration-fast')) || 0,
79
79
  easing: style.getPropertyValue('--hn-ease-enter').trim() || 'linear',
80
80
  fill: 'forwards',
81
81
  })
@@ -89,16 +89,19 @@ export function useImage(
89
89
  }
90
90
 
91
91
  async function reveal() {
92
- revealed.value = true
93
-
94
92
  if (!props.lazy) {
93
+ revealed.value = true
95
94
  skeletonMounted.value = false
96
95
  return
97
96
  }
98
97
 
98
+ const img = imageEl.value
99
+ const at = src.value
99
100
  await nextTick()
100
101
  await afterPaint()
102
+ if (imageEl.value !== img || src.value !== at) return
101
103
 
104
+ revealed.value = true
102
105
  const layer = skeletonEl.value
103
106
  const anim = layer && play(layer, [{ opacity: 1 }, { opacity: 0 }])
104
107
  if (!anim) {
@@ -1,8 +1,20 @@
1
1
  import { tv, type VariantProps } from '../../lib/tv'
2
2
 
3
+ export const imageRoot = tv({
4
+ base: 'relative block w-full overflow-hidden',
5
+ variants: {
6
+ preview: {
7
+ true: 'hn-focus-ring cursor-zoom-in',
8
+ },
9
+ },
10
+ })
11
+
3
12
  export const image = tv({
4
- base: 'block size-full',
13
+ base: 'relative z-10 block size-full',
5
14
  variants: {
15
+ lazy: {
16
+ true: 'transition-opacity duration-(--hn-duration-base) ease-enter motion-reduce:transition-none',
17
+ },
6
18
  fit: {
7
19
  cover: 'object-cover',
8
20
  contain: 'object-contain',
@@ -65,6 +65,7 @@
65
65
  <Highlight
66
66
  v-if="at === props.index"
67
67
  :id="highlightId"
68
+ axis="x"
68
69
  class="ring-accent absolute inset-0 rounded-md ring-2 ring-inset"
69
70
  />
70
71
  </button>
@@ -84,7 +84,9 @@ export function useLightboxMotion(stageHeight: () => number, restScale: () => nu
84
84
  function leave(to: Pose | null): Promise<void> {
85
85
  stop()
86
86
  const t = to ? timing(RETURN_SPRING) : transition('exit')
87
- const travel = to ? poseTo(to, t) : [animate(scale, FADE_POSE.scale, t), animate(opacity, 0, t)]
87
+ const travel = to
88
+ ? poseTo(to, t)
89
+ : [animate(scale, Math.min(scale.get(), FADE_POSE.scale), t), animate(opacity, 0, t)]
88
90
  return settle([...travel, animate(presence, 0, t)])
89
91
  }
90
92
 
@@ -1,5 +1,6 @@
1
1
  import type { useLightboxFrames } from './useLightboxFrames'
2
- import { openPose, radiusOf, type Pose, type Rect } from '../utils/pose'
2
+ import { openPose, type Pose, type Rect } from '../utils/pose'
3
+ import { clipOf } from '../utils/clip'
3
4
  import type { LightboxItem } from '../types'
4
5
 
5
6
  export function useLightboxPose(
@@ -28,7 +29,9 @@ export function useLightboxPose(
28
29
  const box = sourceBox()
29
30
  const natural = frames.naturalOf(item)
30
31
  if (!box || !natural) return null
31
- return openPose(frames.frameOf(item), box, natural, item?.fit, radiusOf(item?.source?.()))
32
+ const clip = clipOf(item?.source?.(), box)
33
+ if (clip.rect.width <= 0 || clip.rect.height <= 0) return null
34
+ return openPose(frames.frameOf(item), box, natural, item?.fit, clip.corners, clip.rect)
32
35
  }
33
36
 
34
37
  return { fromSource }
@@ -0,0 +1,117 @@
1
+ import type { Rect } from './pose'
2
+
3
+ export interface Corner {
4
+ x: number
5
+ y: number
6
+ }
7
+
8
+ export type Corners = [Corner, Corner, Corner, Corner]
9
+
10
+ function emptyCorners(): Corners {
11
+ return [
12
+ { x: 0, y: 0 },
13
+ { x: 0, y: 0 },
14
+ { x: 0, y: 0 },
15
+ { x: 0, y: 0 },
16
+ ]
17
+ }
18
+
19
+ function length(value: string, size: number): number {
20
+ const number = Number.parseFloat(value) || 0
21
+ return value.endsWith('%') ? (number * size) / 100 : number
22
+ }
23
+
24
+ function cornersOf(style: CSSStyleDeclaration, width: number, height: number): Corners {
25
+ const corners = [
26
+ style.borderTopLeftRadius,
27
+ style.borderTopRightRadius,
28
+ style.borderBottomRightRadius,
29
+ style.borderBottomLeftRadius,
30
+ ].map(value => {
31
+ const [x = '0', y = x] = value.split(/\s+/)
32
+ return { x: length(x, width), y: length(y, height) }
33
+ }) as Corners
34
+ const [tl, tr, br, bl] = corners
35
+ const factor = Math.min(
36
+ 1,
37
+ width / (tl.x + tr.x || 1),
38
+ width / (bl.x + br.x || 1),
39
+ height / (tl.y + bl.y || 1),
40
+ height / (tr.y + br.y || 1),
41
+ )
42
+ return corners.map(corner => ({ x: corner.x * factor, y: corner.y * factor })) as Corners
43
+ }
44
+
45
+ function paddingBox(node: Element, style: CSSStyleDeclaration) {
46
+ const box = node.getBoundingClientRect()
47
+ const width = node instanceof HTMLElement ? node.offsetWidth : box.width
48
+ const height = node instanceof HTMLElement ? node.offsetHeight : box.height
49
+ const sx = width ? box.width / width : 1
50
+ const sy = height ? box.height / height : 1
51
+ const left = Number.parseFloat(style.borderLeftWidth) || 0
52
+ const right = Number.parseFloat(style.borderRightWidth) || 0
53
+ const top = Number.parseFloat(style.borderTopWidth) || 0
54
+ const bottom = Number.parseFloat(style.borderBottomWidth) || 0
55
+ const corners = cornersOf(style, width, height)
56
+ const borders = [
57
+ [left, top],
58
+ [right, top],
59
+ [right, bottom],
60
+ [left, bottom],
61
+ ]
62
+ return {
63
+ rect: {
64
+ x: box.x + left * sx,
65
+ y: box.y + top * sy,
66
+ width: Math.max(0, box.width - (left + right) * sx),
67
+ height: Math.max(0, box.height - (top + bottom) * sy),
68
+ },
69
+ corners: corners.map((corner, index) => ({
70
+ x: Math.max(0, corner.x - borders[index]![0]!) * sx,
71
+ y: Math.max(0, corner.y - borders[index]![1]!) * sy,
72
+ })) as Corners,
73
+ }
74
+ }
75
+
76
+ function points(rect: Rect) {
77
+ return [
78
+ [rect.x, rect.y],
79
+ [rect.x + rect.width, rect.y],
80
+ [rect.x + rect.width, rect.y + rect.height],
81
+ [rect.x, rect.y + rect.height],
82
+ ]
83
+ }
84
+
85
+ export function clipOf(el: Element | null | undefined, box: Rect) {
86
+ let rect = { x: box.x, y: box.y, width: box.width, height: box.height }
87
+ const clips: ReturnType<typeof paddingBox>[] = []
88
+ for (let node = el; node; node = node.parentElement) {
89
+ const style = getComputedStyle(node)
90
+ const clipX = node === el || /^(hidden|clip|scroll|auto)$/.test(style.overflowX)
91
+ const clipY = node === el || /^(hidden|clip|scroll|auto)$/.test(style.overflowY)
92
+ if (!clipX && !clipY) continue
93
+ const clip = paddingBox(node, style)
94
+ const x = clipX ? Math.max(rect.x, clip.rect.x) : rect.x
95
+ const y = clipY ? Math.max(rect.y, clip.rect.y) : rect.y
96
+ const right = clipX
97
+ ? Math.min(rect.x + rect.width, clip.rect.x + clip.rect.width)
98
+ : rect.x + rect.width
99
+ const bottom = clipY
100
+ ? Math.min(rect.y + rect.height, clip.rect.y + clip.rect.height)
101
+ : rect.y + rect.height
102
+ rect = { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) }
103
+ if (clipX && clipY) clips.push(clip)
104
+ }
105
+ const corners = emptyCorners()
106
+ const visible = points(rect)
107
+ for (const clip of clips) {
108
+ const edges = points(clip.rect)
109
+ for (let index = 0; index < 4; index += 1) {
110
+ if (visible[index]!.some((value, axis) => Math.abs(value - edges[index]![axis]!) > 0.5))
111
+ continue
112
+ corners[index]!.x = Math.max(corners[index]!.x, clip.corners[index]!.x)
113
+ corners[index]!.y = Math.max(corners[index]!.y, clip.corners[index]!.y)
114
+ }
115
+ }
116
+ return { rect, corners }
117
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ImageVariants } from '../../image/image.variants'
2
+ import type { Corners } from './clip'
2
3
  import { fitSize, rotatedSize, type Size } from './zoom'
3
4
 
4
5
  export interface Rect {
@@ -21,7 +22,7 @@ export const REST_POSE: Pose = {
21
22
  y: 0,
22
23
  scale: 1,
23
24
  rotate: 0,
24
- clipPath: 'inset(0px 0px 0px 0px round 0px)',
25
+ clipPath: 'inset(0px 0px 0px 0px round 0px 0px 0px 0px / 0px 0px 0px 0px)',
25
26
  }
26
27
  export const TRAVEL_SPRING = { type: 'spring', visualDuration: 0.3, bounce: 0 } as const
27
28
  export const RETURN_SPRING = { ...TRAVEL_SPRING, visualDuration: 0.22 } as const
@@ -79,23 +80,40 @@ export function openPose(
79
80
  box: Rect,
80
81
  natural: Size,
81
82
  fit?: ImageVariants['fit'],
82
- radius = 0,
83
+ radius: number | Corners = 0,
84
+ clipBox = box,
83
85
  ): Pose {
84
86
  const rendered = renderedRect(box, natural, fit)
85
87
  if (frame.width <= 0 || frame.height <= 0 || rendered.width <= 0) return REST_POSE
86
88
  const scale = rendered.width / frame.width
87
- const visible = intersect(box, rendered)
89
+ const visible = intersect(intersect(box, clipBox), rendered)
88
90
  const top = (visible.y - rendered.y) / scale
89
91
  const left = (visible.x - rendered.x) / scale
90
92
  const right = (rendered.x + rendered.width - visible.x - visible.width) / scale
91
93
  const bottom = (rendered.y + rendered.height - visible.y - visible.height) / scale
92
- const corner = Math.max(0, radius) / scale
94
+ const corners =
95
+ typeof radius === 'number'
96
+ ? Array.from({ length: 4 }, () => ({ x: radius, y: radius }))
97
+ : radius
98
+ const leftEdge = Math.abs(visible.x - clipBox.x) <= 0.5
99
+ const topEdge = Math.abs(visible.y - clipBox.y) <= 0.5
100
+ const rightEdge = Math.abs(visible.x + visible.width - clipBox.x - clipBox.width) <= 0.5
101
+ const bottomEdge = Math.abs(visible.y + visible.height - clipBox.y - clipBox.height) <= 0.5
102
+ const touches = [
103
+ leftEdge && topEdge,
104
+ rightEdge && topEdge,
105
+ rightEdge && bottomEdge,
106
+ leftEdge && bottomEdge,
107
+ ]
108
+ const clipped = corners.map((corner, index) => (touches[index] ? corner : { x: 0, y: 0 }))
109
+ const horizontal = clipped.map(corner => px(Math.max(0, corner.x) / scale)).join(' ')
110
+ const vertical = clipped.map(corner => px(Math.max(0, corner.y) / scale)).join(' ')
93
111
  return {
94
112
  x: rendered.x + rendered.width / 2 - (frame.x + frame.width / 2),
95
113
  y: rendered.y + rendered.height / 2 - (frame.y + frame.height / 2),
96
114
  scale,
97
115
  rotate: 0,
98
- clipPath: `inset(${px(top)} ${px(right)} ${px(bottom)} ${px(left)} round ${px(corner)})`,
116
+ clipPath: `inset(${px(top)} ${px(right)} ${px(bottom)} ${px(left)} round ${horizontal} / ${vertical})`,
99
117
  }
100
118
  }
101
119
 
@@ -106,14 +124,6 @@ export function baseScale(frame: Size, stage: Size, rotation: number): number {
106
124
  return Math.min(stage.width / visual.width, stage.height / visual.height)
107
125
  }
108
126
 
109
- export function radiusOf(el: Element | null | undefined): number {
110
- for (let node = el, depth = 0; node && depth < 2; node = node.parentElement, depth += 1) {
111
- const value = Number.parseFloat(getComputedStyle(node).borderTopLeftRadius)
112
- if (value > 0) return value
113
- }
114
- return 0
115
- }
116
-
117
127
  export function dismissProgress(offsetY: number, stageHeight: number): number {
118
128
  if (stageHeight <= 0) return 0
119
129
  return Math.min(1, Math.max(0, offsetY) / (stageHeight * DISMISS_TRAVEL))
@@ -1,23 +1,35 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
- import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'reka-ui'
3
+ import { PopoverRoot, PopoverTrigger, PopoverPortal, type PopoverContentEmits } from 'reka-ui'
4
+ import PopoverContent from './PopoverContent.vue'
5
+ import { useAnchoredOverlay } from '../../lib/anchored-overlay'
4
6
  import Card from '../card/Card.vue'
7
+ import { useOverlayPortal } from '../../lib/overlay-portal'
5
8
  import { cn } from '../../lib/cn'
6
9
 
7
- defineOptions({ name: 'HnPopover' })
10
+ defineOptions({ name: 'HnPopover', inheritAttrs: false })
8
11
 
9
12
  const props = withDefaults(
10
13
  defineProps<{
14
+ anchor?: HTMLElement | null
15
+ modal?: boolean
11
16
  side?: 'top' | 'right' | 'bottom' | 'left'
12
17
  align?: 'start' | 'center' | 'end'
13
18
  sideOffset?: number
14
19
  padded?: boolean
15
20
  class?: string
16
21
  }>(),
17
- { side: 'bottom', align: 'center', sideOffset: 8, padded: true },
22
+ { side: 'bottom', align: 'center', sideOffset: 8, padded: true, modal: true },
18
23
  )
19
24
 
20
25
  const open = defineModel<boolean>('open')
26
+ const emit = defineEmits<PopoverContentEmits>()
27
+ defineSlots<{ default?(): unknown; content?(): unknown }>()
28
+ const { trigger, reference, visible, events } = useAnchoredOverlay(props, open, emit, event =>
29
+ emit('openAutoFocus', event),
30
+ )
31
+
32
+ const { content, present } = useOverlayPortal(visible)
21
33
 
22
34
  const pressOrigin = computed(() => {
23
35
  if (props.side === 'left') return 'right'
@@ -29,18 +41,26 @@
29
41
  </script>
30
42
 
31
43
  <template>
32
- <PopoverRoot v-model:open="open" modal>
33
- <PopoverTrigger as-child :style="{ transformOrigin: pressOrigin }">
44
+ <PopoverRoot v-model:open="visible" :modal="props.modal">
45
+ <PopoverTrigger
46
+ v-if="$slots.default"
47
+ :ref="trigger"
48
+ as-child
49
+ :style="{ transformOrigin: pressOrigin }"
50
+ >
34
51
  <slot />
35
52
  </PopoverTrigger>
36
- <PopoverPortal>
53
+ <PopoverPortal v-if="present">
37
54
  <PopoverContent
55
+ v-bind="{ ...$attrs, ...events }"
56
+ :reference="reference"
38
57
  as-child
39
58
  :side="props.side"
40
59
  :align="props.align"
41
60
  :side-offset="props.sideOffset"
42
61
  >
43
62
  <Card
63
+ ref="content"
44
64
  :padded="props.padded"
45
65
  :class="cn('hn-anim-pop z-(--hn-z-overlay) max-w-sm shadow-md outline-none', props.class)"
46
66
  >
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ PopoverContent,
4
+ useForwardPropsEmits,
5
+ type PopoverContentProps,
6
+ type PopoverContentEmits,
7
+ } from 'reka-ui'
8
+ import { usePopoverCloseFocus } from './composables/usePopoverCloseFocus'
9
+
10
+ defineOptions({ name: 'HnPopoverContent' })
11
+
12
+ const props = defineProps<PopoverContentProps>()
13
+ const emit = defineEmits<PopoverContentEmits>()
14
+ const forwarded = useForwardPropsEmits(props, emit)
15
+ const closeAutoFocus = usePopoverCloseFocus(emit)
16
+ </script>
17
+
18
+ <template>
19
+ <PopoverContent
20
+ v-bind="{ ...forwarded, onCloseAutoFocus: closeAutoFocus }"
21
+ :reference="props.reference"
22
+ >
23
+ <slot />
24
+ </PopoverContent>
25
+ </template>
@@ -0,0 +1,17 @@
1
+ import { nextTick } from 'vue'
2
+ import { injectPopoverRootContext } from 'reka-ui'
3
+
4
+ export function usePopoverCloseFocus(emit: (event: 'closeAutoFocus', value: Event) => void) {
5
+ const root = injectPopoverRootContext()
6
+
7
+ return (event: Event) => {
8
+ emit('closeAutoFocus', event)
9
+ if (!event.defaultPrevented || !root.triggerElement.value) return
10
+
11
+ const trigger = root.triggerElement.value
12
+ root.triggerElement.value = undefined
13
+ nextTick(() => {
14
+ root.triggerElement.value ??= trigger
15
+ })
16
+ }
17
+ }
@@ -6,6 +6,7 @@
6
6
  import { useEdgeShadow } from './composables/useEdgeShadow'
7
7
  import { useWheelRedirect } from './composables/useWheelRedirect'
8
8
  import { useLayerLock } from './composables/useLayerLock'
9
+ import { useScrollFocus } from './composables/useScrollFocus'
9
10
 
10
11
  defineOptions({ name: 'HnScrollArea', inheritAttrs: false })
11
12
 
@@ -59,6 +60,10 @@
59
60
  }
60
61
 
61
62
  const { viewport, instance, onEvent } = useOverlayScrollbars(host, content, options)
63
+ const { hostFocus, viewportFocus } = useScrollFocus(host, viewport, () => ({
64
+ focusable: props.focusable,
65
+ label: props.label ?? t.value.scroll.regionLabel,
66
+ }))
62
67
  const { showXStart, showXEnd, showYStart, showYEnd, updateEdges } = useEdgeShadow(
63
68
  viewport,
64
69
  () => ({ direction: props.direction, shadow: props.shadow }),
@@ -83,14 +88,11 @@
83
88
  <div :class="cn('hn-scroll-area relative flex flex-col overflow-hidden', props.class)">
84
89
  <div
85
90
  ref="host"
86
- v-bind="$attrs"
91
+ v-bind="{ ...$attrs, ...hostFocus }"
87
92
  data-overlayscrollbars-initialize
88
- :tabindex="props.focusable ? 0 : undefined"
89
- :role="props.focusable ? 'region' : undefined"
90
- :aria-label="props.focusable ? (props.label ?? t.scroll.regionLabel) : undefined"
91
93
  class="w-full min-h-0 grow"
92
94
  >
93
- <div ref="content" data-overlayscrollbars-contents>
95
+ <div ref="content" v-bind="viewportFocus" data-overlayscrollbars-contents>
94
96
  <slot />
95
97
  </div>
96
98
  </div>
@@ -13,10 +13,6 @@ const useBodyPointerLock = createSharedComposable(() => {
13
13
  return locked
14
14
  })
15
15
 
16
- function block(event: Event) {
17
- event.preventDefault()
18
- }
19
-
20
16
  export function useLayerLock(viewport: ShallowRef<HTMLElement | undefined>) {
21
17
  const locked = useBodyPointerLock()
22
18
  let release: (() => void) | undefined
@@ -25,12 +21,15 @@ export function useLayerLock(viewport: ShallowRef<HTMLElement | undefined>) {
25
21
  release?.()
26
22
  release = undefined
27
23
  const el = viewport.value
28
- if (!el || !locked.value || getComputedStyle(el).pointerEvents !== 'none') return
29
- el.addEventListener('wheel', block, { passive: false })
30
- el.addEventListener('touchmove', block, { passive: false })
24
+ if (!el || !locked.value) return
25
+ const block = (event: Event) => {
26
+ if (getComputedStyle(el).pointerEvents === 'none') event.preventDefault()
27
+ }
28
+ el.addEventListener('wheel', block, { passive: false, capture: true })
29
+ el.addEventListener('touchmove', block, { passive: false, capture: true })
31
30
  release = () => {
32
- el.removeEventListener('wheel', block)
33
- el.removeEventListener('touchmove', block)
31
+ el.removeEventListener('wheel', block, true)
32
+ el.removeEventListener('touchmove', block, true)
34
33
  }
35
34
  }
36
35
 
@@ -0,0 +1,39 @@
1
+ import { computed, nextTick, watch, type ShallowRef } from 'vue'
2
+
3
+ export function useScrollFocus(
4
+ host: ShallowRef<HTMLElement | undefined>,
5
+ viewport: ShallowRef<HTMLElement | undefined>,
6
+ options: () => { focusable: boolean; label: string },
7
+ ) {
8
+ function attributes(enabled: boolean) {
9
+ const { focusable, label } = options()
10
+ return {
11
+ tabindex: enabled && focusable ? 0 : undefined,
12
+ role: enabled && focusable ? 'region' : undefined,
13
+ 'aria-label': enabled && focusable ? label : undefined,
14
+ }
15
+ }
16
+
17
+ const hostFocus = computed(() => attributes(!viewport.value))
18
+ const viewportFocus = computed(() => attributes(!!viewport.value))
19
+
20
+ watch(
21
+ viewport,
22
+ async element => {
23
+ const target = host.value
24
+ if (!element || !target || !options().focusable) return
25
+ if (target.ownerDocument.activeElement !== target) return
26
+ await nextTick()
27
+ const active = target.ownerDocument.activeElement
28
+ if (
29
+ options().focusable &&
30
+ element.isConnected &&
31
+ (active === target || active === target.ownerDocument.body)
32
+ )
33
+ element.focus({ preventScroll: true })
34
+ },
35
+ { flush: 'sync' },
36
+ )
37
+
38
+ return { hostFocus, viewportFocus }
39
+ }
@@ -5,7 +5,7 @@ export function useWheelRedirect(
5
5
  enabled: () => boolean,
6
6
  ) {
7
7
  function onWheel(event: WheelEvent) {
8
- if (!enabled()) return
8
+ if (event.defaultPrevented || !enabled()) return
9
9
  const el = viewport.value
10
10
  if (!el) return
11
11
  if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return
@@ -71,7 +71,12 @@
71
71
  :aria-label="slots.option ? option.label : undefined"
72
72
  :class="segmentedItem({ size: props.size, block: props.block })"
73
73
  >
74
- <Highlight v-if="option.value === current" :id="highlightId" :class="segmentedThumb()" />
74
+ <Highlight
75
+ v-if="option.value === current"
76
+ :id="highlightId"
77
+ :axis="props.orientation === 'vertical' ? 'y' : 'x'"
78
+ :class="segmentedThumb()"
79
+ />
75
80
  <Ripple />
76
81
  <slot name="option" :option="option">{{ option.label }}</slot>
77
82
  </ToggleGroupItem>
@@ -0,0 +1,24 @@
1
+ The skeleton shimmer styles and light/dark opacity values are adapted from PrimeVue / PrimeUIX
2
+ (https://github.com/primefaces/primeuix), using Hina design tokens.
3
+
4
+ MIT License
5
+
6
+ Copyright (c) 2025 PrimeTek
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
@@ -26,7 +26,12 @@
26
26
  :disabled="props.disabled"
27
27
  :class="cn(tabsTrigger({ variant, size, orientation }), props.class)"
28
28
  >
29
- <Highlight v-if="active" :id="highlightId" :class="tabsHighlight({ variant, orientation })" />
29
+ <Highlight
30
+ v-if="active"
31
+ :id="highlightId"
32
+ :axis="orientation === 'vertical' ? 'y' : 'x'"
33
+ :class="tabsHighlight({ variant, orientation })"
34
+ />
30
35
  <Ripple />
31
36
  <slot />
32
37
  </TabsTrigger>
package/src/index.ts CHANGED
@@ -227,6 +227,8 @@ export { default as Spoiler } from './components/spoiler/Spoiler.vue'
227
227
  export { default as Skeleton } from './components/skeleton/Skeleton.vue'
228
228
  export { default as Image } from './components/image/Image.vue'
229
229
  export { default as ImageGroup } from './components/image/ImageGroup.vue'
230
+ export { default as Lightbox } from './components/lightbox/Lightbox.vue'
231
+ export type { LightboxItem } from './components/lightbox/types'
230
232
  export { provideImageResolver, useImageResolver } from './components/image/resolver'
231
233
  export type { ImageResolver } from './components/image/resolver'
232
234
  export type { ImageVariants } from './components/image/image.variants'