| 1 | <template> |
| 2 | <button class="theme-switch" alt="theme-switch" aria-label="theme-switch" @click="toggleTheme"> |
| 3 | <Transition name="rotate"> |
| 4 | <Icon v-if="isThemeDark" :size="20"> |
| 5 | <Iconify :icon="Sunny" class="hover" /> |
| 6 | <Iconify :icon="SunnyOutline" /> |
| 7 | </Icon> |
| 8 | <Icon v-else :size="20"> |
| 9 | <Iconify :icon="Moon" class="hover" /> |
| 10 | <Iconify :icon="MoonOutline" /> |
| 11 | </Icon> |
| 12 | </Transition> |
| 13 | </button> |
| 14 | </template> |
| 15 | |
| 16 | <script lang="ts" setup> |
| 17 | import { Icon as Iconify } from "@iconify/vue" |
| 18 | import { computed, nextTick } from "vue" |
| 19 | import Icon from "@/components/common/Icon.vue" |
| 20 | import { useThemeStore } from "@/stores/theme" |
| 21 | |
| 22 | const Sunny = "ion:sunny" |
| 23 | const Moon = "ion:moon" |
| 24 | const SunnyOutline = "ion:sunny-outline" |
| 25 | const MoonOutline = "ion:moon-outline" |
| 26 | const themeStore = useThemeStore() |
| 27 | const isThemeDark = computed<boolean>(() => themeStore.isThemeDark) |
| 28 | |
| 29 | function toggleTheme(event?: MouseEvent) { |
| 30 | const isAppearanceTransition = |
| 31 | typeof document !== "undefined" && |
| 32 | document.startViewTransition && |
| 33 | !window.matchMedia("(prefers-reduced-motion: reduce)").matches |
| 34 | |
| 35 | if (!isAppearanceTransition || !event) { |
| 36 | themeStore.toggleTheme() |
| 37 | return |
| 38 | } |
| 39 | |
| 40 | if (document?.startViewTransition) { |
| 41 | const x = event.clientX ?? innerWidth / 2 |
| 42 | const y = event.clientY ?? innerHeight / 2 |
| 43 | const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)) |
| 44 | |
| 45 | const transition = document.startViewTransition(async () => { |
| 46 | themeStore.toggleTheme() |
| 47 | await nextTick() |
| 48 | }) |
| 49 | |
| 50 | transition.ready.then(() => { |
| 51 | const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] |
| 52 | // const clipPath = [`inset(50%)`, `inset(0)`] |
| 53 | |
| 54 | document.documentElement.animate( |
| 55 | { |
| 56 | clipPath |
| 57 | }, |
| 58 | { |
| 59 | duration: 300, |
| 60 | easing: "ease-in", |
| 61 | pseudoElement: "::view-transition-new(root)" |
| 62 | } |
| 63 | ) |
| 64 | }) |
| 65 | } |
| 66 | } |
| 67 | </script> |
| 68 | |
| 69 | <style scoped lang="scss"> |
| 70 | .theme-switch { |
| 71 | position: relative; |
| 72 | width: 20px; |
| 73 | height: 20px; |
| 74 | overflow: hidden; |
| 75 | outline: none; |
| 76 | border: none; |
| 77 | |
| 78 | :deep() { |
| 79 | .n-icon { |
| 80 | position: absolute; |
| 81 | top: 0; |
| 82 | left: 0; |
| 83 | |
| 84 | & > svg { |
| 85 | position: absolute; |
| 86 | top: 0; |
| 87 | left: 0; |
| 88 | transition: opacity 0.35s; |
| 89 | |
| 90 | &.hover { |
| 91 | opacity: 0; |
| 92 | } |
| 93 | &:not(.hover) { |
| 94 | opacity: 1; |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | &:hover { |
| 99 | & > svg { |
| 100 | &.hover { |
| 101 | opacity: 1; |
| 102 | } |
| 103 | &:not(.hover) { |
| 104 | opacity: 0; |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | .rotate-enter-active, |
| 112 | .rotate-leave-active { |
| 113 | transition: all 0.5s ease-out; |
| 114 | } |
| 115 | .rotate-enter-from { |
| 116 | opacity: 0; |
| 117 | transform: rotate(45deg); |
| 118 | } |
| 119 | .rotate-leave-to { |
| 120 | opacity: 0; |
| 121 | transform: rotate(-45deg); |
| 122 | } |
| 123 | </style> |