@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.
@@ -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
- circleStrokeWidth?: number
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
- circleStrokeWidth = 4,
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={circleStrokeWidth}
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={circleStrokeWidth}
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={circleStrokeWidth}
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 || !progressCircle) return
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
- const circumference = 2 * Math.PI * 45
132
- const percentPx = circumference / 100
133
- const strokeDasharray = `${(scrollPercentage * percentPx)} ${circumference}`
134
- progressCircle.setAttribute('stroke-dasharray', strokeDasharray)
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
- transition: stroke-dasharray 0.1s ease, stroke 0.1s ease;
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 "@/themes/header.theme.ts"
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
- * Whether the header should be sticky at the top.
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
- const resolvedClasses = computed(() => {
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 :class="[resolvedClasses.root, className]" data-slot="rlv-header" v-bind="$attrs">
81
- <div :class="resolvedClasses.container">
82
- <div :class="resolvedClasses.left" data-slot="header-left">
83
- <slot name="left" />
84
- </div>
85
- <div :class="resolvedClasses.center" data-slot="header-center">
86
- <slot name="center" />
87
- </div>
88
- <div :class="resolvedClasses.right" data-slot="header-right">
89
- <slot name="right" />
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>