v-uixy 1.4.0 → 1.6.0
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 +1 -1
- package/templates/components/Alert/Alert.types.ts +4 -0
- package/templates/components/Alert/AlertItem.component.vue +2 -4
- package/templates/components/Alert/index.ts +6 -1
- package/templates/components/Breadcrumb/Breadcrumb.component.vue +116 -0
- package/templates/components/Breadcrumb/Breadcrumb.styles.ts +40 -0
- package/templates/components/Breadcrumb/Breadcrumb.types.ts +30 -0
- package/templates/components/Breadcrumb/index.ts +17 -0
- package/templates/components/Button/Button.styles.ts +4 -4
- package/templates/components/Calendar/Calendar.component.vue +50 -2
- package/templates/components/Calendar/Calendar.styles.ts +4 -0
- package/templates/components/Calendar/Calendar.types.ts +8 -0
- package/templates/components/Chart/Chart.component.vue +308 -0
- package/templates/components/Chart/Chart.types.ts +18 -0
- package/templates/components/Chart/index.ts +13 -0
- package/templates/components/Checkbox/Checkbox.component.vue +19 -5
- package/templates/components/Checkbox/Checkbox.styles.ts +10 -0
- package/templates/components/Checkbox/Checkbox.types.ts +1 -0
- package/templates/components/DatePicker/DatePicker.component.vue +219 -0
- package/templates/components/DatePicker/DatePicker.styles.ts +74 -0
- package/templates/components/DatePicker/DatePicker.types.ts +18 -0
- package/templates/components/DatePicker/DatePicker.utils.ts +46 -0
- package/templates/components/DatePicker/TimeWheel.vue +145 -0
- package/templates/components/DatePicker/TimeWheelColumn.vue +147 -0
- package/templates/components/DatePicker/index.ts +11 -0
- package/templates/components/DateRangePicker/DateRangePicker.component.vue +304 -0
- package/templates/components/DateRangePicker/DateRangePicker.types.ts +23 -0
- package/templates/components/DateRangePicker/index.ts +11 -0
- package/templates/components/FileUpload/FileUpload.component.vue +306 -0
- package/templates/components/FileUpload/FileUpload.styles.ts +74 -0
- package/templates/components/FileUpload/FileUpload.types.ts +31 -0
- package/templates/components/FileUpload/index.ts +15 -0
- package/templates/components/InputOTP/InputOTP.component.vue +4 -2
- package/templates/components/InputOTP/InputOTP.types.ts +1 -0
- package/templates/components/Popover/Popover.component.vue +1 -1
- package/templates/components/SegmentedControl/SegmentedControl.component.vue +85 -0
- package/templates/components/SegmentedControl/SegmentedControl.styles.ts +55 -0
- package/templates/components/SegmentedControl/SegmentedControl.types.ts +15 -0
- package/templates/components/SegmentedControl/index.ts +11 -0
- package/templates/components/Table/Table.component.vue +507 -0
- package/templates/components/Table/Table.styles.ts +140 -0
- package/templates/components/Table/Table.types.ts +98 -0
- package/templates/components/Table/index.ts +39 -0
- package/templates/components.json +42 -0
- package/templates/composables/usePosition.ts +6 -4
package/package.json
CHANGED
|
@@ -36,13 +36,11 @@
|
|
|
36
36
|
<script setup lang="ts">
|
|
37
37
|
import { motion, useMotionValue, animate } from "motion-v";
|
|
38
38
|
import { UixyButton, UixyIconButton, UixyIcon, UixyCard } from "..";
|
|
39
|
-
import type { UixyAlertItemProps } from "./Alert.types";
|
|
39
|
+
import type { UixyAlertItemProps, UixyAlertItemEmits } from "./Alert.types";
|
|
40
40
|
|
|
41
41
|
const props = defineProps<UixyAlertItemProps>();
|
|
42
42
|
|
|
43
|
-
const emit = defineEmits<
|
|
44
|
-
(event: "close-alert"): void;
|
|
45
|
-
}>();
|
|
43
|
+
const emit = defineEmits<UixyAlertItemEmits>();
|
|
46
44
|
|
|
47
45
|
const width = useMotionValue("100%");
|
|
48
46
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import UixyAlert from "./Alert.component.vue";
|
|
2
2
|
import UixyAlertItem from "./AlertItem.component.vue";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type UixyAlertProps,
|
|
5
|
+
type UixyAlertItemProps,
|
|
6
|
+
type UixyAlertItemEmits,
|
|
7
|
+
} from "./Alert.types";
|
|
4
8
|
|
|
5
9
|
export * from "./composables";
|
|
6
10
|
export {
|
|
@@ -8,4 +12,5 @@ export {
|
|
|
8
12
|
UixyAlertItem,
|
|
9
13
|
type UixyAlertProps,
|
|
10
14
|
type UixyAlertItemProps,
|
|
15
|
+
type UixyAlertItemEmits,
|
|
11
16
|
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav aria-label="Breadcrumb">
|
|
3
|
+
<ol :class="breadcrumbListStyles({ size: props.size })">
|
|
4
|
+
<template v-for="(node, index) in nodes" :key="index">
|
|
5
|
+
<li :class="breadcrumbItemStyles()">
|
|
6
|
+
<button
|
|
7
|
+
v-if="node.kind === 'ellipsis'"
|
|
8
|
+
type="button"
|
|
9
|
+
:class="breadcrumbEllipsisStyles()"
|
|
10
|
+
aria-label="Show hidden breadcrumbs"
|
|
11
|
+
@click="expanded = true"
|
|
12
|
+
>
|
|
13
|
+
<uixy-icon
|
|
14
|
+
name="more-horizontal"
|
|
15
|
+
:class="breadcrumbIconStyles({ size: props.size })"
|
|
16
|
+
/>
|
|
17
|
+
</button>
|
|
18
|
+
|
|
19
|
+
<uixy-link
|
|
20
|
+
v-else-if="node.item?.link && !node.current"
|
|
21
|
+
v-bind="node.item.link"
|
|
22
|
+
:class="breadcrumbLinkStyles()"
|
|
23
|
+
>
|
|
24
|
+
<uixy-icon
|
|
25
|
+
v-if="node.item.icon"
|
|
26
|
+
:name="node.item.icon"
|
|
27
|
+
:class="breadcrumbIconStyles({ size: props.size })"
|
|
28
|
+
/>
|
|
29
|
+
{{ node.item.label }}
|
|
30
|
+
</uixy-link>
|
|
31
|
+
|
|
32
|
+
<span
|
|
33
|
+
v-else
|
|
34
|
+
:class="breadcrumbCurrentStyles()"
|
|
35
|
+
:aria-current="node.current ? 'page' : undefined"
|
|
36
|
+
>
|
|
37
|
+
<uixy-icon
|
|
38
|
+
v-if="node.item?.icon"
|
|
39
|
+
:name="node.item.icon"
|
|
40
|
+
:class="breadcrumbIconStyles({ size: props.size })"
|
|
41
|
+
/>
|
|
42
|
+
{{ node.item?.label }}
|
|
43
|
+
</span>
|
|
44
|
+
</li>
|
|
45
|
+
|
|
46
|
+
<li
|
|
47
|
+
v-if="index < nodes.length - 1"
|
|
48
|
+
:class="breadcrumbSeparatorStyles()"
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
>
|
|
51
|
+
<uixy-icon
|
|
52
|
+
:name="props.separator"
|
|
53
|
+
:class="breadcrumbSeparatorIconStyles({ size: props.size })"
|
|
54
|
+
/>
|
|
55
|
+
</li>
|
|
56
|
+
</template>
|
|
57
|
+
</ol>
|
|
58
|
+
</nav>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<script setup lang="ts">
|
|
62
|
+
import { UixyLink } from "../Link";
|
|
63
|
+
import { UixyIcon } from "../Icon";
|
|
64
|
+
import type {
|
|
65
|
+
BreadcrumbNode,
|
|
66
|
+
ItemNode,
|
|
67
|
+
UixyBreadcrumbItem,
|
|
68
|
+
UixyBreadcrumbProps,
|
|
69
|
+
} from "./Breadcrumb.types";
|
|
70
|
+
import {
|
|
71
|
+
breadcrumbListStyles,
|
|
72
|
+
breadcrumbItemStyles,
|
|
73
|
+
breadcrumbLinkStyles,
|
|
74
|
+
breadcrumbCurrentStyles,
|
|
75
|
+
breadcrumbSeparatorStyles,
|
|
76
|
+
breadcrumbEllipsisStyles,
|
|
77
|
+
breadcrumbIconStyles,
|
|
78
|
+
breadcrumbSeparatorIconStyles,
|
|
79
|
+
} from "./Breadcrumb.styles";
|
|
80
|
+
|
|
81
|
+
const props = withDefaults(defineProps<UixyBreadcrumbProps>(), {
|
|
82
|
+
separator: "chevron-right",
|
|
83
|
+
size: "md",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const expanded = ref(false);
|
|
87
|
+
|
|
88
|
+
const nodes = computed<BreadcrumbNode[]>(() => {
|
|
89
|
+
const items = props.items ?? [];
|
|
90
|
+
const total = items.length;
|
|
91
|
+
|
|
92
|
+
const toItemNode = (item: UixyBreadcrumbItem, index: number): ItemNode => ({
|
|
93
|
+
kind: "item",
|
|
94
|
+
item,
|
|
95
|
+
current: item.current ?? index === total - 1,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const shouldCollapse =
|
|
99
|
+
!expanded.value &&
|
|
100
|
+
props.maxItems &&
|
|
101
|
+
props.maxItems >= 1 &&
|
|
102
|
+
total > props.maxItems;
|
|
103
|
+
|
|
104
|
+
if (!shouldCollapse) return items.map(toItemNode);
|
|
105
|
+
|
|
106
|
+
const tail = Math.min(Math.max(props.maxItems! - 1, 1), total - 1);
|
|
107
|
+
|
|
108
|
+
const head = toItemNode(items[0]!, 0);
|
|
109
|
+
|
|
110
|
+
const tailNodes = items
|
|
111
|
+
.slice(total - tail)
|
|
112
|
+
.map((item, i) => toItemNode(item, total - tail + i));
|
|
113
|
+
|
|
114
|
+
return [head, { kind: "ellipsis" }, ...tailNodes];
|
|
115
|
+
});
|
|
116
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import styles from "~/utils/styles";
|
|
2
|
+
|
|
3
|
+
export const breadcrumbListStyles = styles("flex flex-wrap items-center", {
|
|
4
|
+
size: {
|
|
5
|
+
sm: "gap-1 text-xs",
|
|
6
|
+
md: "gap-1.5 text-sm",
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const breadcrumbItemStyles = styles("inline-flex items-center");
|
|
11
|
+
|
|
12
|
+
export const breadcrumbLinkStyles = styles(
|
|
13
|
+
"inline-flex items-center gap-1.5 rounded-1 text-gray-600 hover:text-black dark:text-gray-500 dark:hover:text-white transition-colors duration-150 ease-in-out outline-offset-2 outline-gray-300",
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const breadcrumbCurrentStyles = styles(
|
|
17
|
+
"inline-flex items-center gap-1.5 font-500 text-gray-900 dark:text-gray-100 select-none",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const breadcrumbSeparatorStyles = styles(
|
|
21
|
+
"inline-flex items-center text-gray-400 dark:text-gray-700 select-none",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const breadcrumbEllipsisStyles = styles(
|
|
25
|
+
"inline-flex items-center justify-center rounded-1 p-0.5 text-gray-500 hover:text-black dark:text-gray-600 dark:hover:text-white transition-colors duration-150 ease-in-out cursor-pointer outline-offset-2 outline-gray-300",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export const breadcrumbIconStyles = styles("shrink-0", {
|
|
29
|
+
size: {
|
|
30
|
+
sm: "h-3.5 w-3.5",
|
|
31
|
+
md: "h-4 w-4",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const breadcrumbSeparatorIconStyles = styles("shrink-0", {
|
|
36
|
+
size: {
|
|
37
|
+
sm: "h-3 w-3",
|
|
38
|
+
md: "h-3.5 w-3.5",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type UixyLinkProps } from "../Link";
|
|
2
|
+
import type { IconName } from "~/types/icons";
|
|
3
|
+
|
|
4
|
+
export interface UixyBreadcrumbItem {
|
|
5
|
+
label: string;
|
|
6
|
+
link?: UixyLinkProps;
|
|
7
|
+
icon?: IconName;
|
|
8
|
+
current?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UixyBreadcrumbProps {
|
|
12
|
+
items: UixyBreadcrumbItem[];
|
|
13
|
+
separator?: IconName;
|
|
14
|
+
size?: "sm" | "md";
|
|
15
|
+
maxItems?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ItemNode {
|
|
19
|
+
kind: "item";
|
|
20
|
+
item: UixyBreadcrumbItem;
|
|
21
|
+
current: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EllipsisNode {
|
|
25
|
+
kind: "ellipsis";
|
|
26
|
+
item?: undefined;
|
|
27
|
+
current?: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BreadcrumbNode = ItemNode | EllipsisNode;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import UixyBreadcrumb from "./Breadcrumb.component.vue";
|
|
2
|
+
import {
|
|
3
|
+
type UixyBreadcrumbProps,
|
|
4
|
+
type UixyBreadcrumbItem,
|
|
5
|
+
type BreadcrumbNode,
|
|
6
|
+
type ItemNode,
|
|
7
|
+
type EllipsisNode,
|
|
8
|
+
} from "./Breadcrumb.types";
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
UixyBreadcrumb,
|
|
12
|
+
type UixyBreadcrumbProps,
|
|
13
|
+
type UixyBreadcrumbItem,
|
|
14
|
+
type BreadcrumbNode,
|
|
15
|
+
type ItemNode,
|
|
16
|
+
type EllipsisNode,
|
|
17
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import styles from "~/utils/styles";
|
|
2
2
|
|
|
3
3
|
export const buttonStyles = styles(
|
|
4
|
-
"relative cursor-pointer
|
|
4
|
+
"relative cursor-pointer font-500 transition-all duration-200 ease-in-out hover:shadow-xs flex items-center justify-center gap-2 disabled:pointer-events-none border border-transparent",
|
|
5
5
|
{
|
|
6
6
|
variant: {
|
|
7
7
|
primary:
|
|
@@ -14,9 +14,9 @@ export const buttonStyles = styles(
|
|
|
14
14
|
"dark:hover:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-800 disabled:text-gray-700",
|
|
15
15
|
},
|
|
16
16
|
size: {
|
|
17
|
-
sm: "
|
|
18
|
-
md: "px-6
|
|
19
|
-
lg: "
|
|
17
|
+
sm: "h-7 px-4 text-button-sm",
|
|
18
|
+
md: "h-8 px-6 text-button-md",
|
|
19
|
+
lg: "h-10 px-8 text-button-lg",
|
|
20
20
|
},
|
|
21
21
|
rounded: {
|
|
22
22
|
true: "rounded-full",
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
:class="
|
|
34
34
|
calendarDayStyles({
|
|
35
35
|
currentMonth: !!day.fromCurrentMonth,
|
|
36
|
-
selected:
|
|
36
|
+
selected: isRange
|
|
37
|
+
? isEndpoint(day.date)
|
|
38
|
+
: model?.toDateString?.() === day.date.toDateString(),
|
|
39
|
+
inRange: isRange && isInRange(day.date),
|
|
37
40
|
disabled:
|
|
38
41
|
!!props.disabled || day.fromPreviousMonth || day.fromNextMonth,
|
|
39
42
|
greyedOut:
|
|
@@ -49,6 +52,8 @@
|
|
|
49
52
|
(!!props.isDateDisabled && props.isDateDisabled(day.date))
|
|
50
53
|
"
|
|
51
54
|
@click="handleDayClick(day.date)"
|
|
55
|
+
@mouseenter="emit('dayHover', day.date)"
|
|
56
|
+
@mouseleave="emit('dayLeave')"
|
|
52
57
|
>
|
|
53
58
|
{{ day.day }}
|
|
54
59
|
</button>
|
|
@@ -60,12 +65,55 @@
|
|
|
60
65
|
import { ref, computed } from "vue";
|
|
61
66
|
import { UixyIconButton } from "../IconButton";
|
|
62
67
|
import { calendarDayStyles, calendarStyles } from "./Calendar.styles";
|
|
63
|
-
import type { UixyCalendarProps } from "./Calendar.types";
|
|
68
|
+
import type { UixyCalendarProps, UixyCalendarEmits } from "./Calendar.types";
|
|
64
69
|
|
|
65
70
|
const props = defineProps<UixyCalendarProps>();
|
|
66
71
|
|
|
72
|
+
const emit = defineEmits<UixyCalendarEmits>();
|
|
73
|
+
|
|
67
74
|
const model = defineModel<Date | null>();
|
|
68
75
|
|
|
76
|
+
const isRange = computed(
|
|
77
|
+
() => props.rangeStart !== undefined || props.rangeEnd !== undefined,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const sameDay = (a?: Date | null, b?: Date | null) =>
|
|
81
|
+
!!a && !!b && a.toDateString() === b.toDateString();
|
|
82
|
+
|
|
83
|
+
const effectiveEnd = computed(
|
|
84
|
+
() => props.rangeEnd ?? props.rangeHover ?? null,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const isEndpoint = (date: Date) =>
|
|
88
|
+
sameDay(date, props.rangeStart) || sameDay(date, effectiveEnd.value);
|
|
89
|
+
|
|
90
|
+
const isInRange = (date: Date) => {
|
|
91
|
+
const start = props.rangeStart;
|
|
92
|
+
const end = effectiveEnd.value;
|
|
93
|
+
|
|
94
|
+
if (!start || !end) return false;
|
|
95
|
+
|
|
96
|
+
const d = new Date(
|
|
97
|
+
date.getFullYear(),
|
|
98
|
+
date.getMonth(),
|
|
99
|
+
date.getDate(),
|
|
100
|
+
).getTime();
|
|
101
|
+
|
|
102
|
+
const a = new Date(
|
|
103
|
+
start.getFullYear(),
|
|
104
|
+
start.getMonth(),
|
|
105
|
+
start.getDate(),
|
|
106
|
+
).getTime();
|
|
107
|
+
|
|
108
|
+
const b = new Date(
|
|
109
|
+
end.getFullYear(),
|
|
110
|
+
end.getMonth(),
|
|
111
|
+
end.getDate(),
|
|
112
|
+
).getTime();
|
|
113
|
+
|
|
114
|
+
return d > Math.min(a, b) && d < Math.max(a, b);
|
|
115
|
+
};
|
|
116
|
+
|
|
69
117
|
const MONTHS = [
|
|
70
118
|
"January",
|
|
71
119
|
"February",
|
|
@@ -15,6 +15,10 @@ export const calendarDayStyles = styles(
|
|
|
15
15
|
true: "bg-black text-white hover:bg-black dark:bg-white dark:!text-black dark:hover:bg-white",
|
|
16
16
|
false: "hover:bg-gray-300 dark:hover:bg-gray-700",
|
|
17
17
|
},
|
|
18
|
+
inRange: {
|
|
19
|
+
true: "bg-gray-300 hover:bg-gray-300 dark:bg-gray-800 dark:hover:bg-gray-800",
|
|
20
|
+
false: "",
|
|
21
|
+
},
|
|
18
22
|
disabled: {
|
|
19
23
|
true: "cursor-default pointer-events-none",
|
|
20
24
|
false: "cursor-pointer",
|
|
@@ -4,4 +4,12 @@ export interface UixyCalendarProps {
|
|
|
4
4
|
startOfWeek?: "Sunday" | "Monday";
|
|
5
5
|
disabled?: boolean;
|
|
6
6
|
isDateDisabled?: (date: Date) => boolean;
|
|
7
|
+
rangeStart?: Date | null;
|
|
8
|
+
rangeEnd?: Date | null;
|
|
9
|
+
rangeHover?: Date | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UixyCalendarEmits {
|
|
13
|
+
(event: "dayHover", date: Date): void;
|
|
14
|
+
(event: "dayLeave"): void;
|
|
7
15
|
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative w-full h-full">
|
|
3
|
+
<svg
|
|
4
|
+
width="100%"
|
|
5
|
+
height="100%"
|
|
6
|
+
:viewBox="`0 0 ${vbWidth} ${vbHeight}`"
|
|
7
|
+
preserveAspectRatio="xMidYMid meet"
|
|
8
|
+
role="img"
|
|
9
|
+
aria-label="Pixel chart"
|
|
10
|
+
class="block select-none pointer-events-none overflow-visible"
|
|
11
|
+
shape-rendering="crispEdges"
|
|
12
|
+
>
|
|
13
|
+
<rect
|
|
14
|
+
v-for="cell in renderCells"
|
|
15
|
+
:key="`base-${cell.key}`"
|
|
16
|
+
:x="cell.x"
|
|
17
|
+
:y="cell.y"
|
|
18
|
+
:width="CELL"
|
|
19
|
+
:height="CELL"
|
|
20
|
+
class="fill-gray-200 dark:fill-gray-900"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<rect
|
|
24
|
+
v-for="cell in renderCells"
|
|
25
|
+
:key="`color-${cell.key}`"
|
|
26
|
+
:x="cell.x"
|
|
27
|
+
:y="cell.y"
|
|
28
|
+
:width="CELL"
|
|
29
|
+
:height="CELL"
|
|
30
|
+
:class="cell.cls"
|
|
31
|
+
:fill="cell.fill"
|
|
32
|
+
:style="{
|
|
33
|
+
opacity: cell.filled ? 1 : 0,
|
|
34
|
+
transition: props.animate
|
|
35
|
+
? `opacity ${REVEAL_DURATION}s ease-out`
|
|
36
|
+
: 'none',
|
|
37
|
+
}"
|
|
38
|
+
/>
|
|
39
|
+
</svg>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
42
|
+
|
|
43
|
+
<script setup lang="ts">
|
|
44
|
+
import { computed, onUnmounted, ref, watch } from "vue";
|
|
45
|
+
import type { UixyChartProps, UixyChartSeries } from "./Chart.types";
|
|
46
|
+
|
|
47
|
+
const CELL = 12;
|
|
48
|
+
const GAP = 2;
|
|
49
|
+
|
|
50
|
+
const COLUMN_STEP = 0.05;
|
|
51
|
+
const ROW_STEP = 0.04;
|
|
52
|
+
const REVEAL_DURATION = 0.18;
|
|
53
|
+
|
|
54
|
+
const TOP_HEADROOM = 0.15;
|
|
55
|
+
const FADE_ROWS = 2;
|
|
56
|
+
|
|
57
|
+
const DEFAULT_CLASSES = [
|
|
58
|
+
"fill-gray-500 dark:fill-gray-600",
|
|
59
|
+
"fill-gray-800 dark:fill-gray-300",
|
|
60
|
+
"fill-gray-900 dark:fill-gray-200",
|
|
61
|
+
"fill-black dark:fill-white",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const props = withDefaults(defineProps<UixyChartProps>(), {
|
|
65
|
+
rows: 8,
|
|
66
|
+
animate: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
type Paint = { fill?: string; cls?: string };
|
|
70
|
+
|
|
71
|
+
type Cell = {
|
|
72
|
+
key: string;
|
|
73
|
+
x: number;
|
|
74
|
+
y: number;
|
|
75
|
+
filled: boolean;
|
|
76
|
+
fill?: string;
|
|
77
|
+
cls?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const parseHex = (hex: string) => {
|
|
81
|
+
let h = hex.replace("#", "").trim();
|
|
82
|
+
if (h.length === 3)
|
|
83
|
+
h = h
|
|
84
|
+
.split("")
|
|
85
|
+
.map((c) => c + c)
|
|
86
|
+
.join("");
|
|
87
|
+
const n = parseInt(h, 16);
|
|
88
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const toHex = (r: number, g: number, b: number) => {
|
|
92
|
+
const channel = (v: number) =>
|
|
93
|
+
Math.max(0, Math.min(255, Math.round(v)))
|
|
94
|
+
.toString(16)
|
|
95
|
+
.padStart(2, "0");
|
|
96
|
+
return `#${channel(r)}${channel(g)}${channel(b)}`;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const mix = (a: string, b: string, t: number) => {
|
|
100
|
+
const from = parseHex(a);
|
|
101
|
+
const to = parseHex(b);
|
|
102
|
+
return toHex(
|
|
103
|
+
from.r + (to.r - from.r) * t,
|
|
104
|
+
from.g + (to.g - from.g) * t,
|
|
105
|
+
from.b + (to.b - from.b) * t,
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const peak = computed(() => props.color ?? "#000000");
|
|
110
|
+
const base = computed(
|
|
111
|
+
() =>
|
|
112
|
+
props.baseColor ??
|
|
113
|
+
(props.color ? mix(props.color, "#000000", 0.4) : "#000000"),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const paintForDepth = (depth: number): Paint => {
|
|
117
|
+
if (!props.color) {
|
|
118
|
+
const index = depth <= 0 ? 0 : depth > FADE_ROWS ? 3 : depth;
|
|
119
|
+
return { cls: DEFAULT_CLASSES[index] };
|
|
120
|
+
}
|
|
121
|
+
if (depth <= 0) return { fill: peak.value };
|
|
122
|
+
if (depth > FADE_ROWS) return { fill: base.value };
|
|
123
|
+
return { fill: mix(peak.value, base.value, depth / (FADE_ROWS + 1)) };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const basePaint = (): Paint =>
|
|
127
|
+
props.color ? { fill: base.value } : { cls: DEFAULT_CLASSES[3] };
|
|
128
|
+
|
|
129
|
+
const rows = computed(() => Math.max(1, Math.floor(props.rows)));
|
|
130
|
+
|
|
131
|
+
const series = computed<UixyChartSeries[]>(() => {
|
|
132
|
+
if (props.series?.length) return props.series;
|
|
133
|
+
if (props.data?.length)
|
|
134
|
+
return [{ label: "data", values: props.data.map((d) => d.value) }];
|
|
135
|
+
return [];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const columnCount = computed(() =>
|
|
139
|
+
Math.max(0, ...series.value.map((s) => s.values.length)),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const columnTotals = computed(() =>
|
|
143
|
+
Array.from({ length: columnCount.value }, (_, c) =>
|
|
144
|
+
series.value.reduce((sum, s) => sum + Math.max(0, s.values[c] ?? 0), 0),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const usableRows = computed(() =>
|
|
149
|
+
Math.max(1, Math.floor(rows.value * (1 - TOP_HEADROOM))),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const scale = computed(
|
|
153
|
+
() => usableRows.value / Math.max(1, Math.max(0, ...columnTotals.value)),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const barHeights = computed(() =>
|
|
157
|
+
columnTotals.value.map((total) =>
|
|
158
|
+
Math.min(usableRows.value, Math.round(total * scale.value)),
|
|
159
|
+
),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const vbWidth = computed(
|
|
163
|
+
() => columnCount.value * CELL + Math.max(0, columnCount.value - 1) * GAP,
|
|
164
|
+
);
|
|
165
|
+
const vbHeight = computed(() => rows.value * CELL + (rows.value - 1) * GAP);
|
|
166
|
+
|
|
167
|
+
type Scheduled = {
|
|
168
|
+
apply: () => void;
|
|
169
|
+
done: boolean;
|
|
170
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let prevHeights: number[] = [];
|
|
174
|
+
let prevPaint: Record<string, Paint> = {};
|
|
175
|
+
let scheduled: Scheduled[] = [];
|
|
176
|
+
|
|
177
|
+
const renderCells = ref<Cell[]>([]);
|
|
178
|
+
|
|
179
|
+
const flushScheduled = () => {
|
|
180
|
+
scheduled.forEach((s) => {
|
|
181
|
+
if (s.timer) clearTimeout(s.timer);
|
|
182
|
+
if (!s.done) s.apply();
|
|
183
|
+
});
|
|
184
|
+
scheduled = [];
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const ensureCells = () => {
|
|
188
|
+
const needed = columnCount.value * rows.value;
|
|
189
|
+
if (renderCells.value.length === needed) return;
|
|
190
|
+
|
|
191
|
+
const cells: Cell[] = [];
|
|
192
|
+
for (let col = 0; col < columnCount.value; col++) {
|
|
193
|
+
const x = col * (CELL + GAP);
|
|
194
|
+
for (let row = 0; row < rows.value; row++) {
|
|
195
|
+
const y = vbHeight.value - (row + 1) * CELL - row * GAP;
|
|
196
|
+
cells.push({
|
|
197
|
+
key: `${col}-${row}`,
|
|
198
|
+
x,
|
|
199
|
+
y,
|
|
200
|
+
filled: false,
|
|
201
|
+
cls: DEFAULT_CLASSES[3],
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
renderCells.value = cells;
|
|
207
|
+
prevHeights = [];
|
|
208
|
+
prevPaint = {};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
type CellTarget = {
|
|
212
|
+
cell: Cell;
|
|
213
|
+
filled: boolean;
|
|
214
|
+
paint: Paint;
|
|
215
|
+
waveStep: number;
|
|
216
|
+
changed: boolean;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const build = () => {
|
|
220
|
+
flushScheduled();
|
|
221
|
+
ensureCells();
|
|
222
|
+
|
|
223
|
+
const heights = barHeights.value;
|
|
224
|
+
const nextPaint: Record<string, Paint> = {};
|
|
225
|
+
|
|
226
|
+
const columns = Array.from({ length: columnCount.value }, (_, col) => {
|
|
227
|
+
const height = heights[col] ?? 0;
|
|
228
|
+
const prevHeight = prevHeights[col] ?? 0;
|
|
229
|
+
const rising = height >= prevHeight;
|
|
230
|
+
let columnChanged = height !== prevHeight;
|
|
231
|
+
|
|
232
|
+
const targets: CellTarget[] = [];
|
|
233
|
+
for (let row = 0; row < rows.value; row++) {
|
|
234
|
+
const cell = renderCells.value[col * rows.value + row]!;
|
|
235
|
+
const filled = row < height;
|
|
236
|
+
const paint = filled
|
|
237
|
+
? paintForDepth(height - 1 - row)
|
|
238
|
+
: (prevPaint[cell.key] ?? basePaint());
|
|
239
|
+
nextPaint[cell.key] = paint;
|
|
240
|
+
|
|
241
|
+
const changed =
|
|
242
|
+
cell.filled !== filled ||
|
|
243
|
+
cell.fill !== paint.fill ||
|
|
244
|
+
cell.cls !== paint.cls;
|
|
245
|
+
if (changed) columnChanged = true;
|
|
246
|
+
|
|
247
|
+
targets.push({
|
|
248
|
+
cell,
|
|
249
|
+
filled,
|
|
250
|
+
paint,
|
|
251
|
+
waveStep: rising ? row : rows.value - 1 - row,
|
|
252
|
+
changed,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { columnChanged, targets };
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
let activeColumn = 0;
|
|
260
|
+
columns.forEach(({ columnChanged, targets }) => {
|
|
261
|
+
const columnDelay = activeColumn * COLUMN_STEP;
|
|
262
|
+
if (columnChanged) activeColumn++;
|
|
263
|
+
|
|
264
|
+
targets.forEach(({ cell, filled, paint, waveStep, changed }) => {
|
|
265
|
+
if (!changed) return;
|
|
266
|
+
|
|
267
|
+
const apply = () => {
|
|
268
|
+
cell.filled = filled;
|
|
269
|
+
cell.fill = paint.fill;
|
|
270
|
+
cell.cls = paint.cls;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const delay = props.animate ? columnDelay + waveStep * ROW_STEP : 0;
|
|
274
|
+
if (delay <= 0) {
|
|
275
|
+
apply();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const record: Scheduled = { apply, done: false };
|
|
280
|
+
record.timer = setTimeout(() => {
|
|
281
|
+
record.done = true;
|
|
282
|
+
apply();
|
|
283
|
+
}, delay * 1000);
|
|
284
|
+
scheduled.push(record);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
prevHeights = heights.slice();
|
|
289
|
+
prevPaint = nextPaint;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
watch(
|
|
293
|
+
[
|
|
294
|
+
barHeights,
|
|
295
|
+
() => props.color,
|
|
296
|
+
() => props.baseColor,
|
|
297
|
+
() => props.animate,
|
|
298
|
+
rows,
|
|
299
|
+
columnCount,
|
|
300
|
+
],
|
|
301
|
+
build,
|
|
302
|
+
{ immediate: true },
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
onUnmounted(() => {
|
|
306
|
+
scheduled.forEach((s) => s.timer && clearTimeout(s.timer));
|
|
307
|
+
});
|
|
308
|
+
</script>
|