@aleph-alpha/ui-library 1.18.0 → 1.19.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/config.d.ts +7 -0
- package/config.js +100 -0
- package/dist/system/index.d.ts +10 -1
- package/dist/system/lib.js +11353 -11270
- package/package.json +3 -3
- package/src/components/UiDropdownMenu/UiDropdownMenu.stories.ts +110 -0
- package/src/components/UiField/UiField.vue +2 -2
- package/src/components/UiField/UiFieldError.vue +1 -1
- package/src/components/core/badge/Badge.vue +25 -2
- package/src/components/core/badge/index.ts +4 -5
- package/src/components/core/button/Button.vue +30 -2
- package/src/components/core/button/index.ts +7 -7
- package/src/directives/index.ts +1 -0
- package/src/directives/vHover.stories.ts +129 -0
- package/src/directives/vHover.ts +70 -0
- package/src/index.ts +4 -0
- package/src/patterns/UiDatePicker/__tests__/UiDatePicker.test.ts +8 -6
- package/src/theme/Overlay.stories.ts +12 -12
- package/tokens.json +4048 -1388
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aleph-alpha/ui-library",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/system/lib.js",
|
|
@@ -71,9 +71,9 @@
|
|
|
71
71
|
"vitest": "^3.0.0",
|
|
72
72
|
"vue-tsc": "^2.2.12",
|
|
73
73
|
"wait-on": "9.0.3",
|
|
74
|
-
"@aleph-alpha/eslint-config-frontend": "0.5.0",
|
|
75
74
|
"@aleph-alpha/prettier-config-frontend": "0.4.0",
|
|
76
|
-
"@aleph-alpha/tsconfig-frontend": "0.5.0"
|
|
75
|
+
"@aleph-alpha/tsconfig-frontend": "0.5.0",
|
|
76
|
+
"@aleph-alpha/eslint-config-frontend": "0.5.0"
|
|
77
77
|
},
|
|
78
78
|
"peerDependencies": {
|
|
79
79
|
"@unocss/preset-wind4": ">=66.0.0",
|
|
@@ -20,6 +20,7 @@ import UiButton from '../UiButton/UiButton.vue';
|
|
|
20
20
|
import UiIconButton from '../UiIconButton/UiIconButton.vue';
|
|
21
21
|
import UiToggle from '../UiToggle/UiToggle.vue';
|
|
22
22
|
import { UiIcon } from '../UiIcon';
|
|
23
|
+
import { UiAvatar, UiAvatarFallback, UiAvatarImage } from '../UiAvatar';
|
|
23
24
|
|
|
24
25
|
const meta: Meta<typeof UiDropdownMenu> = {
|
|
25
26
|
title: 'Components/UiDropdownMenu',
|
|
@@ -1016,3 +1017,112 @@ export const WithSoftToggleTriggerAndText: Story = {
|
|
|
1016
1017
|
},
|
|
1017
1018
|
},
|
|
1018
1019
|
};
|
|
1020
|
+
|
|
1021
|
+
const avatarTriggerTemplateSource = `<script setup lang="ts">
|
|
1022
|
+
import { ref } from 'vue'
|
|
1023
|
+
import {
|
|
1024
|
+
UiDropdownMenu,
|
|
1025
|
+
UiDropdownMenuTrigger,
|
|
1026
|
+
UiDropdownMenuContent,
|
|
1027
|
+
UiDropdownMenuItem,
|
|
1028
|
+
UiDropdownMenuSeparator,
|
|
1029
|
+
UiButton,
|
|
1030
|
+
UiAvatar,
|
|
1031
|
+
UiAvatarImage,
|
|
1032
|
+
UiAvatarFallback,
|
|
1033
|
+
UiIcon,
|
|
1034
|
+
} from '@aleph-alpha/ui-library'
|
|
1035
|
+
|
|
1036
|
+
const open = ref(false)
|
|
1037
|
+
</script>
|
|
1038
|
+
|
|
1039
|
+
<template>
|
|
1040
|
+
<UiDropdownMenu v-model:open="open">
|
|
1041
|
+
<UiDropdownMenuTrigger as-child>
|
|
1042
|
+
<UiButton variant="outline" class="h-auto gap-3 px-3 py-2">
|
|
1043
|
+
<UiAvatar>
|
|
1044
|
+
<UiAvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
|
1045
|
+
<UiAvatarFallback>CN</UiAvatarFallback>
|
|
1046
|
+
</UiAvatar>
|
|
1047
|
+
<div class="flex flex-col items-start overflow-hidden">
|
|
1048
|
+
<span class="truncate text-sm font-medium">Sofia Davis</span>
|
|
1049
|
+
<span class="truncate text-xs text-muted-foreground font-normal">sofia@example.com</span>
|
|
1050
|
+
</div>
|
|
1051
|
+
<UiIcon name="chevrons-up-down" :size="16" class="shrink-0 text-muted-foreground" />
|
|
1052
|
+
</UiButton>
|
|
1053
|
+
</UiDropdownMenuTrigger>
|
|
1054
|
+
<UiDropdownMenuContent class="w-56" align="start">
|
|
1055
|
+
<UiDropdownMenuItem>Profile</UiDropdownMenuItem>
|
|
1056
|
+
<UiDropdownMenuItem>Settings</UiDropdownMenuItem>
|
|
1057
|
+
<UiDropdownMenuItem>Notifications</UiDropdownMenuItem>
|
|
1058
|
+
<UiDropdownMenuSeparator />
|
|
1059
|
+
<UiDropdownMenuItem>Sign Out</UiDropdownMenuItem>
|
|
1060
|
+
</UiDropdownMenuContent>
|
|
1061
|
+
</UiDropdownMenu>
|
|
1062
|
+
</template>`;
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Dropdown menu with a trigger containing an avatar, title, description, and chevron icon.
|
|
1066
|
+
*/
|
|
1067
|
+
export const WithAvatarTrigger: Story = {
|
|
1068
|
+
render: (args) => ({
|
|
1069
|
+
components: {
|
|
1070
|
+
UiDropdownMenu,
|
|
1071
|
+
UiDropdownMenuTrigger,
|
|
1072
|
+
UiDropdownMenuContent,
|
|
1073
|
+
UiDropdownMenuItem,
|
|
1074
|
+
UiDropdownMenuSeparator,
|
|
1075
|
+
UiButton,
|
|
1076
|
+
UiAvatar,
|
|
1077
|
+
UiAvatarFallback,
|
|
1078
|
+
UiAvatarImage,
|
|
1079
|
+
UiIcon,
|
|
1080
|
+
},
|
|
1081
|
+
setup() {
|
|
1082
|
+
const open = ref(args.open);
|
|
1083
|
+
const modal = ref(args.modal);
|
|
1084
|
+
watch(
|
|
1085
|
+
() => args.open,
|
|
1086
|
+
(v) => {
|
|
1087
|
+
open.value = v;
|
|
1088
|
+
},
|
|
1089
|
+
);
|
|
1090
|
+
watch(
|
|
1091
|
+
() => args.modal,
|
|
1092
|
+
(v) => {
|
|
1093
|
+
modal.value = v;
|
|
1094
|
+
},
|
|
1095
|
+
);
|
|
1096
|
+
return { open, modal };
|
|
1097
|
+
},
|
|
1098
|
+
template: `<UiDropdownMenu v-model:open="open" :modal="modal">
|
|
1099
|
+
<UiDropdownMenuTrigger as-child>
|
|
1100
|
+
<UiButton variant="outline" class="h-auto gap-3 px-3 py-2">
|
|
1101
|
+
<UiAvatar>
|
|
1102
|
+
<UiAvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
|
1103
|
+
<UiAvatarFallback>CN</UiAvatarFallback>
|
|
1104
|
+
</UiAvatar>
|
|
1105
|
+
<div class="flex flex-col items-start overflow-hidden">
|
|
1106
|
+
<span class="truncate text-sm font-medium">Sofia Davis</span>
|
|
1107
|
+
<span class="truncate text-xs text-muted-foreground font-normal">sofia@example.com</span>
|
|
1108
|
+
</div>
|
|
1109
|
+
<UiIcon name="chevrons-up-down" :size="16" class="shrink-0 text-muted-foreground" />
|
|
1110
|
+
</UiButton>
|
|
1111
|
+
</UiDropdownMenuTrigger>
|
|
1112
|
+
<UiDropdownMenuContent class="w-56" align="start">
|
|
1113
|
+
<UiDropdownMenuItem>Profile</UiDropdownMenuItem>
|
|
1114
|
+
<UiDropdownMenuItem>Settings</UiDropdownMenuItem>
|
|
1115
|
+
<UiDropdownMenuItem>Notifications</UiDropdownMenuItem>
|
|
1116
|
+
<UiDropdownMenuSeparator />
|
|
1117
|
+
<UiDropdownMenuItem>Sign Out</UiDropdownMenuItem>
|
|
1118
|
+
</UiDropdownMenuContent>
|
|
1119
|
+
</UiDropdownMenu>`,
|
|
1120
|
+
}),
|
|
1121
|
+
parameters: {
|
|
1122
|
+
docs: {
|
|
1123
|
+
source: {
|
|
1124
|
+
code: avatarTriggerTemplateSource,
|
|
1125
|
+
},
|
|
1126
|
+
},
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function partitionSlots(groupNames: string[]) {
|
|
43
|
-
const children = slots.default?.() ?? [];
|
|
43
|
+
const children = slots.default?.({}) ?? [];
|
|
44
44
|
const group = children.filter((v) => groupNames.includes(getSlotName(v) ?? ''));
|
|
45
45
|
const rest = children.filter((v) => !groupNames.includes(getSlotName(v) ?? ''));
|
|
46
46
|
return { group, rest };
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
const labelPartition = computed(() => partitionSlots(['UiFieldLabel', 'UiFieldDescription']));
|
|
50
50
|
|
|
51
51
|
const inputPartition = computed(() => {
|
|
52
|
-
const children = slots.default?.() ?? [];
|
|
52
|
+
const children = slots.default?.({}) ?? [];
|
|
53
53
|
const content = children.filter((v) => getSlotName(v) === 'UiFieldContent');
|
|
54
54
|
const desc = children.filter((v) => getSlotName(v) === 'UiFieldDescription');
|
|
55
55
|
const rest = children.filter((v) => {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const props = defineProps<UiFieldErrorProps>();
|
|
11
11
|
|
|
12
12
|
const slots = useSlots();
|
|
13
|
-
const hasDefaultSlotContent = computed(() => (slots.default?.().length ?? 0) > 0);
|
|
13
|
+
const hasDefaultSlotContent = computed(() => (slots.default?.({}).length ?? 0) > 0);
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
16
|
<template>
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
import type { PrimitiveProps } from 'reka-ui';
|
|
3
3
|
import type { HTMLAttributes } from 'vue';
|
|
4
4
|
import type { BadgeVariants } from '.';
|
|
5
|
+
import type { VHoverVariant } from '@/directives/vHover';
|
|
5
6
|
import { reactiveOmit } from '@vueuse/core';
|
|
6
7
|
import { Primitive } from 'reka-ui';
|
|
8
|
+
import { computed } from 'vue';
|
|
7
9
|
import { cn } from '@/lib/utils';
|
|
8
10
|
import { badgeVariants } from '.';
|
|
11
|
+
import { vHover } from '@/directives/vHover';
|
|
9
12
|
|
|
10
13
|
const props = defineProps<
|
|
11
14
|
PrimitiveProps & {
|
|
@@ -14,13 +17,33 @@
|
|
|
14
17
|
}
|
|
15
18
|
>();
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
type BadgeVariant = NonNullable<BadgeVariants['variant']>;
|
|
21
|
+
|
|
22
|
+
const VARIANT_TO_V_HOVER_OVERLAY = new Map<BadgeVariant, VHoverVariant | false>([
|
|
23
|
+
['default', 'subtle'],
|
|
24
|
+
['secondary', 'default'],
|
|
25
|
+
['destructive', 'default'],
|
|
26
|
+
['outline', 'default'],
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const delegatedProps = reactiveOmit(props, 'class', 'variant');
|
|
30
|
+
|
|
31
|
+
const badgeClass = computed(() => cn(badgeVariants({ variant: props.variant }), props.class));
|
|
32
|
+
|
|
33
|
+
const hoverOverlayVariant = computed(
|
|
34
|
+
() => VARIANT_TO_V_HOVER_OVERLAY.get(props.variant ?? 'default') ?? false,
|
|
35
|
+
);
|
|
18
36
|
</script>
|
|
19
37
|
|
|
20
38
|
<template>
|
|
39
|
+
<Primitive v-if="asChild" data-slot="badge" :class="badgeClass" v-bind="delegatedProps">
|
|
40
|
+
<slot />
|
|
41
|
+
</Primitive>
|
|
21
42
|
<Primitive
|
|
43
|
+
v-else
|
|
44
|
+
v-hover="hoverOverlayVariant"
|
|
22
45
|
data-slot="badge"
|
|
23
|
-
:class="
|
|
46
|
+
:class="badgeClass"
|
|
24
47
|
v-bind="delegatedProps"
|
|
25
48
|
>
|
|
26
49
|
<slot />
|
|
@@ -8,12 +8,11 @@ export const badgeVariants = cva(
|
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
|
-
default: 'border-transparent bg-primary text-primary-foreground
|
|
12
|
-
secondary:
|
|
13
|
-
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
|
11
|
+
default: 'border-transparent bg-primary text-primary-foreground',
|
|
12
|
+
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
|
14
13
|
destructive:
|
|
15
|
-
'border-transparent bg-destructive text-white
|
|
16
|
-
outline: 'text-foreground
|
|
14
|
+
'border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-[#FF8D8F] dark:text-black',
|
|
15
|
+
outline: 'text-foreground',
|
|
17
16
|
},
|
|
18
17
|
},
|
|
19
18
|
defaultVariants: {
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
import type { PrimitiveProps } from 'reka-ui';
|
|
3
3
|
import type { HTMLAttributes } from 'vue';
|
|
4
4
|
import type { ButtonVariants } from '.';
|
|
5
|
+
import type { VHoverVariant } from '@/directives/vHover';
|
|
5
6
|
import { Primitive } from 'reka-ui';
|
|
7
|
+
import { computed } from 'vue';
|
|
6
8
|
import { cn } from '@/lib/utils';
|
|
7
9
|
import { buttonVariants } from '.';
|
|
10
|
+
import { vHover } from '@/directives/vHover';
|
|
8
11
|
|
|
9
12
|
interface Props extends PrimitiveProps {
|
|
10
13
|
variant?: ButtonVariants['variant'];
|
|
@@ -15,14 +18,39 @@
|
|
|
15
18
|
const props = withDefaults(defineProps<Props>(), {
|
|
16
19
|
as: 'button',
|
|
17
20
|
});
|
|
21
|
+
|
|
22
|
+
type ButtonVariant = NonNullable<ButtonVariants['variant']>;
|
|
23
|
+
|
|
24
|
+
/** Background overlay via `v-hover`; `link` uses `overlay-link-hover-subtle` (color) instead. */
|
|
25
|
+
const VARIANT_TO_V_HOVER_OVERLAY = new Map<ButtonVariant, VHoverVariant | false>([
|
|
26
|
+
['default', 'subtle'],
|
|
27
|
+
['link', false],
|
|
28
|
+
['secondary', 'default'],
|
|
29
|
+
['destructive', 'default'],
|
|
30
|
+
['outline', 'default'],
|
|
31
|
+
['ghost', 'default'],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const buttonClass = computed(() =>
|
|
35
|
+
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const hoverOverlayVariant = computed(
|
|
39
|
+
() => VARIANT_TO_V_HOVER_OVERLAY.get(props.variant ?? 'default') ?? false,
|
|
40
|
+
);
|
|
18
41
|
</script>
|
|
19
42
|
|
|
20
43
|
<template>
|
|
44
|
+
<Primitive v-if="asChild" data-slot="button" :as="as" :as-child="true" :class="buttonClass">
|
|
45
|
+
<slot />
|
|
46
|
+
</Primitive>
|
|
21
47
|
<Primitive
|
|
48
|
+
v-else
|
|
49
|
+
v-hover="hoverOverlayVariant"
|
|
22
50
|
data-slot="button"
|
|
23
51
|
:as="as"
|
|
24
|
-
:as-child="
|
|
25
|
-
:class="
|
|
52
|
+
:as-child="false"
|
|
53
|
+
:class="buttonClass"
|
|
26
54
|
>
|
|
27
55
|
<slot />
|
|
28
56
|
</Primitive>
|
|
@@ -8,14 +8,14 @@ export const buttonVariants = cva(
|
|
|
8
8
|
{
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
|
-
default: 'bg-primary text-primary-foreground
|
|
11
|
+
default: 'bg-primary text-primary-foreground',
|
|
12
12
|
destructive:
|
|
13
|
-
'bg-destructive text-destructive-foreground
|
|
14
|
-
outline:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
link: 'text-link
|
|
13
|
+
'bg-destructive text-destructive-foreground focus-visible:border-transparent focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
|
|
14
|
+
outline: 'border border-input bg-transparent text-foreground shadow-xs',
|
|
15
|
+
secondary: 'bg-secondary text-secondary-foreground',
|
|
16
|
+
ghost:
|
|
17
|
+
'text-foreground hover:border-border-button-outlined focus-visible:border-border-button-outlined',
|
|
18
|
+
link: 'text-link overlay-link-hover-subtle',
|
|
19
19
|
},
|
|
20
20
|
size: {
|
|
21
21
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { vHover, type VHoverVariant } from './vHover';
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import { defineComponent } from 'vue';
|
|
3
|
+
import { vHover } from '@/directives/vHover';
|
|
4
|
+
|
|
5
|
+
const VHoverDemo = defineComponent({
|
|
6
|
+
name: 'VHoverDemo',
|
|
7
|
+
directives: { hover: vHover },
|
|
8
|
+
methods: {
|
|
9
|
+
copyVHover(variant: 'subtle' | 'default' | 'strong', e: MouseEvent) {
|
|
10
|
+
const el = e.currentTarget as HTMLElement;
|
|
11
|
+
const snippet = `v-hover="'${variant}'"`;
|
|
12
|
+
navigator.clipboard
|
|
13
|
+
.writeText(snippet)
|
|
14
|
+
.then(() => {
|
|
15
|
+
el.classList.add('copied');
|
|
16
|
+
setTimeout(() => el.classList.remove('copied'), 1500);
|
|
17
|
+
})
|
|
18
|
+
.catch(() => {
|
|
19
|
+
/* clipboard unavailable (e.g. some test / non-secure contexts) */
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
template: `
|
|
24
|
+
<div class="p-8 bg-white dark:bg-background min-h-screen">
|
|
25
|
+
<style>
|
|
26
|
+
.copy-btn { cursor: pointer; transition: all 0.15s; }
|
|
27
|
+
.copy-btn:hover { background: rgba(0,0,0,0.05); }
|
|
28
|
+
.copy-btn.copied { background: #22c55e22; }
|
|
29
|
+
.copy-btn.copied::after { content: ' ✓'; color: #22c55e; }
|
|
30
|
+
</style>
|
|
31
|
+
|
|
32
|
+
<div class="mb-6">
|
|
33
|
+
<h2 class="text-xl font-bold text-content-on-surface-primary mb-1">v-hover</h2>
|
|
34
|
+
<p class="text-sm text-content-on-surface-muted max-w-2xl">
|
|
35
|
+
Registers as <code class="text-xs">v-hover</code> when using
|
|
36
|
+
<code class="text-xs">app.directive('hover', vHover)</code> from the library install.
|
|
37
|
+
Injects a single overlay layer; visible on <code class="text-xs">:hover</code> when
|
|
38
|
+
<code class="text-xs">@media (hover: hover)</code> and on <code class="text-xs">:focus-visible</code>.
|
|
39
|
+
Variants map to
|
|
40
|
+
<code class="text-xs">--overlay-hover-subtle</code>,
|
|
41
|
+
<code class="text-xs">--overlay-hover-default</code>, and
|
|
42
|
+
<code class="text-xs">--overlay-hover-strong</code>.
|
|
43
|
+
Hover or Tab+focus each tile to compare intensity.
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="flex flex-wrap gap-8">
|
|
48
|
+
<div class="flex flex-col items-center gap-2">
|
|
49
|
+
<button
|
|
50
|
+
type="button"
|
|
51
|
+
v-hover="'subtle'"
|
|
52
|
+
class="h-24 w-40 rounded-md bg-background-button-primary-button text-content-on-button-primary-button text-sm font-medium flex items-center justify-center border border-border-surface-default shadow-xs"
|
|
53
|
+
>
|
|
54
|
+
subtle
|
|
55
|
+
</button>
|
|
56
|
+
<div class="text-center">
|
|
57
|
+
<span class="text-xs text-content-on-surface-primary font-medium block">subtle</span>
|
|
58
|
+
<code
|
|
59
|
+
class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded"
|
|
60
|
+
@click="copyVHover('subtle', $event)"
|
|
61
|
+
>v-hover="'subtle'"</code>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div class="flex flex-col items-center gap-2">
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
v-hover="'default'"
|
|
69
|
+
class="h-24 w-40 rounded-md bg-background-button-primary-button text-content-on-button-primary-button text-sm font-medium flex items-center justify-center border border-border-surface-default shadow-xs"
|
|
70
|
+
>
|
|
71
|
+
default
|
|
72
|
+
</button>
|
|
73
|
+
<div class="text-center">
|
|
74
|
+
<span class="text-xs text-content-on-surface-primary font-medium block">default</span>
|
|
75
|
+
<code
|
|
76
|
+
class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded"
|
|
77
|
+
@click="copyVHover('default', $event)"
|
|
78
|
+
>v-hover="'default'"</code>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div class="flex flex-col items-center gap-2">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
v-hover="'strong'"
|
|
86
|
+
class="h-24 w-40 rounded-md bg-background-button-primary-button text-content-on-button-primary-button text-sm font-medium flex items-center justify-center border border-border-surface-default shadow-xs"
|
|
87
|
+
>
|
|
88
|
+
strong
|
|
89
|
+
</button>
|
|
90
|
+
<div class="text-center">
|
|
91
|
+
<span class="text-xs text-content-on-surface-primary font-medium block">strong</span>
|
|
92
|
+
<code
|
|
93
|
+
class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded"
|
|
94
|
+
@click="copyVHover('strong', $event)"
|
|
95
|
+
>v-hover="'strong'"</code>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
`,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const meta: Meta<typeof VHoverDemo> = {
|
|
104
|
+
title: 'Directives/v-hover',
|
|
105
|
+
component: VHoverDemo,
|
|
106
|
+
tags: ['autodocs'],
|
|
107
|
+
parameters: {
|
|
108
|
+
layout: 'fullscreen',
|
|
109
|
+
docs: {
|
|
110
|
+
description: {
|
|
111
|
+
component:
|
|
112
|
+
'Overlay hover directive: `false` disables; string `subtle` | `default` | `strong` or `{ variant }`. Used by Button and Badge.',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export default meta;
|
|
119
|
+
type Story = StoryObj<typeof VHoverDemo>;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Three overlay intensities on the same button fill so you can compare subtle, default, and strong.
|
|
123
|
+
*/
|
|
124
|
+
export const Variants: Story = {
|
|
125
|
+
render: () => ({
|
|
126
|
+
components: { VHoverDemo },
|
|
127
|
+
template: '<VHoverDemo />',
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DirectiveBinding, ObjectDirective } from 'vue';
|
|
2
|
+
|
|
3
|
+
export type VHoverVariant = 'subtle' | 'default' | 'strong';
|
|
4
|
+
|
|
5
|
+
const OVERLAY_EL = Symbol('vHoverOverlayEl');
|
|
6
|
+
|
|
7
|
+
type VHoverBinding = VHoverVariant | false | null | undefined | { variant: VHoverVariant };
|
|
8
|
+
|
|
9
|
+
function resolveVariant(binding: DirectiveBinding<VHoverBinding>): VHoverVariant | null {
|
|
10
|
+
const v = binding.value;
|
|
11
|
+
if (v === false || v === null || v === undefined) return null;
|
|
12
|
+
if (typeof v === 'object' && v !== null && 'variant' in v) {
|
|
13
|
+
const { variant } = v;
|
|
14
|
+
if (variant === 'subtle' || variant === 'default' || variant === 'strong') return variant;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
if (v === 'subtle' || v === 'default' || v === 'strong') return v;
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function detach(el: HTMLElement): void {
|
|
22
|
+
const overlay = (el as unknown as Record<symbol, HTMLElement>)[OVERLAY_EL];
|
|
23
|
+
overlay?.remove();
|
|
24
|
+
delete (el as unknown as Record<symbol, unknown>)[OVERLAY_EL];
|
|
25
|
+
el.classList.remove('v-hover-root');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function attach(el: HTMLElement, variant: VHoverVariant): void {
|
|
29
|
+
detach(el);
|
|
30
|
+
|
|
31
|
+
el.classList.add('v-hover-root');
|
|
32
|
+
|
|
33
|
+
const overlay = document.createElement('span');
|
|
34
|
+
overlay.className = 'v-hover-overlay';
|
|
35
|
+
overlay.setAttribute('data-v-hover-variant', variant);
|
|
36
|
+
overlay.setAttribute('aria-hidden', 'true');
|
|
37
|
+
el.prepend(overlay);
|
|
38
|
+
(el as unknown as Record<symbol, HTMLElement>)[OVERLAY_EL] = overlay;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setVariant(el: HTMLElement, variant: VHoverVariant): void {
|
|
42
|
+
const overlay = (el as unknown as Record<symbol, HTMLElement>)[OVERLAY_EL];
|
|
43
|
+
if (overlay?.isConnected) {
|
|
44
|
+
overlay.setAttribute('data-v-hover-variant', variant);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const vHover: ObjectDirective<HTMLElement, VHoverBinding> = {
|
|
49
|
+
mounted(el, binding) {
|
|
50
|
+
const variant = resolveVariant(binding);
|
|
51
|
+
if (variant) attach(el, variant);
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
updated(el, binding) {
|
|
55
|
+
const variant = resolveVariant(binding);
|
|
56
|
+
if (!variant) {
|
|
57
|
+
detach(el);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if ((el as unknown as Record<symbol, HTMLElement>)[OVERLAY_EL]?.isConnected) {
|
|
61
|
+
setVariant(el, variant);
|
|
62
|
+
} else {
|
|
63
|
+
attach(el, variant);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
unmounted(el) {
|
|
68
|
+
detach(el);
|
|
69
|
+
},
|
|
70
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
export * from './components';
|
|
6
6
|
export * from './patterns';
|
|
7
7
|
export * from './templates';
|
|
8
|
+
export * from './directives';
|
|
9
|
+
|
|
10
|
+
import { vHover } from './directives/vHover';
|
|
8
11
|
|
|
9
12
|
const components = import.meta.glob(
|
|
10
13
|
['@/components/**/*.vue', '@/patterns/**/*.vue', '@/templates/**/*.vue'],
|
|
@@ -15,6 +18,7 @@ const components = import.meta.glob(
|
|
|
15
18
|
|
|
16
19
|
export default {
|
|
17
20
|
install(app, options) {
|
|
21
|
+
app.directive('hover', vHover);
|
|
18
22
|
Object.values(components).forEach((component: any) => {
|
|
19
23
|
app.component(component.default.__name, component.default);
|
|
20
24
|
});
|
|
@@ -245,7 +245,7 @@ describe('UiDatePicker', () => {
|
|
|
245
245
|
|
|
246
246
|
describe('lazy loading optimization', () => {
|
|
247
247
|
test('lazily loads UiCalendar in single mode when popover opens', async () => {
|
|
248
|
-
const { getByRole, queryByRole, findByRole } = render(UiDatePicker, {
|
|
248
|
+
const { getByRole, queryByRole, findByRole, findAllByRole } = render(UiDatePicker, {
|
|
249
249
|
props: { mode: 'single', modelValue: new CalendarDate(2025, 1, 15) },
|
|
250
250
|
});
|
|
251
251
|
|
|
@@ -255,13 +255,14 @@ describe('UiDatePicker', () => {
|
|
|
255
255
|
// Open the popover
|
|
256
256
|
await userEvent.click(getByRole('button'));
|
|
257
257
|
|
|
258
|
-
// Calendar should be rendered after async load (
|
|
258
|
+
// Calendar should be rendered after async load (find* waits for element)
|
|
259
259
|
await findByRole('dialog');
|
|
260
|
-
await
|
|
260
|
+
const cells = await findAllByRole('gridcell');
|
|
261
|
+
expect(cells.length).toBeGreaterThan(0);
|
|
261
262
|
});
|
|
262
263
|
|
|
263
264
|
test('lazily loads UiRangeCalendar in range mode when popover opens', async () => {
|
|
264
|
-
const { getByRole, queryByRole, findByRole } = render(UiDatePicker, {
|
|
265
|
+
const { getByRole, queryByRole, findByRole, findAllByRole } = render(UiDatePicker, {
|
|
265
266
|
props: { mode: 'range' },
|
|
266
267
|
});
|
|
267
268
|
|
|
@@ -271,9 +272,10 @@ describe('UiDatePicker', () => {
|
|
|
271
272
|
// Open the popover
|
|
272
273
|
await userEvent.click(getByRole('button'));
|
|
273
274
|
|
|
274
|
-
// RangeCalendar should be rendered after async load (
|
|
275
|
+
// RangeCalendar should be rendered after async load (find* waits for element)
|
|
275
276
|
await findByRole('dialog');
|
|
276
|
-
await
|
|
277
|
+
const cells = await findAllByRole('gridcell');
|
|
278
|
+
expect(cells.length).toBeGreaterThan(0);
|
|
277
279
|
});
|
|
278
280
|
|
|
279
281
|
test('single mode calendar is fully functional after lazy load', async () => {
|
|
@@ -73,8 +73,8 @@ export const Surface: Story = {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Hover overlay
|
|
77
|
-
*
|
|
76
|
+
* Hover overlay tokens used by `v-hover` (`subtle` / `default` / `strong`).
|
|
77
|
+
* Same three tiers as interactive overlay layers; use these utilities when you need a solid swatch.
|
|
78
78
|
*/
|
|
79
79
|
export const Hover: Story = {
|
|
80
80
|
render: () => ({
|
|
@@ -90,31 +90,31 @@ export const Hover: Story = {
|
|
|
90
90
|
|
|
91
91
|
<div class="mb-6">
|
|
92
92
|
<h2 class="text-xl font-bold text-content-on-surface-primary mb-1">hover</h2>
|
|
93
|
-
<p class="text-sm text-content-on-surface-muted">Overlay
|
|
93
|
+
<p class="text-sm text-content-on-surface-muted">Overlay hover tiers (<code class="text-xs">subtle</code>, <code class="text-xs">default</code>, <code class="text-xs">strong</code>). Matches <code class="text-xs">v-hover</code>. Click class name to copy.</p>
|
|
94
94
|
</div>
|
|
95
95
|
|
|
96
96
|
<div class="flex flex-wrap gap-6">
|
|
97
97
|
<div class="flex flex-col items-center gap-1.5">
|
|
98
|
-
<div class="w-16 h-16 rounded-md bg-overlay-hover-
|
|
98
|
+
<div class="w-16 h-16 rounded-md bg-overlay-hover-subtle border border-border-surface-default"></div>
|
|
99
99
|
<div class="text-center">
|
|
100
|
-
<span class="text-xs text-content-on-surface-primary block font-medium">
|
|
101
|
-
<code class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded" onclick="copyToClipboard('bg-overlay-hover-
|
|
100
|
+
<span class="text-xs text-content-on-surface-primary block font-medium">subtle</span>
|
|
101
|
+
<code class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded" onclick="copyToClipboard('bg-overlay-hover-subtle', this)">bg-overlay-hover-subtle</code>
|
|
102
102
|
</div>
|
|
103
103
|
</div>
|
|
104
104
|
|
|
105
105
|
<div class="flex flex-col items-center gap-1.5">
|
|
106
|
-
<div class="w-16 h-16 rounded-md bg-overlay-hover-
|
|
106
|
+
<div class="w-16 h-16 rounded-md bg-overlay-hover-default border border-border-surface-default"></div>
|
|
107
107
|
<div class="text-center">
|
|
108
|
-
<span class="text-xs text-content-on-surface-primary block font-medium">
|
|
109
|
-
<code class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded" onclick="copyToClipboard('bg-overlay-hover-
|
|
108
|
+
<span class="text-xs text-content-on-surface-primary block font-medium">default</span>
|
|
109
|
+
<code class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded" onclick="copyToClipboard('bg-overlay-hover-default', this)">bg-overlay-hover-default</code>
|
|
110
110
|
</div>
|
|
111
111
|
</div>
|
|
112
112
|
|
|
113
113
|
<div class="flex flex-col items-center gap-1.5">
|
|
114
|
-
<div class="w-16 h-16 rounded-md bg-overlay-hover-
|
|
114
|
+
<div class="w-16 h-16 rounded-md bg-overlay-hover-strong border border-border-surface-default"></div>
|
|
115
115
|
<div class="text-center">
|
|
116
|
-
<span class="text-xs text-content-on-surface-primary block font-medium">
|
|
117
|
-
<code class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded" onclick="copyToClipboard('bg-overlay-hover-
|
|
116
|
+
<span class="text-xs text-content-on-surface-primary block font-medium">strong</span>
|
|
117
|
+
<code class="copy-btn text-[10px] text-content-on-surface-muted block mt-0.5 px-1 py-0.5 rounded" onclick="copyToClipboard('bg-overlay-hover-strong', this)">bg-overlay-hover-strong</code>
|
|
118
118
|
</div>
|
|
119
119
|
</div>
|
|
120
120
|
</div>
|