@rimelight/ui 0.0.21 → 0.0.23
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.
- package/package.json +12 -13
- package/src/components/ThemeToggle.astro +2 -4
- package/src/components/astro/RLAFooter.astro +69 -0
- package/src/components/astro/RLAHeader.astro +178 -75
- package/src/components/astro/RLAIcon.astro +61 -0
- package/src/components/astro/RLALogo.astro +100 -0
- package/src/components/astro/RLAPlaceholder.astro +71 -0
- package/src/components/astro/RLAScrollToTop.astro +61 -15
- package/src/components/vue/RLVFooter.vue +81 -0
- package/src/components/vue/RLVHeader.vue +140 -18
- package/src/components/vue/RLVIcon.vue +64 -0
- package/src/components/vue/RLVLogo.vue +123 -0
- package/src/components/vue/RLVPlaceholder.vue +76 -0
- package/src/components/vue/RLVScrollToTop.vue +214 -154
- package/src/integrations/ui.ts +20 -8
- package/src/plugins/index.ts +2 -2
- package/src/plugins/starlightAddons/index.ts +119 -125
- package/src/presets/index.ts +7 -0
- package/src/styles/index.css +107 -107
- package/src/themes/footer.theme.ts +29 -0
- package/src/themes/header.theme.ts +32 -2
- package/src/themes/icon.theme.ts +18 -0
- package/src/themes/index.ts +12 -4
- package/src/themes/logo.theme.ts +8 -0
- package/src/themes/placeholder.theme.ts +14 -0
- package/src/utils/headerStack.ts +88 -0
|
@@ -6,12 +6,47 @@ import { scrollToTopTheme } from "../../themes/scroll-to-top.theme.ts"
|
|
|
6
6
|
import { getUIConfig } from "virtual:rimelight-ui"
|
|
7
7
|
|
|
8
8
|
export interface RLAScrollToTopProps {
|
|
9
|
+
/**
|
|
10
|
+
* Button color.
|
|
11
|
+
*
|
|
12
|
+
* @default "primary"
|
|
13
|
+
*/
|
|
9
14
|
color?: keyof typeof scrollToTopTheme.variants.color
|
|
10
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Transition duration for the progress circle (in seconds).
|
|
17
|
+
*
|
|
18
|
+
* @default 0.1
|
|
19
|
+
*/
|
|
11
20
|
duration?: number
|
|
21
|
+
/**
|
|
22
|
+
* Scroll threshold (in pixels) before the button becomes visible.
|
|
23
|
+
*
|
|
24
|
+
* @default 200
|
|
25
|
+
*/
|
|
12
26
|
threshold?: number
|
|
27
|
+
/**
|
|
28
|
+
* Whether to show the scroll progress indicator.
|
|
29
|
+
*
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
showProgress?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Stroke width of the progress circle.
|
|
35
|
+
*
|
|
36
|
+
* @default 6
|
|
37
|
+
*/
|
|
38
|
+
progressWidth?: number
|
|
39
|
+
/**
|
|
40
|
+
* UI overrides for individual slots, derived from the scroll-to-top theme.
|
|
41
|
+
*/
|
|
13
42
|
ui?: Partial<Record<keyof typeof scrollToTopTheme.base, string>>
|
|
43
|
+
/**
|
|
44
|
+
* External class — applied to the root element.
|
|
45
|
+
*/
|
|
14
46
|
class?: string
|
|
47
|
+
/**
|
|
48
|
+
* Standard HTML attributes.
|
|
49
|
+
*/
|
|
15
50
|
[key: string]: unknown
|
|
16
51
|
}
|
|
17
52
|
|
|
@@ -26,9 +61,10 @@ const scrollToTop = scv({
|
|
|
26
61
|
|
|
27
62
|
const {
|
|
28
63
|
color = "primary",
|
|
29
|
-
|
|
64
|
+
progressWidth = 6,
|
|
30
65
|
duration = 0.1,
|
|
31
66
|
threshold = 200,
|
|
67
|
+
showProgress = false,
|
|
32
68
|
ui: uiProp,
|
|
33
69
|
class: className,
|
|
34
70
|
...rest
|
|
@@ -53,7 +89,7 @@ const percentPx = circumference / 100
|
|
|
53
89
|
class:list={["rla-scroll-to-top", "fixed bottom-6 right-6 z-50", twMerge(root, mergedUI.root, className)]}
|
|
54
90
|
data-threshold={threshold}
|
|
55
91
|
data-duration={duration}
|
|
56
|
-
data-circle-stroke-width={
|
|
92
|
+
data-circle-stroke-width={progressWidth}
|
|
57
93
|
{...rest}
|
|
58
94
|
>
|
|
59
95
|
<button
|
|
@@ -63,28 +99,31 @@ const percentPx = circumference / 100
|
|
|
63
99
|
data-scroll-to-top-button
|
|
64
100
|
>
|
|
65
101
|
<div class={twMerge(progressBase, mergedUI.progressBase)}>
|
|
66
|
-
<svg class={twMerge(svgClass, mergedUI.svg)} viewBox="0 0 100 100">
|
|
102
|
+
<svg class={twMerge(svgClass, mergedUI.svg)} viewBox="0 0 100 100" shape-rendering="geometricPrecision">
|
|
67
103
|
<circle
|
|
68
104
|
cx="50"
|
|
69
105
|
cy="50"
|
|
70
106
|
r="45"
|
|
71
107
|
fill="var(--color-primary-950)"
|
|
72
|
-
stroke-width={
|
|
108
|
+
stroke-width={progressWidth}
|
|
73
109
|
stroke-dashoffset="0"
|
|
74
110
|
stroke-linecap="round"
|
|
75
111
|
class="gauge-secondary-stroke opacity-100"
|
|
76
112
|
/>
|
|
113
|
+
{showProgress && (
|
|
77
114
|
<circle
|
|
78
115
|
cx="50"
|
|
79
116
|
cy="50"
|
|
80
117
|
r="45"
|
|
81
118
|
fill="transparent"
|
|
82
|
-
stroke-width={
|
|
119
|
+
stroke-width={progressWidth}
|
|
83
120
|
stroke-dashoffset="0"
|
|
84
121
|
stroke-linecap="round"
|
|
85
122
|
class="gauge-primary-stroke opacity-100"
|
|
86
123
|
data-progress-circle
|
|
124
|
+
style={`--stroke-percent: 0; --percent-to-px: ${percentPx}px; --circumference: ${circumference}; --duration: ${duration}s`}
|
|
87
125
|
/>
|
|
126
|
+
)}
|
|
88
127
|
</svg>
|
|
89
128
|
<div class={twMerge(iconContainer, mergedUI.iconContainer)}>
|
|
90
129
|
<svg
|
|
@@ -109,11 +148,11 @@ const percentPx = circumference / 100
|
|
|
109
148
|
|
|
110
149
|
<script>
|
|
111
150
|
document.querySelectorAll('.rla-scroll-to-top').forEach((el) => {
|
|
112
|
-
const element = el
|
|
113
|
-
const button = element.querySelector('[data-scroll-to-top-button]')
|
|
114
|
-
const progressCircle = element.querySelector('[data-progress-circle]')
|
|
151
|
+
const element = el as HTMLElement
|
|
152
|
+
const button = element.querySelector('[data-scroll-to-top-button]') as HTMLButtonElement | null
|
|
153
|
+
const progressCircle = element.querySelector('[data-progress-circle]') as SVGCircleElement | null
|
|
115
154
|
|
|
116
|
-
if (!button
|
|
155
|
+
if (!button) return
|
|
117
156
|
|
|
118
157
|
const threshold = Number(element.dataset.threshold) || 200
|
|
119
158
|
|
|
@@ -128,10 +167,15 @@ const percentPx = circumference / 100
|
|
|
128
167
|
element.classList.remove('visible')
|
|
129
168
|
}
|
|
130
169
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
170
|
+
if (progressCircle) {
|
|
171
|
+
const circumference = 2 * Math.PI * 45
|
|
172
|
+
const percentPx = circumference / 100
|
|
173
|
+
const duration = element.dataset.duration || '0.1'
|
|
174
|
+
progressCircle.style.setProperty('--stroke-percent', scrollPercentage.toString())
|
|
175
|
+
progressCircle.style.setProperty('--percent-to-px', `${percentPx}px`)
|
|
176
|
+
progressCircle.style.setProperty('--circumference', circumference.toString())
|
|
177
|
+
progressCircle.style.setProperty('--duration', `${duration}s`)
|
|
178
|
+
}
|
|
135
179
|
}
|
|
136
180
|
|
|
137
181
|
button.addEventListener('click', () => {
|
|
@@ -161,9 +205,11 @@ const percentPx = circumference / 100
|
|
|
161
205
|
|
|
162
206
|
.gauge-primary-stroke {
|
|
163
207
|
stroke: var(--color-primary-500);
|
|
164
|
-
|
|
208
|
+
stroke-dasharray: calc(var(--stroke-percent, 0) * var(--percent-to-px, 2.826px)) var(--circumference, 282.6);
|
|
209
|
+
transition: stroke-dasharray var(--duration, 0.1s) ease, stroke var(--duration, 0.1s) ease;
|
|
165
210
|
transform: rotate(-90deg);
|
|
166
211
|
transform-origin: center;
|
|
212
|
+
shape-rendering: geometricPrecision;
|
|
167
213
|
}
|
|
168
214
|
|
|
169
215
|
.gauge-secondary-stroke {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
import { scv, cx } from "css-variants"
|
|
4
|
+
import { twMerge } from "tailwind-merge"
|
|
5
|
+
import { useUi } from "../../composables"
|
|
6
|
+
import { footerTheme } from "../../themes/footer.theme.ts"
|
|
7
|
+
|
|
8
|
+
const footer = scv({
|
|
9
|
+
slots: [...footerTheme.slots],
|
|
10
|
+
base: footerTheme.base,
|
|
11
|
+
variants: footerTheme.variants,
|
|
12
|
+
defaultVariants: footerTheme.defaultVariants,
|
|
13
|
+
classNameResolver: (...args) => twMerge(cx(...args))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export interface RLVFooterProps {
|
|
17
|
+
/**
|
|
18
|
+
* Whether to contain the footer content in a max-width container.
|
|
19
|
+
*
|
|
20
|
+
* @default false
|
|
21
|
+
*/
|
|
22
|
+
contain?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* UI overrides for individual slots, derived from the footer theme.
|
|
25
|
+
*/
|
|
26
|
+
ui?: Partial<Record<keyof typeof footerTheme.base, string>>
|
|
27
|
+
/**
|
|
28
|
+
* External class — applied to the root element.
|
|
29
|
+
*/
|
|
30
|
+
class?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { contain = false, class: className, ui: uiProp } = defineProps<RLVFooterProps>()
|
|
34
|
+
|
|
35
|
+
defineSlots<{
|
|
36
|
+
/**
|
|
37
|
+
* Content to display on the left side of the footer.
|
|
38
|
+
*/
|
|
39
|
+
left(props: {}): any
|
|
40
|
+
/**
|
|
41
|
+
* Content to display in the center of the footer.
|
|
42
|
+
*/
|
|
43
|
+
center(props: {}): any
|
|
44
|
+
/**
|
|
45
|
+
* Content to display on the right side of the footer.
|
|
46
|
+
*/
|
|
47
|
+
right(props: {}): any
|
|
48
|
+
}>()
|
|
49
|
+
|
|
50
|
+
const mergedUI = useUi("footer", uiProp as Record<string, unknown> | undefined)
|
|
51
|
+
|
|
52
|
+
const resolvedClasses = computed(() => {
|
|
53
|
+
const classes = footer({
|
|
54
|
+
contain
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
root: twMerge(classes.root, mergedUI.value.root as string | undefined),
|
|
59
|
+
container: twMerge(classes.container, mergedUI.value.container as string | undefined),
|
|
60
|
+
left: twMerge(classes.left, mergedUI.value.left as string | undefined),
|
|
61
|
+
center: twMerge(classes.center, mergedUI.value.center as string | undefined),
|
|
62
|
+
right: twMerge(classes.right, mergedUI.value.right as string | undefined)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<template>
|
|
68
|
+
<footer :class="[resolvedClasses.root, className]" data-slot="rlv-footer" v-bind="$attrs">
|
|
69
|
+
<div :class="resolvedClasses.container">
|
|
70
|
+
<div :class="resolvedClasses.left" data-slot="footer-left">
|
|
71
|
+
<slot name="left" />
|
|
72
|
+
</div>
|
|
73
|
+
<div :class="resolvedClasses.center" data-slot="footer-center">
|
|
74
|
+
<slot name="center" />
|
|
75
|
+
</div>
|
|
76
|
+
<div :class="resolvedClasses.right" data-slot="footer-right">
|
|
77
|
+
<slot name="right" />
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</footer>
|
|
81
|
+
</template>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from "vue"
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref } from "vue"
|
|
3
3
|
import { scv, cx } from "css-variants"
|
|
4
4
|
import { twMerge } from "tailwind-merge"
|
|
5
5
|
import { useUi } from "../../composables"
|
|
6
|
-
import { headerTheme } from "
|
|
6
|
+
import { headerTheme } from "../../themes/header.theme.ts"
|
|
7
|
+
import { getHeaderStack } from "../../utils/headerStack.ts"
|
|
8
|
+
|
|
9
|
+
// ── Style engine ─────────────────────────────────────────────────────────────
|
|
7
10
|
|
|
8
11
|
const header = scv({
|
|
9
12
|
slots: [...headerTheme.slots],
|
|
@@ -13,6 +16,8 @@ const header = scv({
|
|
|
13
16
|
classNameResolver: (...args) => twMerge(cx(...args))
|
|
14
17
|
})
|
|
15
18
|
|
|
19
|
+
// ── Props ─────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
16
21
|
export interface RLVHeaderProps {
|
|
17
22
|
/**
|
|
18
23
|
* Whether to contain the header content in a max-width container.
|
|
@@ -21,11 +26,32 @@ export interface RLVHeaderProps {
|
|
|
21
26
|
*/
|
|
22
27
|
contain?: boolean
|
|
23
28
|
/**
|
|
24
|
-
*
|
|
29
|
+
* Sticky positioning when `fixed` is false.
|
|
25
30
|
*
|
|
26
31
|
* @default true
|
|
27
32
|
*/
|
|
28
33
|
sticky?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Switches to `position: fixed; left: 0; right: 0` (layer mode). When true, `top` and
|
|
36
|
+
* `stackIndex` control the exact position.
|
|
37
|
+
*
|
|
38
|
+
* @default false
|
|
39
|
+
*/
|
|
40
|
+
fixed?: boolean
|
|
41
|
+
/**
|
|
42
|
+
* Controls z-index when `fixed` is true: `z-index = 100 - stackIndex`. Lower index = higher in
|
|
43
|
+
* the visual stack.
|
|
44
|
+
*
|
|
45
|
+
* @default 0
|
|
46
|
+
*/
|
|
47
|
+
stackIndex?: number
|
|
48
|
+
/**
|
|
49
|
+
* When true, hides the header on scroll-down and shows it on scroll-up. Uses a passive `scroll`
|
|
50
|
+
* event listener — no external dependencies. Only meaningful when `fixed` is true.
|
|
51
|
+
*
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
hideOnScroll?: boolean
|
|
29
55
|
/**
|
|
30
56
|
* UI overrides for individual slots, derived from the header theme.
|
|
31
57
|
*/
|
|
@@ -39,10 +65,15 @@ export interface RLVHeaderProps {
|
|
|
39
65
|
const {
|
|
40
66
|
contain = true,
|
|
41
67
|
sticky = true,
|
|
68
|
+
fixed = false,
|
|
69
|
+
stackIndex = 0,
|
|
70
|
+
hideOnScroll = false,
|
|
42
71
|
class: className,
|
|
43
72
|
ui: uiProp
|
|
44
73
|
} = defineProps<RLVHeaderProps>()
|
|
45
74
|
|
|
75
|
+
// ── Slots ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
46
77
|
defineSlots<{
|
|
47
78
|
/**
|
|
48
79
|
* Content to display on the left side of the header (e.g., Logo).
|
|
@@ -58,35 +89,126 @@ defineSlots<{
|
|
|
58
89
|
right(props: {}): any
|
|
59
90
|
}>()
|
|
60
91
|
|
|
92
|
+
// ── UI merge ──────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
61
94
|
const mergedUI = useUi("header", uiProp as Record<string, unknown> | undefined)
|
|
62
95
|
|
|
63
|
-
|
|
64
|
-
const classes = header({
|
|
65
|
-
contain,
|
|
66
|
-
sticky
|
|
67
|
-
})
|
|
96
|
+
// ── Resolved classes ──────────────────────────────────────────────────────────
|
|
68
97
|
|
|
98
|
+
const resolvedClasses = computed(() => {
|
|
99
|
+
const classes = header({ contain, sticky, fixed })
|
|
69
100
|
return {
|
|
70
101
|
root: twMerge(classes.root, mergedUI.value.root as string | undefined),
|
|
102
|
+
content: twMerge(classes.content, mergedUI.value.content as string | undefined),
|
|
71
103
|
container: twMerge(classes.container, mergedUI.value.container as string | undefined),
|
|
72
104
|
left: twMerge(classes.left, mergedUI.value.left as string | undefined),
|
|
73
105
|
center: twMerge(classes.center, mergedUI.value.center as string | undefined),
|
|
74
106
|
right: twMerge(classes.right, mergedUI.value.right as string | undefined)
|
|
75
107
|
}
|
|
76
108
|
})
|
|
109
|
+
|
|
110
|
+
// ── Fixed-mode state ──────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const id = `rlv-header-${Math.random().toString(36).slice(2, 8)}`
|
|
113
|
+
const rootRef = ref<HTMLElement | null>(null)
|
|
114
|
+
const contentRef = ref<HTMLElement | null>(null)
|
|
115
|
+
const naturalHeight = ref(0)
|
|
116
|
+
const isVisible = ref(true)
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Inline style applied to the root element in fixed mode.
|
|
120
|
+
*/
|
|
121
|
+
const fixedStyle = computed(() => {
|
|
122
|
+
if (!fixed) return {}
|
|
123
|
+
return {
|
|
124
|
+
zIndex: 100 - stackIndex
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// ── Lifecycle: height measurement + scroll listener ───────────────────────────
|
|
129
|
+
|
|
130
|
+
let resizeObserver: ResizeObserver | null = null
|
|
131
|
+
let scrollHandler: (() => void) | null = null
|
|
132
|
+
|
|
133
|
+
onMounted(() => {
|
|
134
|
+
if (!fixed) return
|
|
135
|
+
|
|
136
|
+
const stack = getHeaderStack()
|
|
137
|
+
|
|
138
|
+
const updateStack = () => {
|
|
139
|
+
stack.register(id, stackIndex, naturalHeight.value, isVisible.value, rootRef.value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Measure natural height via ResizeObserver
|
|
143
|
+
if (contentRef.value) {
|
|
144
|
+
naturalHeight.value = contentRef.value.getBoundingClientRect().height
|
|
145
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
146
|
+
const h = entries[0]?.contentRect.height
|
|
147
|
+
if (h && h > 0) {
|
|
148
|
+
naturalHeight.value = h
|
|
149
|
+
updateStack()
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
resizeObserver.observe(contentRef.value)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Raw scroll listener for hideOnScroll
|
|
156
|
+
if (hideOnScroll) {
|
|
157
|
+
let localLastScrollY = window.scrollY
|
|
158
|
+
scrollHandler = () => {
|
|
159
|
+
const current = window.scrollY
|
|
160
|
+
const diff = current - localLastScrollY
|
|
161
|
+
|
|
162
|
+
// Ignore tiny jitter
|
|
163
|
+
if (Math.abs(diff) < 10) return
|
|
164
|
+
|
|
165
|
+
const newVisible = current <= 50 ? true : diff <= 0
|
|
166
|
+
if (newVisible !== isVisible.value) {
|
|
167
|
+
isVisible.value = newVisible
|
|
168
|
+
updateStack()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
localLastScrollY = current
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
window.addEventListener("scroll", scrollHandler, { passive: true })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
updateStack()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
onUnmounted(() => {
|
|
181
|
+
resizeObserver?.disconnect()
|
|
182
|
+
if (scrollHandler) window.removeEventListener("scroll", scrollHandler)
|
|
183
|
+
if (fixed) getHeaderStack().unregister(id)
|
|
184
|
+
})
|
|
77
185
|
</script>
|
|
78
186
|
|
|
79
187
|
<template>
|
|
80
|
-
<header
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
188
|
+
<header
|
|
189
|
+
ref="rootRef"
|
|
190
|
+
:id="id"
|
|
191
|
+
:class="[resolvedClasses.root, className]"
|
|
192
|
+
:style="fixedStyle"
|
|
193
|
+
data-slot="rlv-header"
|
|
194
|
+
v-bind="$attrs"
|
|
195
|
+
>
|
|
196
|
+
<!--
|
|
197
|
+
content wrapper: always rendered, but only meaningful in fixed mode.
|
|
198
|
+
The ResizeObserver targets this element so the `<header>` root's padding/border
|
|
199
|
+
don't interfere with the height measurement.
|
|
200
|
+
-->
|
|
201
|
+
<div ref="contentRef" :class="resolvedClasses.content">
|
|
202
|
+
<div :class="resolvedClasses.container">
|
|
203
|
+
<div :class="resolvedClasses.left" data-slot="header-left">
|
|
204
|
+
<slot name="left" />
|
|
205
|
+
</div>
|
|
206
|
+
<div :class="resolvedClasses.center" data-slot="header-center">
|
|
207
|
+
<slot name="center" />
|
|
208
|
+
</div>
|
|
209
|
+
<div :class="resolvedClasses.right" data-slot="header-right">
|
|
210
|
+
<slot name="right" />
|
|
211
|
+
</div>
|
|
90
212
|
</div>
|
|
91
213
|
</div>
|
|
92
214
|
</header>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
import { scv, cx } from "css-variants"
|
|
4
|
+
import { twMerge } from "tailwind-merge"
|
|
5
|
+
import { useUi } from "../../composables"
|
|
6
|
+
import { iconTheme } from "../../themes/icon.theme"
|
|
7
|
+
|
|
8
|
+
const icon = scv({
|
|
9
|
+
slots: [...iconTheme.slots],
|
|
10
|
+
base: iconTheme.base,
|
|
11
|
+
variants: iconTheme.variants,
|
|
12
|
+
defaultVariants: iconTheme.defaultVariants,
|
|
13
|
+
classNameResolver: (...args) => twMerge(cx(...args))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export interface RLVIconProps {
|
|
17
|
+
/**
|
|
18
|
+
* Icon name following the UnoCSS icons format. Can be a string like "i-lucide-home" or a Vue
|
|
19
|
+
* component.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* "i-lucide-home"
|
|
23
|
+
*/
|
|
24
|
+
name: string
|
|
25
|
+
/**
|
|
26
|
+
* Size variant for the icon.
|
|
27
|
+
*/
|
|
28
|
+
size?: keyof typeof iconTheme.variants.size
|
|
29
|
+
/**
|
|
30
|
+
* External class applied to the root element.
|
|
31
|
+
*/
|
|
32
|
+
class?: string
|
|
33
|
+
/**
|
|
34
|
+
* UI overrides for individual slots.
|
|
35
|
+
*/
|
|
36
|
+
ui?: Partial<Record<keyof typeof iconTheme.base, string>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { name, size = "md", class: className, ui: uiProp } = defineProps<RLVIconProps>()
|
|
40
|
+
|
|
41
|
+
const mergedUI = useUi("icon", uiProp as Record<string, unknown> | undefined)
|
|
42
|
+
|
|
43
|
+
const resolvedClasses = computed(() => {
|
|
44
|
+
const { root } = icon({ size })
|
|
45
|
+
return {
|
|
46
|
+
root: twMerge(root, mergedUI.value.root, className)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
defineSlots<{
|
|
51
|
+
default(props: {}): any
|
|
52
|
+
}>()
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<span
|
|
57
|
+
v-bind="$attrs"
|
|
58
|
+
:class="[resolvedClasses.root, name]"
|
|
59
|
+
data-slot="rlv-icon"
|
|
60
|
+
aria-hidden="true"
|
|
61
|
+
>
|
|
62
|
+
<slot />
|
|
63
|
+
</span>
|
|
64
|
+
</template>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
import { scv, cx } from "css-variants"
|
|
4
|
+
import { twMerge } from "tailwind-merge"
|
|
5
|
+
import defu from "defu"
|
|
6
|
+
import { logoTheme } from "../../themes/logo.theme.ts"
|
|
7
|
+
import { getUIConfig } from "virtual:rimelight-ui"
|
|
8
|
+
|
|
9
|
+
const logo = scv({
|
|
10
|
+
slots: [...logoTheme.slots],
|
|
11
|
+
base: logoTheme.base,
|
|
12
|
+
variants: logoTheme.variants,
|
|
13
|
+
defaultVariants: logoTheme.defaultVariants,
|
|
14
|
+
classNameResolver: (...args) => twMerge(cx(...args))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export interface RLVLogoProps {
|
|
18
|
+
/**
|
|
19
|
+
* The variant of the logo to display.
|
|
20
|
+
*
|
|
21
|
+
* @default "logomark"
|
|
22
|
+
*/
|
|
23
|
+
variant?: string
|
|
24
|
+
/**
|
|
25
|
+
* Override the color mode.
|
|
26
|
+
*/
|
|
27
|
+
mode?: "color" | "white" | "black"
|
|
28
|
+
/**
|
|
29
|
+
* The URL to link to.
|
|
30
|
+
*
|
|
31
|
+
* @default "/"
|
|
32
|
+
*/
|
|
33
|
+
href?: string
|
|
34
|
+
/**
|
|
35
|
+
* UI overrides for individual slots, derived from the logo theme.
|
|
36
|
+
*/
|
|
37
|
+
ui?: Partial<Record<keyof typeof logoTheme.base, string>>
|
|
38
|
+
/**
|
|
39
|
+
* External class — applied to the root element.
|
|
40
|
+
*/
|
|
41
|
+
class?: string
|
|
42
|
+
/**
|
|
43
|
+
* The alt text for the logo image.
|
|
44
|
+
*/
|
|
45
|
+
alt?: string
|
|
46
|
+
/**
|
|
47
|
+
* Standard HTML attributes for <a>
|
|
48
|
+
*/
|
|
49
|
+
[key: string]: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const props = withDefaults(defineProps<RLVLogoProps>(), {
|
|
53
|
+
variant: "logomark",
|
|
54
|
+
href: "/"
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
defineSlots<{
|
|
58
|
+
/**
|
|
59
|
+
* The default slot for the logo content (e.g., text, custom elements).
|
|
60
|
+
*/
|
|
61
|
+
default(props: {}): any
|
|
62
|
+
}>()
|
|
63
|
+
|
|
64
|
+
const globalConfig = getUIConfig()
|
|
65
|
+
|
|
66
|
+
const mergedUI = computed(
|
|
67
|
+
() =>
|
|
68
|
+
defu(props.ui ?? {}, (globalConfig.logo as Record<string, unknown>) ?? {}) as Record<
|
|
69
|
+
keyof typeof logoTheme.base,
|
|
70
|
+
string | undefined
|
|
71
|
+
>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const resolvedClasses = computed(() => {
|
|
75
|
+
const { root } = logo()
|
|
76
|
+
return {
|
|
77
|
+
root: twMerge(root, mergedUI.value.root, props.class)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const logoSrc = computed<string | null>(() => {
|
|
82
|
+
const rcLogos = globalConfig.logos as Record<string, any>
|
|
83
|
+
let src: any = null
|
|
84
|
+
|
|
85
|
+
if (rcLogos && typeof rcLogos === "object") {
|
|
86
|
+
src = rcLogos[props.variant]
|
|
87
|
+
if (src && typeof src === "object") {
|
|
88
|
+
src = src[props.mode || "color"] || src.black || src.color
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (src && typeof src === "string" && src) return src
|
|
93
|
+
|
|
94
|
+
return null
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const isIcon = computed(() => {
|
|
98
|
+
const src = logoSrc.value
|
|
99
|
+
if (!src) return false
|
|
100
|
+
if (src.startsWith("http") || src.startsWith("/") || src.startsWith(".")) return false
|
|
101
|
+
return src.includes(":") || src.startsWith("i-")
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const Tag = computed(() => (props.href ? "a" : "div"))
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<component
|
|
109
|
+
v-bind="$attrs"
|
|
110
|
+
:is="Tag"
|
|
111
|
+
:href="Tag === 'a' ? href : undefined"
|
|
112
|
+
:aria-label="alt || variant"
|
|
113
|
+
:class="resolvedClasses.root"
|
|
114
|
+
data-slot="rlv-logo"
|
|
115
|
+
>
|
|
116
|
+
<template v-if="logoSrc">
|
|
117
|
+
<!-- Using mode='svg' pattern is not strictly needed for UnoCSS icons -->
|
|
118
|
+
<span v-if="isIcon" :class="[logoSrc, 'size-full block shrink-0']" aria-hidden="true" />
|
|
119
|
+
<img v-else :src="logoSrc" :alt="alt || ''" class="size-full block object-contain shrink-0" />
|
|
120
|
+
</template>
|
|
121
|
+
<slot v-else />
|
|
122
|
+
</component>
|
|
123
|
+
</template>
|