@rimelight/ui 0.0.21 → 0.0.22

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.
@@ -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>
@@ -1,154 +1,214 @@
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 { scrollToTopTheme } from "../../themes/scroll-to-top.theme.ts"
7
- import { useScrollToTop } from "../../composables/useScrollToTop"
8
- import { getUIConfig } from "virtual:rimelight-ui"
9
-
10
- export interface RLVScrollToTopProps {
11
- color?: keyof typeof scrollToTopTheme.variants.color
12
- circleStrokeWidth?: number
13
- duration?: number
14
- threshold?: number
15
- ui?: Partial<Record<keyof typeof scrollToTopTheme.base, string>>
16
- class?: string
17
- [key: string]: unknown
18
- }
19
-
20
- const scrollToTop = scv({
21
- slots: [...scrollToTopTheme.slots],
22
- base: scrollToTopTheme.base,
23
- variants: scrollToTopTheme.variants,
24
- compoundVariants: [...scrollToTopTheme.compoundVariants],
25
- defaultVariants: scrollToTopTheme.defaultVariants,
26
- classNameResolver: (...args) => twMerge(cx(...args))
27
- })
28
-
29
- const {
30
- color = "primary",
31
- circleStrokeWidth = 4,
32
- duration = 0.1,
33
- threshold = 200,
34
- ui: uiProp,
35
- class: className,
36
- } = defineProps<RLVScrollToTopProps>()
37
-
38
- const globalConfig = getUIConfig()
39
-
40
- const mergedUI = defu(
41
- uiProp ?? {},
42
- (globalConfig["scroll-to-top"] as Record<string, unknown>) ?? {}
43
- ) as Record<keyof typeof scrollToTopTheme.base, string | undefined>
44
-
45
- defineSlots<{
46
- default(props: {}): any
47
- }>()
48
-
49
- const { root, button, progressBase, svg: svgClass, iconContainer, icon } = scrollToTop({
50
- color
51
- })
52
-
53
- const { isVisible, scrollPercentage, scrollToTop: scrollToTopFn } = useScrollToTop({
54
- threshold
55
- })
56
-
57
- const circumference = 2 * Math.PI * 45
58
- const percentPx = circumference / 100
59
-
60
- const currentPercent = computed(() => scrollPercentage.value)
61
- const percentageInPx = computed(() => `${percentPx}px`)
62
- const durationInSeconds = computed(() => `${duration}s`)
63
- </script>
64
-
65
- <template>
66
- <div
67
- :class="[
68
- 'rla-scroll-to-top fixed bottom-6 right-6 z-50',
69
- { 'visible': isVisible },
70
- twMerge(root, mergedUI.root, className)
71
- ]"
72
- v-bind="$attrs"
73
- >
74
- <button
75
- type="button"
76
- :class="twMerge(button, mergedUI.button)"
77
- aria-label="Scroll to top"
78
- @click="scrollToTopFn"
79
- >
80
- <div :class="twMerge(progressBase, mergedUI.progressBase)">
81
- <svg :class="twMerge(svgClass, mergedUI.svg)" viewBox="0 0 100 100">
82
- <circle
83
- cx="50"
84
- cy="50"
85
- r="45"
86
- fill="var(--color-primary-950)"
87
- :stroke-width="circleStrokeWidth"
88
- stroke-dashoffset="0"
89
- stroke-linecap="round"
90
- class="gauge-secondary-stroke opacity-100"
91
- />
92
- <circle
93
- cx="50"
94
- cy="50"
95
- r="45"
96
- fill="transparent"
97
- :stroke-width="circleStrokeWidth"
98
- stroke-linecap="round"
99
- class="gauge-primary-stroke opacity-100"
100
- :style="{ strokeDasharray: `${(currentPercent / 100) * circumference} ${circumference}` }"
101
- />
102
- </svg>
103
- <div :class="twMerge(iconContainer, mergedUI.iconContainer)">
104
- <svg
105
- xmlns="http://www.w3.org/2000/svg"
106
- width="24"
107
- height="24"
108
- viewBox="0 0 24 24"
109
- fill="none"
110
- stroke="currentColor"
111
- stroke-width="2"
112
- stroke-linecap="round"
113
- stroke-linejoin="round"
114
- :class="twMerge(icon, mergedUI.icon)"
115
- >
116
- <path d="m5 12 7-7 7 7" />
117
- <path d="M12 19V5" />
118
- </svg>
119
- </div>
120
- </div>
121
- </button>
122
- </div>
123
- </template>
124
-
125
- <style scoped>
126
- .rla-scroll-to-top {
127
- opacity: 0;
128
- pointer-events: none;
129
- transition: opacity 0.3s ease-in-out;
130
- }
131
-
132
- .rla-scroll-to-top.visible {
133
- opacity: 1;
134
- pointer-events: auto;
135
- }
136
-
137
- .progress-circle-base {
138
- transform: translateZ(0);
139
- }
140
-
141
- .gauge-primary-stroke {
142
- stroke: var(--color-primary-500);
143
- transition: stroke-dasharray 0.1s ease, stroke 0.1s ease;
144
- transform: rotate(-90deg);
145
- transform-origin: center;
146
- }
147
-
148
- .gauge-secondary-stroke {
149
- stroke: var(--color-primary-900);
150
- stroke-dasharray: 282.6;
151
- transform: rotate(-90deg);
152
- transform-origin: center;
153
- }
154
- </style>
1
+ <script setup lang="ts">
2
+ import { scv, cx } from "css-variants"
3
+ import { twMerge } from "tailwind-merge"
4
+ import defu from "defu"
5
+ import { scrollToTopTheme } from "../../themes/scroll-to-top.theme.ts"
6
+ import { useScrollToTop } from "../../composables/useScrollToTop"
7
+ import { computed } from "vue"
8
+ import { getUIConfig } from "virtual:rimelight-ui"
9
+
10
+ export interface RLVScrollToTopProps {
11
+ /**
12
+ * Button color.
13
+ *
14
+ * @default "primary"
15
+ */
16
+ color?: keyof typeof scrollToTopTheme.variants.color
17
+ /**
18
+ * Transition duration for the progress circle (in seconds).
19
+ *
20
+ * @default 0.1
21
+ */
22
+ duration?: number
23
+ /**
24
+ * Scroll threshold (in pixels) before the button becomes visible.
25
+ *
26
+ * @default 200
27
+ */
28
+ threshold?: number
29
+ /**
30
+ * Whether to show the scroll progress indicator.
31
+ *
32
+ * @default false
33
+ */
34
+ showProgress?: boolean
35
+ /**
36
+ * Stroke width of the progress circle.
37
+ *
38
+ * @default 6
39
+ */
40
+ progressWidth?: number
41
+ /**
42
+ * UI overrides for individual slots, derived from the scroll-to-top theme.
43
+ */
44
+ ui?: Partial<Record<keyof typeof scrollToTopTheme.base, string>>
45
+ /**
46
+ * External class — applied to the root element.
47
+ */
48
+ class?: string
49
+ /**
50
+ * Standard HTML attributes.
51
+ */
52
+ [key: string]: unknown
53
+ }
54
+
55
+ const scrollToTop = scv({
56
+ slots: [...scrollToTopTheme.slots],
57
+ base: scrollToTopTheme.base,
58
+ variants: scrollToTopTheme.variants,
59
+ compoundVariants: [...scrollToTopTheme.compoundVariants],
60
+ defaultVariants: scrollToTopTheme.defaultVariants,
61
+ classNameResolver: (...args) => twMerge(cx(...args))
62
+ })
63
+
64
+ const {
65
+ color = "primary",
66
+ progressWidth = 6,
67
+ duration = 0.1,
68
+ threshold = 200,
69
+ showProgress = false,
70
+ ui: uiProp,
71
+ class: className
72
+ } = defineProps<RLVScrollToTopProps>()
73
+
74
+ const globalConfig = getUIConfig()
75
+
76
+ const mergedUI = defu(
77
+ uiProp ?? {},
78
+ (globalConfig["scroll-to-top"] as Record<string, unknown>) ?? {}
79
+ ) as Record<keyof typeof scrollToTopTheme.base, string | undefined>
80
+
81
+ const {
82
+ root,
83
+ button,
84
+ progressBase,
85
+ svg: svgClass,
86
+ iconContainer,
87
+ icon
88
+ } = scrollToTop({
89
+ color
90
+ })
91
+
92
+ const {
93
+ isVisible,
94
+ scrollPercentage,
95
+ scrollToTop: scrollToTopFn
96
+ } = useScrollToTop({
97
+ threshold
98
+ })
99
+
100
+ const circumference = 2 * Math.PI * 45
101
+ const percentPx = circumference / 100
102
+
103
+ const currentPercent = computed(() => scrollPercentage.value)
104
+ const percentageInPx = computed(() => `${percentPx}px`)
105
+ const durationInSeconds = computed(() => `${duration}s`)
106
+ </script>
107
+
108
+ <template>
109
+ <div
110
+ :class="[
111
+ 'rla-scroll-to-top fixed bottom-6 right-6 z-50',
112
+ { visible: isVisible },
113
+ twMerge(root, mergedUI.root, className)
114
+ ]"
115
+ v-bind="$attrs"
116
+ >
117
+ <button
118
+ type="button"
119
+ :class="twMerge(button, mergedUI.button)"
120
+ aria-label="Scroll to top"
121
+ @click="scrollToTopFn"
122
+ >
123
+ <div :class="twMerge(progressBase, mergedUI.progressBase)">
124
+ <svg
125
+ :class="twMerge(svgClass, mergedUI.svg)"
126
+ viewBox="0 0 100 100"
127
+ shape-rendering="geometricPrecision"
128
+ >
129
+ <circle
130
+ cx="50"
131
+ cy="50"
132
+ r="45"
133
+ fill="var(--color-primary-950)"
134
+ :stroke-width="progressWidth"
135
+ stroke-dashoffset="0"
136
+ stroke-linecap="round"
137
+ class="gauge-secondary-stroke opacity-100"
138
+ />
139
+ <circle
140
+ v-if="showProgress"
141
+ cx="50"
142
+ cy="50"
143
+ r="45"
144
+ fill="transparent"
145
+ :stroke-width="progressWidth"
146
+ stroke-linecap="round"
147
+ class="gauge-primary-stroke opacity-100"
148
+ :style="{
149
+ '--stroke-percent': currentPercent,
150
+ '--percent-to-px': percentageInPx,
151
+ '--circumference': circumference,
152
+ '--duration': durationInSeconds
153
+ }"
154
+ />
155
+ </svg>
156
+ <div :class="twMerge(iconContainer, mergedUI.iconContainer)">
157
+ <svg
158
+ xmlns="http://www.w3.org/2000/svg"
159
+ width="24"
160
+ height="24"
161
+ viewBox="0 0 24 24"
162
+ fill="none"
163
+ stroke="currentColor"
164
+ stroke-width="2"
165
+ stroke-linecap="round"
166
+ stroke-linejoin="round"
167
+ :class="twMerge(icon, mergedUI.icon)"
168
+ >
169
+ <path d="m5 12 7-7 7 7" />
170
+ <path d="M12 19V5" />
171
+ </svg>
172
+ </div>
173
+ </div>
174
+ </button>
175
+ </div>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .rla-scroll-to-top {
180
+ opacity: 0;
181
+ pointer-events: none;
182
+ transition: opacity 0.3s ease-in-out;
183
+ }
184
+
185
+ .rla-scroll-to-top.visible {
186
+ opacity: 1;
187
+ pointer-events: auto;
188
+ }
189
+
190
+ .progress-circle-base {
191
+ transform: translateZ(0);
192
+ }
193
+
194
+ .gauge-primary-stroke {
195
+ stroke: var(--color-primary-500);
196
+ --stroke-percent: v-bind(currentPercent);
197
+ --percent-to-px: v-bind(percentageInPx);
198
+ --circumference: v-bind(circumference);
199
+ stroke-dasharray: calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference);
200
+ transition:
201
+ stroke-dasharray v-bind(durationInSeconds) ease,
202
+ stroke v-bind(durationInSeconds) ease;
203
+ transform: rotate(-90deg);
204
+ transform-origin: center;
205
+ shape-rendering: geometricPrecision;
206
+ }
207
+
208
+ .gauge-secondary-stroke {
209
+ stroke: var(--color-primary-900);
210
+ stroke-dasharray: 282.6;
211
+ transform: rotate(-90deg);
212
+ transform-origin: center;
213
+ }
214
+ </style>
@@ -11,8 +11,8 @@ import { defaultUIConfig } from "../themes"
11
11
  * Shape of the `ui` config that can be passed to the integration.
12
12
  */
13
13
  export type UIConfigOverrides = {
14
- button?: Partial<(typeof defaultUIConfig)["button"]>
15
- header?: Partial<(typeof defaultUIConfig)["header"]>
14
+ "button"?: Partial<(typeof defaultUIConfig)["button"]>
15
+ "header"?: Partial<(typeof defaultUIConfig)["header"]>
16
16
  "scroll-to-top"?: Partial<(typeof defaultUIConfig)["scroll-to-top"]>
17
17
  }
18
18
 
@@ -1,2 +1,2 @@
1
- export { default as starlightAddons } from "../plugins/starlightAddons"
2
- export type { StarlightAddonsConfig } from "../plugins/starlightAddons"
1
+ export { default as starlightAddons } from "../plugins/starlightAddons"
2
+ export type { StarlightAddonsConfig } from "../plugins/starlightAddons"