@sabrenski/spire-ui 0.0.1
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/LICENSE +21 -0
- package/README.md +233 -0
- package/dist/index.d.ts +4981 -0
- package/dist/spire-ui.css +1 -0
- package/dist/spire-ui.es.js +18403 -0
- package/dist/spire-ui.umd.js +45 -0
- package/package.json +83 -0
- package/src/components/Accordion/Accordion.test.ts +218 -0
- package/src/components/Accordion/AccordionContent.vue +112 -0
- package/src/components/Accordion/AccordionItem.vue +87 -0
- package/src/components/Accordion/AccordionRoot.vue +111 -0
- package/src/components/Accordion/AccordionTrigger.vue +125 -0
- package/src/components/Accordion/index.ts +11 -0
- package/src/components/Accordion/keys.ts +23 -0
- package/src/components/Avatar/Avatar.test.ts +181 -0
- package/src/components/Avatar/Avatar.vue +150 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.test.ts +141 -0
- package/src/components/Badge/Badge.vue +133 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/BadgeContainer/BadgeContainer.test.ts +150 -0
- package/src/components/BadgeContainer/BadgeContainer.vue +90 -0
- package/src/components/BadgeContainer/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.test.ts +342 -0
- package/src/components/Breadcrumb/BreadcrumbEllipsis.vue +96 -0
- package/src/components/Breadcrumb/BreadcrumbItem.vue +16 -0
- package/src/components/Breadcrumb/BreadcrumbLink.vue +67 -0
- package/src/components/Breadcrumb/BreadcrumbList.vue +20 -0
- package/src/components/Breadcrumb/BreadcrumbPage.vue +25 -0
- package/src/components/Breadcrumb/BreadcrumbRoot.vue +41 -0
- package/src/components/Breadcrumb/BreadcrumbSeparator.vue +63 -0
- package/src/components/Breadcrumb/index.ts +13 -0
- package/src/components/Breadcrumb/keys.ts +7 -0
- package/src/components/Button/Button.test.ts +231 -0
- package/src/components/Button/Button.vue +349 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Callout/Callout.test.ts +260 -0
- package/src/components/Callout/Callout.vue +341 -0
- package/src/components/Callout/index.ts +2 -0
- package/src/components/Card/Card.test.ts +565 -0
- package/src/components/Card/Card.vue +209 -0
- package/src/components/Card/CardContent.vue +57 -0
- package/src/components/Card/CardFooter.vue +72 -0
- package/src/components/Card/CardHeader.vue +111 -0
- package/src/components/Card/CardImage.vue +124 -0
- package/src/components/Card/index.ts +14 -0
- package/src/components/Chart/BarChart.vue +208 -0
- package/src/components/Chart/BaseChart.vue +444 -0
- package/src/components/Chart/Chart.test.ts +359 -0
- package/src/components/Chart/DonutChart.vue +283 -0
- package/src/components/Chart/LineChart.vue +211 -0
- package/src/components/Chart/index.ts +20 -0
- package/src/components/Chart/useChartTheme.ts +192 -0
- package/src/components/Checkbox/Checkbox.test.ts +209 -0
- package/src/components/Checkbox/Checkbox.vue +285 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/ChoiceChip/ChoiceChip.test.ts +142 -0
- package/src/components/ChoiceChip/ChoiceChip.vue +218 -0
- package/src/components/ChoiceChip/index.ts +2 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.test.ts +151 -0
- package/src/components/ChoiceChipGroup/ChoiceChipGroup.vue +70 -0
- package/src/components/ChoiceChipGroup/index.ts +2 -0
- package/src/components/ColorPicker/ColorArea.vue +159 -0
- package/src/components/ColorPicker/ColorPicker.test.ts +250 -0
- package/src/components/ColorPicker/ColorPicker.vue +339 -0
- package/src/components/ColorPicker/ColorSlider.vue +191 -0
- package/src/components/ColorPicker/index.ts +7 -0
- package/src/components/Combobox/Combobox.test.ts +891 -0
- package/src/components/Combobox/Combobox.vue +934 -0
- package/src/components/Combobox/index.ts +2 -0
- package/src/components/DataTable/DataTable.test.ts +1221 -0
- package/src/components/DataTable/DataTable.vue +1415 -0
- package/src/components/DataTable/index.ts +10 -0
- package/src/components/DatePicker/DatePicker.test.ts +625 -0
- package/src/components/DatePicker/DatePicker.vue +1586 -0
- package/src/components/DatePicker/index.ts +2 -0
- package/src/components/Drawer/Drawer.test.ts +336 -0
- package/src/components/Drawer/Drawer.vue +466 -0
- package/src/components/Drawer/index.ts +2 -0
- package/src/components/Dropdown/Dropdown.test.ts +607 -0
- package/src/components/Dropdown/Dropdown.vue +807 -0
- package/src/components/Dropdown/DropdownItem.vue +227 -0
- package/src/components/Dropdown/DropdownSeparator.vue +14 -0
- package/src/components/Dropdown/DropdownSub.vue +104 -0
- package/src/components/Dropdown/DropdownSubContent.vue +187 -0
- package/src/components/Dropdown/DropdownSubTrigger.vue +151 -0
- package/src/components/Dropdown/index.ts +14 -0
- package/src/components/EmptyState/EmptyState.test.ts +180 -0
- package/src/components/EmptyState/EmptyState.vue +137 -0
- package/src/components/EmptyState/index.ts +2 -0
- package/src/components/FileUpload/FileUpload.test.ts +1151 -0
- package/src/components/FileUpload/FileUpload.vue +1042 -0
- package/src/components/FileUpload/index.ts +2 -0
- package/src/components/Heading/Heading.test.ts +107 -0
- package/src/components/Heading/Heading.vue +67 -0
- package/src/components/Heading/index.ts +2 -0
- package/src/components/Icon/Icon.test.ts +157 -0
- package/src/components/Icon/Icon.vue +86 -0
- package/src/components/Icon/index.ts +2 -0
- package/src/components/Input/Input.test.ts +273 -0
- package/src/components/Input/Input.vue +388 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Layout/Container.vue +67 -0
- package/src/components/Layout/Grid.vue +159 -0
- package/src/components/Layout/GridItem.vue +154 -0
- package/src/components/Layout/Layout.test.ts +202 -0
- package/src/components/Layout/Stack.vue +128 -0
- package/src/components/Layout/index.ts +9 -0
- package/src/components/Layout/keys.ts +7 -0
- package/src/components/Modal/Modal.test.ts +311 -0
- package/src/components/Modal/Modal.vue +336 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Pagination/Pagination.test.ts +303 -0
- package/src/components/Pagination/Pagination.vue +212 -0
- package/src/components/Pagination/index.ts +3 -0
- package/src/components/Pagination/utils.ts +86 -0
- package/src/components/Popover/Popover.test.ts +285 -0
- package/src/components/Popover/Popover.vue +441 -0
- package/src/components/Popover/index.ts +2 -0
- package/src/components/Progress/Progress.test.ts +361 -0
- package/src/components/Progress/Progress.vue +363 -0
- package/src/components/Progress/index.ts +7 -0
- package/src/components/Radio/Radio.test.ts +216 -0
- package/src/components/Radio/Radio.vue +214 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Rating/Rating.test.ts +319 -0
- package/src/components/Rating/Rating.vue +247 -0
- package/src/components/Rating/index.ts +2 -0
- package/src/components/SegmentedControl/SegmentedControl.test.ts +292 -0
- package/src/components/SegmentedControl/SegmentedControl.vue +288 -0
- package/src/components/SegmentedControl/index.ts +2 -0
- package/src/components/Select/Select.test.ts +589 -0
- package/src/components/Select/Select.vue +666 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Sidebar/Sidebar.test.ts +301 -0
- package/src/components/Sidebar/SidebarGroup.vue +103 -0
- package/src/components/Sidebar/SidebarItem.vue +196 -0
- package/src/components/Sidebar/SidebarLayout.vue +42 -0
- package/src/components/Sidebar/SidebarRoot.vue +122 -0
- package/src/components/Sidebar/index.ts +11 -0
- package/src/components/Sidebar/keys.ts +14 -0
- package/src/components/Skeleton/Skeleton.test.ts +130 -0
- package/src/components/Skeleton/Skeleton.vue +104 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.test.ts +416 -0
- package/src/components/Slider/Slider.vue +435 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Slider/utils.ts +91 -0
- package/src/components/Spinner/Spinner.test.ts +79 -0
- package/src/components/Spinner/Spinner.vue +159 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/SpireProvider/SpireProvider.vue +71 -0
- package/src/components/SpireProvider/index.ts +11 -0
- package/src/components/Stepper/Stepper.test.ts +221 -0
- package/src/components/Stepper/StepperContent.vue +51 -0
- package/src/components/Stepper/StepperItem.vue +89 -0
- package/src/components/Stepper/StepperRoot.vue +101 -0
- package/src/components/Stepper/StepperSeparator.vue +52 -0
- package/src/components/Stepper/StepperTrigger.vue +144 -0
- package/src/components/Stepper/index.ts +11 -0
- package/src/components/Stepper/keys.ts +27 -0
- package/src/components/Switch/Switch.test.ts +214 -0
- package/src/components/Switch/Switch.vue +235 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Tabs/Tabs.test.ts +363 -0
- package/src/components/Tabs/Tabs.vue +318 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Text/Text.test.ts +154 -0
- package/src/components/Text/Text.vue +100 -0
- package/src/components/Text/index.ts +2 -0
- package/src/components/Textarea/Textarea.test.ts +432 -0
- package/src/components/Textarea/Textarea.vue +411 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/TimePicker/TimePicker.test.ts +352 -0
- package/src/components/TimePicker/TimePicker.vue +569 -0
- package/src/components/TimePicker/index.ts +2 -0
- package/src/components/Timeline/Timeline.test.ts +193 -0
- package/src/components/Timeline/Timeline.vue +111 -0
- package/src/components/Timeline/TimelineItem.vue +167 -0
- package/src/components/Timeline/index.ts +13 -0
- package/src/components/Timeline/keys.ts +21 -0
- package/src/components/Toast/ToastItem.test.ts +289 -0
- package/src/components/Toast/ToastItem.vue +370 -0
- package/src/components/Toast/ToastProvider.test.ts +158 -0
- package/src/components/Toast/ToastProvider.vue +181 -0
- package/src/components/Toast/index.ts +83 -0
- package/src/components/Toast/toastState.test.ts +165 -0
- package/src/components/Toast/toastState.ts +161 -0
- package/src/components/ToggleButton/ToggleButton.test.ts +166 -0
- package/src/components/ToggleButton/ToggleButton.vue +197 -0
- package/src/components/ToggleButton/index.ts +2 -0
- package/src/components/ToggleGroup/ToggleGroup.test.ts +181 -0
- package/src/components/ToggleGroup/ToggleGroup.vue +130 -0
- package/src/components/ToggleGroup/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.test.ts +238 -0
- package/src/components/Tooltip/Tooltip.vue +217 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/components/TreeView/TreeView.test.ts +357 -0
- package/src/components/TreeView/TreeView.vue +251 -0
- package/src/components/TreeView/TreeViewItem.vue +288 -0
- package/src/components/TreeView/index.ts +11 -0
- package/src/components/TreeView/keys.ts +35 -0
- package/src/composables/index.ts +12 -0
- package/src/composables/useClickOutside.ts +36 -0
- package/src/composables/useClipboard.ts +35 -0
- package/src/composables/useEventListener.ts +48 -0
- package/src/composables/useFocusTrap.ts +58 -0
- package/src/composables/useHoverReveal.ts +98 -0
- package/src/composables/useId.ts +10 -0
- package/src/composables/useMagnetic.ts +171 -0
- package/src/composables/useRelativePosition.ts +127 -0
- package/src/composables/useRipple.ts +146 -0
- package/src/composables/useScrollLock.ts +25 -0
- package/src/composables/useSpireConfig.ts +27 -0
- package/src/composables/useStagger.ts +224 -0
- package/src/config/icons.test.ts +115 -0
- package/src/config/icons.ts +170 -0
- package/src/index.ts +361 -0
- package/src/styles/depth.css +129 -0
- package/src/styles/effects.css +169 -0
- package/src/styles/fallback.css +152 -0
- package/src/styles/main.css +25 -0
- package/src/styles/mood.css +211 -0
- package/src/styles/motion.css +159 -0
- package/src/styles/reset.css +97 -0
- package/src/styles/theme.css +708 -0
- package/src/styles/tokens.css +183 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/color.ts +277 -0
- package/src/utils/date.test.ts +522 -0
- package/src/utils/date.ts +380 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/object.test.ts +80 -0
- package/src/utils/object.ts +25 -0
- package/src/utils/string.test.ts +64 -0
- package/src/utils/string.ts +32 -0
- package/src/utils/time.ts +156 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { ref, watch, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UseMagneticOptions {
|
|
4
|
+
strength?: number
|
|
5
|
+
radius?: number
|
|
6
|
+
ease?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseMagneticReturn {
|
|
10
|
+
x: Ref<number>
|
|
11
|
+
y: Ref<number>
|
|
12
|
+
isHovering: Ref<boolean>
|
|
13
|
+
style: Ref<Record<string, string>>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Creates a magnetic hover effect where the element subtly follows the cursor.
|
|
18
|
+
* Handles deferred elements (tabs, v-if) by watching for target availability.
|
|
19
|
+
*
|
|
20
|
+
* @param target - Ref to the target element
|
|
21
|
+
* @param options - Configuration options
|
|
22
|
+
* @returns Reactive values for position and style
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```vue
|
|
26
|
+
* <script setup>
|
|
27
|
+
* const buttonRef = ref<HTMLElement | null>(null)
|
|
28
|
+
* const { style } = useMagnetic(buttonRef, { strength: 0.3 })
|
|
29
|
+
* </script>
|
|
30
|
+
*
|
|
31
|
+
* <template>
|
|
32
|
+
* <button ref="buttonRef" :style="style">Magnetic Button</button>
|
|
33
|
+
* </template>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function useMagnetic(
|
|
37
|
+
target: Ref<HTMLElement | null>,
|
|
38
|
+
options: UseMagneticOptions = {}
|
|
39
|
+
): UseMagneticReturn {
|
|
40
|
+
const { strength = 0.2, radius = 100, ease = 0.15 } = options
|
|
41
|
+
|
|
42
|
+
const x = ref(0)
|
|
43
|
+
const y = ref(0)
|
|
44
|
+
const isHovering = ref(false)
|
|
45
|
+
const style = ref<Record<string, string>>({})
|
|
46
|
+
|
|
47
|
+
let animationFrame: number | null = null
|
|
48
|
+
let targetX = 0
|
|
49
|
+
let targetY = 0
|
|
50
|
+
let currentX = 0
|
|
51
|
+
let currentY = 0
|
|
52
|
+
let isInitialized = false
|
|
53
|
+
let currentElement: HTMLElement | null = null
|
|
54
|
+
|
|
55
|
+
function lerp(start: number, end: number, factor: number): number {
|
|
56
|
+
return start + (end - start) * factor
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function animate() {
|
|
60
|
+
currentX = lerp(currentX, targetX, ease)
|
|
61
|
+
currentY = lerp(currentY, targetY, ease)
|
|
62
|
+
|
|
63
|
+
const deltaX = Math.abs(currentX - targetX)
|
|
64
|
+
const deltaY = Math.abs(currentY - targetY)
|
|
65
|
+
|
|
66
|
+
if (deltaX < 0.01 && deltaY < 0.01) {
|
|
67
|
+
currentX = targetX
|
|
68
|
+
currentY = targetY
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
x.value = currentX
|
|
72
|
+
y.value = currentY
|
|
73
|
+
style.value = {
|
|
74
|
+
transform: `translate(${currentX}px, ${currentY}px)`,
|
|
75
|
+
transition: 'none'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (isHovering.value || currentX !== 0 || currentY !== 0) {
|
|
79
|
+
animationFrame = requestAnimationFrame(animate)
|
|
80
|
+
} else {
|
|
81
|
+
animationFrame = null
|
|
82
|
+
style.value = {
|
|
83
|
+
transform: 'translate(0, 0)',
|
|
84
|
+
transition: 'transform var(--duration-normal, 200ms) var(--ease-out, ease-out)'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleMouseMove(event: MouseEvent) {
|
|
90
|
+
if (!target.value) return
|
|
91
|
+
|
|
92
|
+
const rect = target.value.getBoundingClientRect()
|
|
93
|
+
const centerX = rect.left + rect.width / 2
|
|
94
|
+
const centerY = rect.top + rect.height / 2
|
|
95
|
+
|
|
96
|
+
const distanceX = event.clientX - centerX
|
|
97
|
+
const distanceY = event.clientY - centerY
|
|
98
|
+
const distance = Math.sqrt(distanceX ** 2 + distanceY ** 2)
|
|
99
|
+
|
|
100
|
+
if (distance < radius) {
|
|
101
|
+
isHovering.value = true
|
|
102
|
+
const factor = 1 - distance / radius
|
|
103
|
+
targetX = distanceX * strength * factor
|
|
104
|
+
targetY = distanceY * strength * factor
|
|
105
|
+
|
|
106
|
+
if (!animationFrame) {
|
|
107
|
+
animationFrame = requestAnimationFrame(animate)
|
|
108
|
+
}
|
|
109
|
+
} else if (isHovering.value) {
|
|
110
|
+
handleMouseLeave()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function handleMouseLeave() {
|
|
115
|
+
isHovering.value = false
|
|
116
|
+
targetX = 0
|
|
117
|
+
targetY = 0
|
|
118
|
+
|
|
119
|
+
if (!animationFrame) {
|
|
120
|
+
animationFrame = requestAnimationFrame(animate)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function initialize(element: HTMLElement) {
|
|
125
|
+
if (isInitialized && currentElement === element) return
|
|
126
|
+
|
|
127
|
+
cleanup()
|
|
128
|
+
|
|
129
|
+
currentElement = element
|
|
130
|
+
isInitialized = true
|
|
131
|
+
|
|
132
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
133
|
+
element.addEventListener('mouseleave', handleMouseLeave)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cleanup() {
|
|
137
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
138
|
+
if (currentElement) {
|
|
139
|
+
currentElement.removeEventListener('mouseleave', handleMouseLeave)
|
|
140
|
+
}
|
|
141
|
+
if (animationFrame) {
|
|
142
|
+
cancelAnimationFrame(animationFrame)
|
|
143
|
+
animationFrame = null
|
|
144
|
+
}
|
|
145
|
+
isInitialized = false
|
|
146
|
+
currentElement = null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
watch(
|
|
150
|
+
target,
|
|
151
|
+
(newTarget) => {
|
|
152
|
+
if (newTarget) {
|
|
153
|
+
initialize(newTarget)
|
|
154
|
+
} else {
|
|
155
|
+
cleanup()
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
{ immediate: true }
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
onUnmounted(() => {
|
|
162
|
+
cleanup()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
x,
|
|
167
|
+
y,
|
|
168
|
+
isHovering,
|
|
169
|
+
style
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type Placement = 'top' | 'bottom' | 'left' | 'right'
|
|
4
|
+
|
|
5
|
+
export interface PositionResult {
|
|
6
|
+
top: number
|
|
7
|
+
left: number
|
|
8
|
+
actualPlacement: Placement
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Pure calculation function - no reactive dependencies.
|
|
13
|
+
* Can be called synchronously before first paint.
|
|
14
|
+
*/
|
|
15
|
+
export function calculatePosition(
|
|
16
|
+
triggerEl: HTMLElement,
|
|
17
|
+
contentEl: HTMLElement,
|
|
18
|
+
desiredPlacement: Placement,
|
|
19
|
+
offset: number
|
|
20
|
+
): PositionResult {
|
|
21
|
+
const triggerRect = triggerEl.getBoundingClientRect()
|
|
22
|
+
|
|
23
|
+
// Use offsetWidth/Height for content - not affected by CSS transforms (scale)
|
|
24
|
+
const contentWidth = contentEl.offsetWidth
|
|
25
|
+
const contentHeight = contentEl.offsetHeight
|
|
26
|
+
|
|
27
|
+
const viewport = {
|
|
28
|
+
width: window.innerWidth || 1024,
|
|
29
|
+
height: window.innerHeight || 768
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
|
33
|
+
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
|
34
|
+
|
|
35
|
+
function calcPosition(p: Placement): { top: number; left: number } {
|
|
36
|
+
switch (p) {
|
|
37
|
+
case 'top':
|
|
38
|
+
return {
|
|
39
|
+
top: triggerRect.top - contentHeight - offset,
|
|
40
|
+
left: triggerCenterX - contentWidth / 2
|
|
41
|
+
}
|
|
42
|
+
case 'bottom':
|
|
43
|
+
return {
|
|
44
|
+
top: triggerRect.bottom + offset,
|
|
45
|
+
left: triggerCenterX - contentWidth / 2
|
|
46
|
+
}
|
|
47
|
+
case 'left':
|
|
48
|
+
return {
|
|
49
|
+
top: triggerCenterY - contentHeight / 2,
|
|
50
|
+
left: triggerRect.left - contentWidth - offset
|
|
51
|
+
}
|
|
52
|
+
case 'right':
|
|
53
|
+
return {
|
|
54
|
+
top: triggerCenterY - contentHeight / 2,
|
|
55
|
+
left: triggerRect.right + offset
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const opposites: Record<Placement, Placement> = {
|
|
61
|
+
top: 'bottom',
|
|
62
|
+
bottom: 'top',
|
|
63
|
+
left: 'right',
|
|
64
|
+
right: 'left'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let currentPlacement = desiredPlacement
|
|
68
|
+
let pos = calcPosition(currentPlacement)
|
|
69
|
+
|
|
70
|
+
const overflows = {
|
|
71
|
+
top: pos.top < 0,
|
|
72
|
+
bottom: pos.top + contentHeight > viewport.height,
|
|
73
|
+
left: pos.left < 0,
|
|
74
|
+
right: pos.left + contentWidth > viewport.width
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
(currentPlacement === 'top' && overflows.top) ||
|
|
79
|
+
(currentPlacement === 'bottom' && overflows.bottom) ||
|
|
80
|
+
(currentPlacement === 'left' && overflows.left) ||
|
|
81
|
+
(currentPlacement === 'right' && overflows.right)
|
|
82
|
+
) {
|
|
83
|
+
currentPlacement = opposites[currentPlacement]
|
|
84
|
+
pos = calcPosition(currentPlacement)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const top = Math.max(offset, Math.min(pos.top, viewport.height - contentHeight - offset))
|
|
88
|
+
const left = Math.max(offset, Math.min(pos.left, viewport.width - contentWidth - offset))
|
|
89
|
+
|
|
90
|
+
return { top, left, actualPlacement: currentPlacement }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Composable for positioning floating elements relative to a trigger.
|
|
95
|
+
* Includes collision detection to flip placement when near viewport edges.
|
|
96
|
+
*/
|
|
97
|
+
export function useRelativePosition(
|
|
98
|
+
trigger: Ref<HTMLElement | null>,
|
|
99
|
+
content: Ref<HTMLElement | null>,
|
|
100
|
+
placement: Ref<Placement> | Placement | (() => Placement) = 'top',
|
|
101
|
+
offset = 8
|
|
102
|
+
) {
|
|
103
|
+
const coords = ref<PositionResult>({ top: 0, left: 0, actualPlacement: 'top' })
|
|
104
|
+
|
|
105
|
+
function getPlacement(): Placement {
|
|
106
|
+
if (typeof placement === 'string') return placement
|
|
107
|
+
if (typeof placement === 'function') return placement()
|
|
108
|
+
return placement.value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function updatePosition() {
|
|
112
|
+
if (!trigger.value || !content.value) return
|
|
113
|
+
coords.value = calculatePosition(trigger.value, content.value, getPlacement(), offset)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onMounted(() => {
|
|
117
|
+
window.addEventListener('resize', updatePosition)
|
|
118
|
+
window.addEventListener('scroll', updatePosition, true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
onUnmounted(() => {
|
|
122
|
+
window.removeEventListener('resize', updatePosition)
|
|
123
|
+
window.removeEventListener('scroll', updatePosition, true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return { coords, updatePosition, getPlacement }
|
|
127
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { watch, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UseRippleOptions {
|
|
4
|
+
color?: string
|
|
5
|
+
opacity?: number
|
|
6
|
+
duration?: number
|
|
7
|
+
disabled?: Ref<boolean> | boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a ripple effect on click for interactive elements.
|
|
12
|
+
* Handles deferred elements (tabs, v-if) by watching for target availability.
|
|
13
|
+
*
|
|
14
|
+
* @param target - Ref to the target element
|
|
15
|
+
* @param options - Configuration options
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```vue
|
|
19
|
+
* <script setup>
|
|
20
|
+
* const buttonRef = ref<HTMLElement | null>(null)
|
|
21
|
+
* useRipple(buttonRef, { color: 'white', opacity: 0.3 })
|
|
22
|
+
* </script>
|
|
23
|
+
*
|
|
24
|
+
* <template>
|
|
25
|
+
* <button ref="buttonRef" data-ripple>Click me</button>
|
|
26
|
+
* </template>
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function useRipple(
|
|
30
|
+
target: Ref<HTMLElement | null>,
|
|
31
|
+
options: UseRippleOptions = {}
|
|
32
|
+
): void {
|
|
33
|
+
const {
|
|
34
|
+
color = 'var(--effect-ripple-color, white)',
|
|
35
|
+
opacity = 0.25,
|
|
36
|
+
duration = 800,
|
|
37
|
+
disabled = false
|
|
38
|
+
} = options
|
|
39
|
+
|
|
40
|
+
let isInitialized = false
|
|
41
|
+
let currentElement: HTMLElement | null = null
|
|
42
|
+
let styleElement: HTMLStyleElement | null = null
|
|
43
|
+
|
|
44
|
+
function isDisabled(): boolean {
|
|
45
|
+
if (typeof disabled === 'boolean') return disabled
|
|
46
|
+
return disabled.value
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureKeyframes(): void {
|
|
50
|
+
if (document.querySelector('style[data-ripple-keyframes]')) return
|
|
51
|
+
|
|
52
|
+
styleElement = document.createElement('style')
|
|
53
|
+
styleElement.setAttribute('data-ripple-keyframes', '')
|
|
54
|
+
styleElement.textContent = `
|
|
55
|
+
@keyframes ui-ripple-expand {
|
|
56
|
+
0% {
|
|
57
|
+
opacity: 0;
|
|
58
|
+
transform: scale(0);
|
|
59
|
+
}
|
|
60
|
+
15% {
|
|
61
|
+
opacity: var(--ripple-opacity, 0.25);
|
|
62
|
+
}
|
|
63
|
+
100% {
|
|
64
|
+
transform: scale(2.5);
|
|
65
|
+
opacity: 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`
|
|
69
|
+
document.head.appendChild(styleElement)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createRipple(event: MouseEvent) {
|
|
73
|
+
if (!target.value || isDisabled()) return
|
|
74
|
+
|
|
75
|
+
const element = target.value
|
|
76
|
+
const rect = element.getBoundingClientRect()
|
|
77
|
+
|
|
78
|
+
const x = event.clientX - rect.left
|
|
79
|
+
const y = event.clientY - rect.top
|
|
80
|
+
|
|
81
|
+
const size = Math.max(rect.width, rect.height) * 2
|
|
82
|
+
|
|
83
|
+
const ripple = document.createElement('span')
|
|
84
|
+
ripple.className = 'ui-ripple'
|
|
85
|
+
ripple.style.cssText = `
|
|
86
|
+
position: absolute;
|
|
87
|
+
left: ${x - size / 2}px;
|
|
88
|
+
top: ${y - size / 2}px;
|
|
89
|
+
width: ${size}px;
|
|
90
|
+
height: ${size}px;
|
|
91
|
+
border-radius: 50%;
|
|
92
|
+
background: ${color};
|
|
93
|
+
--ripple-opacity: ${opacity};
|
|
94
|
+
opacity: 0;
|
|
95
|
+
transform: scale(0);
|
|
96
|
+
pointer-events: none;
|
|
97
|
+
animation: ui-ripple-expand ${duration}ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
98
|
+
`
|
|
99
|
+
|
|
100
|
+
element.style.position = element.style.position || 'relative'
|
|
101
|
+
element.style.overflow = 'hidden'
|
|
102
|
+
|
|
103
|
+
element.appendChild(ripple)
|
|
104
|
+
|
|
105
|
+
ripple.addEventListener('animationend', () => {
|
|
106
|
+
ripple.remove()
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function initialize(element: HTMLElement) {
|
|
111
|
+
if (isInitialized && currentElement === element) return
|
|
112
|
+
|
|
113
|
+
cleanup()
|
|
114
|
+
|
|
115
|
+
currentElement = element
|
|
116
|
+
isInitialized = true
|
|
117
|
+
|
|
118
|
+
ensureKeyframes()
|
|
119
|
+
element.addEventListener('click', createRipple)
|
|
120
|
+
element.setAttribute('data-ripple', '')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cleanup() {
|
|
124
|
+
if (currentElement) {
|
|
125
|
+
currentElement.removeEventListener('click', createRipple)
|
|
126
|
+
}
|
|
127
|
+
isInitialized = false
|
|
128
|
+
currentElement = null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
watch(
|
|
132
|
+
target,
|
|
133
|
+
(newTarget) => {
|
|
134
|
+
if (newTarget) {
|
|
135
|
+
initialize(newTarget)
|
|
136
|
+
} else {
|
|
137
|
+
cleanup()
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{ immediate: true }
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
onUnmounted(() => {
|
|
144
|
+
cleanup()
|
|
145
|
+
})
|
|
146
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { onUnmounted, watch, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Locks body scroll when active, preventing background scrolling.
|
|
5
|
+
* Compensates for scrollbar width to prevent layout shift.
|
|
6
|
+
*/
|
|
7
|
+
export function useScrollLock(isLocked: Ref<boolean>) {
|
|
8
|
+
const lock = () => {
|
|
9
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
|
|
10
|
+
document.body.style.paddingRight = `${scrollbarWidth}px`
|
|
11
|
+
document.body.style.overflow = 'hidden'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const unlock = () => {
|
|
15
|
+
document.body.style.paddingRight = ''
|
|
16
|
+
document.body.style.overflow = ''
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
watch(isLocked, (val) => {
|
|
20
|
+
if (val) lock()
|
|
21
|
+
else unlock()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
onUnmounted(() => unlock())
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { inject, type ComputedRef } from 'vue'
|
|
2
|
+
import { spireConfigKey, type SpireConfig } from '../components/SpireProvider/SpireProvider.vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Access the current Spire UI configuration from a parent SpireProvider.
|
|
6
|
+
*
|
|
7
|
+
* @returns The current theme configuration, or undefined if no provider exists
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```vue
|
|
11
|
+
* <script setup>
|
|
12
|
+
* import { useSpireConfig } from 'spire-ui'
|
|
13
|
+
*
|
|
14
|
+
* const config = useSpireConfig()
|
|
15
|
+
* // config?.theme, config?.mood, config?.depth, config?.motion
|
|
16
|
+
* </script>
|
|
17
|
+
*
|
|
18
|
+
* <template>
|
|
19
|
+
* <div v-if="config">
|
|
20
|
+
* Current mood: {{ config.mood }}
|
|
21
|
+
* </div>
|
|
22
|
+
* </template>
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export function useSpireConfig(): ComputedRef<SpireConfig> | undefined {
|
|
26
|
+
return inject(spireConfigKey, undefined)
|
|
27
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { ref, watch, nextTick, onUnmounted, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UseStaggerOptions {
|
|
4
|
+
delay?: number
|
|
5
|
+
duration?: number
|
|
6
|
+
easing?: string
|
|
7
|
+
from?: {
|
|
8
|
+
opacity?: number
|
|
9
|
+
transform?: string
|
|
10
|
+
}
|
|
11
|
+
to?: {
|
|
12
|
+
opacity?: number
|
|
13
|
+
transform?: string
|
|
14
|
+
}
|
|
15
|
+
animateOnVisible?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UseStaggerReturn {
|
|
19
|
+
isAnimating: Ref<boolean>
|
|
20
|
+
animate: () => Promise<void>
|
|
21
|
+
reset: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Creates sequential stagger animations for list items.
|
|
26
|
+
* Handles deferred elements (tabs, v-if) by watching for container availability.
|
|
27
|
+
*
|
|
28
|
+
* @param container - Ref to the container element
|
|
29
|
+
* @param itemSelector - CSS selector for items to animate
|
|
30
|
+
* @param options - Animation configuration
|
|
31
|
+
* @returns Controls for the stagger animation
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```vue
|
|
35
|
+
* <script setup>
|
|
36
|
+
* const listRef = ref<HTMLElement | null>(null)
|
|
37
|
+
* const { animate } = useStagger(listRef, '.list-item', {
|
|
38
|
+
* delay: 80,
|
|
39
|
+
* from: { opacity: 0, transform: 'translateY(12px)' },
|
|
40
|
+
* to: { opacity: 1, transform: 'translateY(0)' }
|
|
41
|
+
* })
|
|
42
|
+
*
|
|
43
|
+
* // Trigger manually when ready
|
|
44
|
+
* function onTabVisible() {
|
|
45
|
+
* animate()
|
|
46
|
+
* }
|
|
47
|
+
* </script>
|
|
48
|
+
*
|
|
49
|
+
* <template>
|
|
50
|
+
* <ul ref="listRef">
|
|
51
|
+
* <li class="list-item" v-for="item in items" :key="item.id">{{ item.name }}</li>
|
|
52
|
+
* </ul>
|
|
53
|
+
* </template>
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function useStagger(
|
|
57
|
+
container: Ref<HTMLElement | null>,
|
|
58
|
+
itemSelector: string,
|
|
59
|
+
options: UseStaggerOptions = {}
|
|
60
|
+
): UseStaggerReturn {
|
|
61
|
+
const {
|
|
62
|
+
delay = 80,
|
|
63
|
+
duration = 400,
|
|
64
|
+
easing = 'cubic-bezier(0.16, 1, 0.3, 1)',
|
|
65
|
+
from = { opacity: 0, transform: 'translateY(16px)' },
|
|
66
|
+
to = { opacity: 1, transform: 'translateY(0)' },
|
|
67
|
+
animateOnVisible = false
|
|
68
|
+
} = options
|
|
69
|
+
|
|
70
|
+
const isAnimating = ref(false)
|
|
71
|
+
let hasAnimatedOnce = false
|
|
72
|
+
let animationTimeout: ReturnType<typeof setTimeout> | null = null
|
|
73
|
+
|
|
74
|
+
function getItems(): HTMLElement[] {
|
|
75
|
+
if (!container.value) return []
|
|
76
|
+
return Array.from(container.value.querySelectorAll<HTMLElement>(itemSelector))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function forceReflow(element: HTMLElement): void {
|
|
80
|
+
void element.offsetHeight
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function applyInitialStyles(items: HTMLElement[]): void {
|
|
84
|
+
items.forEach((item) => {
|
|
85
|
+
item.style.transition = 'none'
|
|
86
|
+
if (from.opacity !== undefined) {
|
|
87
|
+
item.style.opacity = String(from.opacity)
|
|
88
|
+
}
|
|
89
|
+
if (from.transform) {
|
|
90
|
+
item.style.transform = from.transform
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function animate(): Promise<void> {
|
|
96
|
+
await nextTick()
|
|
97
|
+
|
|
98
|
+
const items = getItems()
|
|
99
|
+
if (items.length === 0) return
|
|
100
|
+
|
|
101
|
+
if (isAnimating.value) return
|
|
102
|
+
isAnimating.value = true
|
|
103
|
+
|
|
104
|
+
applyInitialStyles(items)
|
|
105
|
+
|
|
106
|
+
if (items[0]) {
|
|
107
|
+
forceReflow(items[0])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
await new Promise(resolve => requestAnimationFrame(resolve))
|
|
111
|
+
|
|
112
|
+
items.forEach((item, index) => {
|
|
113
|
+
const itemDelay = index * delay
|
|
114
|
+
|
|
115
|
+
item.style.transition = `opacity ${duration}ms ${easing} ${itemDelay}ms, transform ${duration}ms ${easing} ${itemDelay}ms`
|
|
116
|
+
|
|
117
|
+
if (to.opacity !== undefined) {
|
|
118
|
+
item.style.opacity = String(to.opacity)
|
|
119
|
+
}
|
|
120
|
+
if (to.transform) {
|
|
121
|
+
item.style.transform = to.transform
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const totalDuration = (items.length - 1) * delay + duration
|
|
126
|
+
|
|
127
|
+
await new Promise<void>(resolve => {
|
|
128
|
+
if (animationTimeout) {
|
|
129
|
+
clearTimeout(animationTimeout)
|
|
130
|
+
}
|
|
131
|
+
animationTimeout = setTimeout(() => {
|
|
132
|
+
animationTimeout = null
|
|
133
|
+
isAnimating.value = false
|
|
134
|
+
items.forEach((item) => {
|
|
135
|
+
item.style.transition = ''
|
|
136
|
+
})
|
|
137
|
+
resolve()
|
|
138
|
+
}, totalDuration + 50)
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function reset(): void {
|
|
143
|
+
const items = getItems()
|
|
144
|
+
items.forEach((item) => {
|
|
145
|
+
item.style.opacity = ''
|
|
146
|
+
item.style.transform = ''
|
|
147
|
+
item.style.transition = ''
|
|
148
|
+
})
|
|
149
|
+
isAnimating.value = false
|
|
150
|
+
hasAnimatedOnce = false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (animateOnVisible) {
|
|
154
|
+
watch(
|
|
155
|
+
container,
|
|
156
|
+
async (newContainer) => {
|
|
157
|
+
if (newContainer && !hasAnimatedOnce) {
|
|
158
|
+
await nextTick()
|
|
159
|
+
const items = getItems()
|
|
160
|
+
if (items.length > 0) {
|
|
161
|
+
hasAnimatedOnce = true
|
|
162
|
+
animate()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
{ immediate: true }
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
onUnmounted(() => {
|
|
171
|
+
if (animationTimeout) {
|
|
172
|
+
clearTimeout(animationTimeout)
|
|
173
|
+
animationTimeout = null
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
isAnimating,
|
|
179
|
+
animate,
|
|
180
|
+
reset
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Convenience function to apply stagger animation styles directly via CSS.
|
|
186
|
+
* Returns a style object that can be applied to list items.
|
|
187
|
+
*
|
|
188
|
+
* @param index - Item index in the list
|
|
189
|
+
* @param options - Animation configuration
|
|
190
|
+
* @returns Style object with animation delay
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```vue
|
|
194
|
+
* <template>
|
|
195
|
+
* <ul>
|
|
196
|
+
* <li
|
|
197
|
+
* v-for="(item, index) in items"
|
|
198
|
+
* :key="item.id"
|
|
199
|
+
* :style="getStaggerStyle(index)"
|
|
200
|
+
* class="stagger-item"
|
|
201
|
+
* >
|
|
202
|
+
* {{ item.name }}
|
|
203
|
+
* </li>
|
|
204
|
+
* </ul>
|
|
205
|
+
* </template>
|
|
206
|
+
*
|
|
207
|
+
* <style>
|
|
208
|
+
* .stagger-item {
|
|
209
|
+
* animation: stagger-in var(--duration-normal) var(--ease-out) both;
|
|
210
|
+
* animation-delay: var(--stagger-delay);
|
|
211
|
+
* }
|
|
212
|
+
* </style>
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
export function getStaggerStyle(
|
|
216
|
+
index: number,
|
|
217
|
+
options: { delay?: number } = {}
|
|
218
|
+
): Record<string, string> {
|
|
219
|
+
const { delay = 80 } = options
|
|
220
|
+
return {
|
|
221
|
+
'--stagger-delay': `${index * delay}ms`,
|
|
222
|
+
'--stagger-index': String(index)
|
|
223
|
+
}
|
|
224
|
+
}
|