@hina-ui/vue 1.2.0 → 1.3.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 (58) hide show
  1. package/dist/index.js +613 -302
  2. package/dist/index.js.map +1 -1
  3. package/dist/types/components/alert-dialog/AlertDialog.vue.d.ts +4 -0
  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/dropdown-menu/DropdownMenu.vue.d.ts +18 -6
  18. package/dist/types/components/dropdown-menu/DropdownMenu.vue.d.ts.map +1 -1
  19. package/dist/types/components/highlight/Highlight.vue.d.ts +2 -0
  20. package/dist/types/components/highlight/Highlight.vue.d.ts.map +1 -1
  21. package/dist/types/components/highlight/composables/useHighlightMotion.d.ts +10 -0
  22. package/dist/types/components/highlight/composables/useHighlightMotion.d.ts.map +1 -0
  23. package/dist/types/components/lightbox/LightboxThumbs.vue.d.ts.map +1 -1
  24. package/dist/types/components/popover/Popover.vue.d.ts +19 -6
  25. package/dist/types/components/popover/Popover.vue.d.ts.map +1 -1
  26. package/dist/types/components/popover/PopoverContent.vue.d.ts +29 -0
  27. package/dist/types/components/popover/PopoverContent.vue.d.ts.map +1 -0
  28. package/dist/types/components/popover/composables/usePopoverCloseFocus.d.ts +2 -0
  29. package/dist/types/components/popover/composables/usePopoverCloseFocus.d.ts.map +1 -0
  30. package/dist/types/components/scroll-area/ScrollArea.vue.d.ts.map +1 -1
  31. package/dist/types/components/scroll-area/composables/useScrollFocus.d.ts +17 -0
  32. package/dist/types/components/scroll-area/composables/useScrollFocus.d.ts.map +1 -0
  33. package/dist/types/components/segmented-control/SegmentedControl.vue.d.ts.map +1 -1
  34. package/dist/types/components/tabs/TabsTrigger.vue.d.ts.map +1 -1
  35. package/dist/types/lib/anchored-overlay.d.ts +23 -0
  36. package/dist/types/lib/anchored-overlay.d.ts.map +1 -0
  37. package/package.json +1 -1
  38. package/src/components/alert-dialog/AlertDialog.vue +24 -36
  39. package/src/components/alert-dialog/alert-dialog.variants.ts +17 -0
  40. package/src/components/alert-dialog/composables/useAlertDialogConfirm.ts +29 -0
  41. package/src/components/alert-dialog/composables/useConfirmDelay.ts +37 -0
  42. package/src/components/anchor/Anchor.vue +1 -0
  43. package/src/components/code-block/CodeBlock.vue +15 -30
  44. package/src/components/code-block/code-block.variants.ts +21 -0
  45. package/src/components/code-block/composables/useCodeHighlight.ts +30 -0
  46. package/src/components/dropdown-menu/DropdownMenu.vue +16 -4
  47. package/src/components/highlight/Highlight.vue +5 -4
  48. package/src/components/highlight/composables/useHighlightMotion.ts +22 -0
  49. package/src/components/lightbox/LightboxThumbs.vue +1 -0
  50. package/src/components/popover/Popover.vue +21 -5
  51. package/src/components/popover/PopoverContent.vue +25 -0
  52. package/src/components/popover/composables/usePopoverCloseFocus.ts +17 -0
  53. package/src/components/scroll-area/ScrollArea.vue +7 -5
  54. package/src/components/scroll-area/composables/useScrollFocus.ts +39 -0
  55. package/src/components/segmented-control/SegmentedControl.vue +6 -1
  56. package/src/components/tabs/TabsTrigger.vue +6 -1
  57. package/src/lib/anchored-overlay.ts +110 -0
  58. package/src/styles/typography.css +9 -4
@@ -1,12 +1,17 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, shallowRef, watch } from 'vue'
3
- import type { ThemedToken } from 'shiki/core'
2
+ import { computed } from 'vue'
4
3
  import { cn } from '../../lib/cn'
5
- import { devWarn } from '../../lib/dev'
6
4
  import { useUiLocale } from '../../locale'
7
5
  import CopyButton from '../copy-button/CopyButton.vue'
8
6
  import ScrollArea from '../scroll-area/ScrollArea.vue'
9
- import { tokenize } from './highlighter'
7
+ import { useCodeHighlight } from './composables/useCodeHighlight'
8
+ import {
9
+ codeBlock,
10
+ codeBlockArea,
11
+ codeBlockContent,
12
+ codeBlockActions,
13
+ codeBlockLabel,
14
+ } from './code-block.variants'
10
15
 
11
16
  defineOptions({ name: 'HnCodeBlock' })
12
17
 
@@ -25,38 +30,18 @@
25
30
  const t = useUiLocale()
26
31
  const tag = computed(() => props.label ?? props.lang)
27
32
 
28
- const tokens = shallowRef<ThemedToken[][] | null>(null)
29
-
30
- onMounted(() => {
31
- watch(
32
- () => [props.code, props.lang, props.html] as const,
33
- async ([code, lang, html]) => {
34
- if (html || !lang) {
35
- tokens.value = null
36
- return
37
- }
38
- try {
39
- const result = await tokenize(code, lang)
40
- if (code === props.code && lang === props.lang) tokens.value = result
41
- } catch (error) {
42
- tokens.value = null
43
- devWarn('CodeBlock', `着色管线失败,已退回素文本(lang="${lang}"):${String(error)}`, lang)
44
- }
45
- },
46
- { immediate: true },
47
- )
48
- })
33
+ const tokens = useCodeHighlight(props)
49
34
  </script>
50
35
 
51
36
  <template>
52
- <div :data-lang="props.lang" :class="cn('relative', props.class)">
53
- <ScrollArea direction="horizontal" focusable :label="tag" class="hn-pre">
37
+ <div :data-lang="props.lang" :class="cn(codeBlock(), props.class)">
38
+ <ScrollArea direction="both" focusable :label="tag" :class="codeBlockArea()">
54
39
  <pre
55
- class="m-0"
40
+ :class="codeBlockContent()"
56
41
  ><code v-if="props.html" v-html="props.html"></code><code v-else-if="tokens"><template v-for="(line, i) of tokens" :key="i">{{ i ? '\n' : '' }}<span v-for="(tk, j) of line" :key="j" :style="tk.htmlStyle">{{ tk.content }}</span></template></code><code v-else>{{ code }}</code></pre>
57
42
  </ScrollArea>
58
- <div class="absolute top-2 end-2 flex items-center gap-2">
59
- <span v-if="tag" class="text-faint font-mono text-xs select-none">{{ tag }}</span>
43
+ <div :class="codeBlockActions()">
44
+ <span v-if="tag" :class="codeBlockLabel()">{{ tag }}</span>
60
45
  <CopyButton v-if="copyable" :text="props.code" :label="t.codeblock.copy" />
61
46
  </div>
62
47
  </div>
@@ -0,0 +1,21 @@
1
+ import { tv } from '../../lib/tv'
2
+
3
+ export const codeBlock = tv({
4
+ base: 'relative flex min-h-0 min-w-0 flex-col',
5
+ })
6
+
7
+ export const codeBlockArea = tv({
8
+ base: 'hn-pre min-h-0 grow p-0',
9
+ })
10
+
11
+ export const codeBlockContent = tv({
12
+ base: 'hn-pre-content m-0 w-max min-w-full',
13
+ })
14
+
15
+ export const codeBlockActions = tv({
16
+ base: 'absolute top-2 end-2 flex items-center gap-2',
17
+ })
18
+
19
+ export const codeBlockLabel = tv({
20
+ base: 'text-faint font-mono text-xs select-none',
21
+ })
@@ -0,0 +1,30 @@
1
+ import { onMounted, shallowRef, watch } from 'vue'
2
+ import type { ThemedToken } from 'shiki/core'
3
+ import { devWarn } from '../../../lib/dev'
4
+ import { tokenize } from '../highlighter'
5
+
6
+ export function useCodeHighlight(props: { code: string; lang?: string; html?: string }) {
7
+ const tokens = shallowRef<ThemedToken[][] | null>(null)
8
+
9
+ onMounted(() => {
10
+ watch(
11
+ () => [props.code, props.lang, props.html] as const,
12
+ async ([code, lang, html]) => {
13
+ if (html || !lang) {
14
+ tokens.value = null
15
+ return
16
+ }
17
+ try {
18
+ const result = await tokenize(code, lang)
19
+ if (code === props.code && lang === props.lang) tokens.value = result
20
+ } catch (error) {
21
+ tokens.value = null
22
+ devWarn('CodeBlock', `着色管线失败,已退回素文本(lang="${lang}"):${String(error)}`, lang)
23
+ }
24
+ },
25
+ { immediate: true },
26
+ )
27
+ })
28
+
29
+ return tokens
30
+ }
@@ -5,24 +5,32 @@
5
5
  DropdownMenuTrigger,
6
6
  DropdownMenuPortal,
7
7
  DropdownMenuContent,
8
+ type DropdownMenuContentEmits,
8
9
  } from 'reka-ui'
10
+ import { useAnchoredOverlay } from '../../lib/anchored-overlay'
9
11
  import Card from '../card/Card.vue'
10
12
  import { cn } from '../../lib/cn'
11
13
 
12
- defineOptions({ name: 'HnDropdownMenu' })
14
+ defineOptions({ name: 'HnDropdownMenu', inheritAttrs: false })
13
15
 
14
16
  const props = withDefaults(
15
17
  defineProps<{
16
18
  label?: string
19
+ anchor?: HTMLElement | null
20
+ modal?: boolean
21
+ dir?: 'ltr' | 'rtl'
17
22
  side?: 'top' | 'right' | 'bottom' | 'left'
18
23
  align?: 'start' | 'center' | 'end'
19
24
  sideOffset?: number
20
25
  class?: string
21
26
  }>(),
22
- { side: 'bottom', align: 'center', sideOffset: 8 },
27
+ { side: 'bottom', align: 'center', sideOffset: 8, modal: true },
23
28
  )
24
29
 
25
30
  const open = defineModel<boolean>('open')
31
+ const emit = defineEmits<DropdownMenuContentEmits>()
32
+ defineSlots<{ default?(): unknown; content?(): unknown }>()
33
+ const { trigger, reference, visible, events } = useAnchoredOverlay(props, open, emit)
26
34
 
27
35
  const pressOrigin = computed(() => {
28
36
  if (props.side === 'left') return 'right'
@@ -34,8 +42,10 @@
34
42
  </script>
35
43
 
36
44
  <template>
37
- <DropdownMenuRoot v-model:open="open" modal>
45
+ <DropdownMenuRoot v-model:open="visible" :modal="props.modal" :dir="props.dir">
38
46
  <DropdownMenuTrigger
47
+ v-if="$slots.default"
48
+ ref="trigger"
39
49
  as-child
40
50
  class="group/hn-disclosure"
41
51
  :style="{ transformOrigin: pressOrigin }"
@@ -44,8 +54,10 @@
44
54
  </DropdownMenuTrigger>
45
55
  <DropdownMenuPortal>
46
56
  <DropdownMenuContent
57
+ v-if="reference"
58
+ v-bind="{ 'aria-label': props.label, ...$attrs, ...events }"
59
+ :reference="reference"
47
60
  as-child
48
- :aria-label="props.label"
49
61
  :side="props.side"
50
62
  :align="props.align"
51
63
  :side-offset="props.sideOffset"
@@ -1,21 +1,21 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
3
2
  import { Motion } from 'motion-v'
4
3
  import { cn } from '../../lib/cn'
5
- import { TRANSITION, prefersReducedMotion } from '../../motion'
4
+ import { useHighlightMotion } from './composables/useHighlightMotion'
6
5
 
7
6
  defineOptions({ name: 'HnHighlight' })
8
7
 
9
8
  const props = withDefaults(
10
9
  defineProps<{
11
10
  id?: string
11
+ axis?: 'x' | 'y' | 'both'
12
12
  as?: 'div' | 'li'
13
13
  class?: string
14
14
  }>(),
15
- { id: undefined, as: 'div' },
15
+ { id: undefined, axis: 'both', as: 'div' },
16
16
  )
17
17
 
18
- const transition = computed(() => (prefersReducedMotion() ? { duration: 0 } : TRANSITION.layout))
18
+ const { transition, transformTemplate } = useHighlightMotion(() => props.axis)
19
19
  </script>
20
20
 
21
21
  <template>
@@ -26,6 +26,7 @@
26
26
  aria-hidden="true"
27
27
  data-hn-highlight
28
28
  :transition="transition"
29
+ :transform-template="transformTemplate"
29
30
  :class="cn('pointer-events-none', props.class)"
30
31
  />
31
32
  </template>
@@ -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
+ }
@@ -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>
@@ -1,23 +1,32 @@
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'
5
7
  import { cn } from '../../lib/cn'
6
8
 
7
- defineOptions({ name: 'HnPopover' })
9
+ defineOptions({ name: 'HnPopover', inheritAttrs: false })
8
10
 
9
11
  const props = withDefaults(
10
12
  defineProps<{
13
+ anchor?: HTMLElement | null
14
+ modal?: boolean
11
15
  side?: 'top' | 'right' | 'bottom' | 'left'
12
16
  align?: 'start' | 'center' | 'end'
13
17
  sideOffset?: number
14
18
  padded?: boolean
15
19
  class?: string
16
20
  }>(),
17
- { side: 'bottom', align: 'center', sideOffset: 8, padded: true },
21
+ { side: 'bottom', align: 'center', sideOffset: 8, padded: true, modal: true },
18
22
  )
19
23
 
20
24
  const open = defineModel<boolean>('open')
25
+ const emit = defineEmits<PopoverContentEmits>()
26
+ defineSlots<{ default?(): unknown; content?(): unknown }>()
27
+ const { trigger, reference, visible, events } = useAnchoredOverlay(props, open, emit, event =>
28
+ emit('openAutoFocus', event),
29
+ )
21
30
 
22
31
  const pressOrigin = computed(() => {
23
32
  if (props.side === 'left') return 'right'
@@ -29,12 +38,19 @@
29
38
  </script>
30
39
 
31
40
  <template>
32
- <PopoverRoot v-model:open="open" modal>
33
- <PopoverTrigger as-child :style="{ transformOrigin: pressOrigin }">
41
+ <PopoverRoot v-model:open="visible" :modal="props.modal">
42
+ <PopoverTrigger
43
+ v-if="$slots.default"
44
+ ref="trigger"
45
+ as-child
46
+ :style="{ transformOrigin: pressOrigin }"
47
+ >
34
48
  <slot />
35
49
  </PopoverTrigger>
36
50
  <PopoverPortal>
37
51
  <PopoverContent
52
+ v-bind="{ ...$attrs, ...events }"
53
+ :reference="reference"
38
54
  as-child
39
55
  :side="props.side"
40
56
  :align="props.align"
@@ -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>
@@ -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
+ }
@@ -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>
@@ -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>
@@ -0,0 +1,110 @@
1
+ import { computed, nextTick, shallowRef, watch, type Ref } from 'vue'
2
+ import type { DropdownMenuContentEmits } from 'reka-ui'
3
+
4
+ export function useAnchoredOverlay(
5
+ props: { anchor?: HTMLElement | null; modal: boolean },
6
+ open: Ref<boolean | undefined>,
7
+ emit: <K extends keyof DropdownMenuContentEmits>(
8
+ event: K,
9
+ ...args: DropdownMenuContentEmits[K]
10
+ ) => void,
11
+ openAutoFocus?: (event: Event) => void,
12
+ ) {
13
+ const trigger = shallowRef<{ $el: HTMLElement } | null>(null)
14
+ const triggerElement = computed(() =>
15
+ trigger.value?.$el?.nodeType === 1 ? trigger.value.$el : undefined,
16
+ )
17
+ const retainedAnchor = shallowRef<HTMLElement>()
18
+ const reference = computed(
19
+ () =>
20
+ props.anchor ??
21
+ (open.value
22
+ ? (triggerElement.value ?? retainedAnchor.value)
23
+ : (retainedAnchor.value ?? triggerElement.value)),
24
+ )
25
+ const visible = computed({
26
+ get: () => !!open.value && !!(props.anchor || triggerElement.value),
27
+ set: value => {
28
+ open.value = value
29
+ },
30
+ })
31
+
32
+ watch(
33
+ () => props.anchor,
34
+ anchor => {
35
+ if (anchor) retainedAnchor.value = anchor
36
+ },
37
+ { immediate: true, flush: 'sync' },
38
+ )
39
+
40
+ let previousFocus: HTMLElement | null = null
41
+ let interactedOutside = false
42
+
43
+ function once<E extends Event>(handler: (event: E) => void) {
44
+ const handled = new WeakSet<E>()
45
+ return (event: E) => {
46
+ if (handled.has(event)) return
47
+ handled.add(event)
48
+ handler(event)
49
+ }
50
+ }
51
+
52
+ watch(
53
+ visible,
54
+ active => {
55
+ if (!active) return
56
+ const document = props.anchor?.ownerDocument ?? triggerElement.value?.ownerDocument
57
+ previousFocus = document?.activeElement as HTMLElement | null
58
+ interactedOutside = false
59
+ },
60
+ { immediate: true, flush: 'sync' },
61
+ )
62
+
63
+ const onCloseAutoFocus = once((event: Event) => {
64
+ emit('closeAutoFocus', event)
65
+ const cancelled = event.defaultPrevented
66
+ event.preventDefault()
67
+ const panel = event.target as HTMLElement
68
+ const target = triggerElement.value ?? previousFocus
69
+ const restore = !cancelled && !interactedOutside
70
+ nextTick(() => {
71
+ const active = panel.ownerDocument.activeElement
72
+ if (
73
+ restore &&
74
+ !visible.value &&
75
+ target?.isConnected &&
76
+ (active === panel.ownerDocument.body || panel.contains(active))
77
+ )
78
+ target.focus({ preventScroll: true })
79
+ if (!visible.value) retainedAnchor.value = undefined
80
+ })
81
+ })
82
+
83
+ const onInteractOutside = once((event: DropdownMenuContentEmits['interactOutside'][0]) => {
84
+ if (!triggerElement.value && props.anchor?.contains(event.target as Node))
85
+ event.preventDefault()
86
+ emit('interactOutside', event)
87
+ const original = event.detail.originalEvent
88
+ const rightClick =
89
+ 'button' in original && (original.button === 2 || (original.button === 0 && original.ctrlKey))
90
+ if (!event.defaultPrevented && (!props.modal || rightClick)) interactedOutside = true
91
+ })
92
+
93
+ return {
94
+ trigger,
95
+ reference,
96
+ visible,
97
+ events: {
98
+ ...(openAutoFocus ? { onOpenAutoFocus: once(openAutoFocus) } : {}),
99
+ onCloseAutoFocus,
100
+ onInteractOutside,
101
+ onPointerDownOutside: once((event: DropdownMenuContentEmits['pointerDownOutside'][0]) =>
102
+ emit('pointerDownOutside', event),
103
+ ),
104
+ onFocusOutside: once((event: DropdownMenuContentEmits['focusOutside'][0]) =>
105
+ emit('focusOutside', event),
106
+ ),
107
+ onEscapeKeyDown: once((event: KeyboardEvent) => emit('escapeKeyDown', event)),
108
+ },
109
+ }
110
+ }
@@ -74,14 +74,19 @@
74
74
  }
75
75
  }
76
76
 
77
+ @utility hn-pre-content {
78
+ padding: 1em 1.25em;
79
+ }
80
+
77
81
  @utility hn-pre {
82
+ @apply hn-pre-content;
78
83
  font-family: var(--hn-font-mono);
79
84
  background-color: var(--hn-bg-inset);
80
85
  border-radius: var(--hn-radius-lg);
81
- padding: 1em 1.25em;
82
86
 
83
87
  &:focus-visible,
84
- &:has(> [data-overlayscrollbars-initialize]:focus-visible) {
88
+ &:has(> [data-overlayscrollbars-initialize]:focus-visible),
89
+ &:has(> [data-overlayscrollbars-initialize] > [data-overlayscrollbars-contents]:focus-visible) {
85
90
  outline: var(--hn-focus-ring-width) solid var(--hn-focus-ring);
86
91
  outline-offset: var(--hn-focus-ring-offset);
87
92
  }
@@ -144,7 +149,7 @@
144
149
  & ol,
145
150
  & dl,
146
151
  & blockquote,
147
- & pre,
152
+ & pre:not(:where(.hn-pre-content)),
148
153
  & table,
149
154
  & figure {
150
155
  margin-block: 0.75em 0;
@@ -228,7 +233,7 @@
228
233
  @apply hn-code;
229
234
  }
230
235
 
231
- & pre {
236
+ & pre:not(:where(.hn-pre-content)) {
232
237
  @apply hn-pre;
233
238
  overflow-x: auto;
234
239
  }