srcdev-nuxt-components 9.6.18 β 9.6.20
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/.claude/rules/consumer/css-relative-colour-calc.md +15 -0
- package/.claude/rules/consumer/ids-from-useid.md +3 -2
- package/.claude/rules/consumer/vue-dynamic-arg-no-spaces.md +16 -0
- package/.claude/skills/component-compliance-checklist.md +11 -1
- package/.claude/skills/component-dynamic-slots.md +2 -2
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/accordion-chat.md +99 -0
- package/.claude/skills/components/accordion-group.md +98 -0
- package/.claude/skills/components/action-menu.md +11 -5
- package/.claude/skills/components/auto-grid.md +3 -1
- package/.claude/skills/components/capture-qr-code.md +9 -5
- package/.claude/skills/components/carousel-flip.md +41 -30
- package/.claude/skills/components/column-flow-grid.md +2 -0
- package/.claude/skills/components/dashboard-quad-grid.md +7 -2
- package/.claude/skills/components/dashboard-stats-grid.md +4 -0
- package/.claude/skills/components/decode-qr-code.md +7 -2
- package/.claude/skills/components/deep-expanding-menu-classic.md +31 -30
- package/.claude/skills/components/deep-expanding-menu.md +22 -21
- package/.claude/skills/components/expanding-panel-classic.md +9 -7
- package/.claude/skills/components/expanding-panel.md +9 -8
- package/.claude/skills/components/grid-stack.md +2 -0
- package/.claude/skills/components/layout-grid-by-cols.md +2 -0
- package/.claude/skills/components/masonry-grid.md +9 -1
- package/.claude/skills/components/page-row.md +3 -0
- package/.claude/skills/components/samaritan-prompt.md +24 -8
- package/.claude/skills/components/site-header.md +16 -2
- package/.claude/skills/components/sparkle-effect.md +79 -0
- package/.claude/skills/index.md +16 -14
- package/.claude/skills/storybook-add-story.md +33 -0
- package/.claude/skills/testing-add-unit-test.md +26 -0
- package/.claude/skills/using-component-skills.md +1 -1
- package/.vscode/srcdev-component-accordion-chat.code-snippets +42 -0
- package/.vscode/srcdev-component-accordion-group.code-snippets +38 -0
- package/.vscode/srcdev-component-action-menu.code-snippets +3 -3
- package/.vscode/srcdev-component-capture-decode-qr-code.code-snippets +11 -2
- package/.vscode/srcdev-component-carousel-flip.code-snippets +22 -4
- package/.vscode/srcdev-component-deep-expanding-menu-classic.code-snippets +32 -2
- package/.vscode/srcdev-component-deep-expanding-menu.code-snippets +31 -1
- package/.vscode/srcdev-component-samaritan-prompt.code-snippets +5 -4
- package/.vscode/srcdev-component-site-header.code-snippets +13 -1
- package/.vscode/srcdev-component-sparkle-effect.code-snippets +33 -0
- package/app/components/01.atoms/animations/sparkle-effect/CONSUMER-STYLING.md +75 -0
- package/app/components/01.atoms/animations/sparkle-effect/SparkleEffect.vue +213 -0
- package/app/components/01.atoms/animations/sparkle-effect/stories/SparkleEffect.stories.ts +303 -0
- package/app/components/01.atoms/animations/sparkle-effect/tests/SparkleEffect.spec.ts +150 -0
- package/app/components/01.atoms/content-wrappers/docs-pages/ContentDocs.vue +1 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +12 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue +19 -17
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts +42 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts +31 -44
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +13 -1
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +40 -31
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts +47 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts +51 -53
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/LayoutGridByCols.vue +4 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/stories/LayoutGridByCols.stories.ts +47 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/layout-grid-by-cols/tests/LayoutGridByCols.spec.ts +36 -23
- package/app/components/01.atoms/grids/auto-grid/AutoGrid.vue +7 -3
- package/app/components/01.atoms/grids/auto-grid/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/grids/auto-grid/stories/AutoGrid.stories.ts +46 -1
- package/app/components/01.atoms/grids/auto-grid/tests/AutoGrid.spec.ts +40 -27
- package/app/components/01.atoms/grids/auto-grid/tests/__snapshots__/AutoGrid.spec.ts.snap +1 -1
- package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +9 -0
- package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +3 -2
- package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +46 -0
- package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +24 -11
- package/app/components/01.atoms/grids/grid-stack/CONSUMER-STYLING.md +3 -0
- package/app/components/01.atoms/grids/grid-stack/GridStack.vue +2 -0
- package/app/components/01.atoms/grids/grid-stack/stories/GridStack.stories.ts +41 -0
- package/app/components/01.atoms/grids/grid-stack/tests/GridStack.spec.ts +26 -13
- package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +15 -0
- package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +51 -31
- package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +48 -0
- package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +84 -29
- package/app/components/01.atoms/page-row/CONSUMER-STYLING.md +6 -1
- package/app/components/01.atoms/page-row/PageRow.vue +16 -15
- package/app/components/01.atoms/page-row/stories/PageRow.stories.ts +23 -0
- package/app/components/01.atoms/page-row/tests/PageRow.spec.ts +6 -5
- package/app/components/02.molecules/action-menu/ActionMenu.vue +6 -2
- package/app/components/02.molecules/action-menu/ActionMenuItem.vue +6 -3
- package/app/components/02.molecules/action-menu/CONSUMER-STYLING.md +11 -4
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +130 -18
- package/app/components/02.molecules/expandable/accordion-chat/AccordionChat.vue +227 -0
- package/app/components/02.molecules/expandable/accordion-chat/CONSUMER-STYLING.md +150 -0
- package/app/components/02.molecules/expandable/accordion-chat/stories/AccordionChat.stories.ts +347 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/AccordionChat.spec.ts +205 -0
- package/app/components/02.molecules/expandable/accordion-chat/tests/__snapshots__/AccordionChat.spec.ts.snap +40 -0
- package/app/components/02.molecules/expandable/{accordian/AccordianCore.vue β accordion-group/AccordionGroup.vue} +32 -20
- package/app/components/02.molecules/expandable/accordion-group/CONSUMER-STYLING.md +66 -0
- package/app/components/02.molecules/expandable/accordion-group/stories/AccordionGroup.stories.ts +238 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/AccordionGroup.spec.ts +172 -0
- package/app/components/02.molecules/expandable/accordion-group/tests/__snapshots__/AccordionGroup.spec.ts.snap +45 -0
- package/app/components/02.molecules/expandable/expanding-panel/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel/ExpandingPanel.vue +11 -6
- package/app/components/02.molecules/expandable/expanding-panel/stories/ExpandingPanel.stories.ts +82 -1
- package/app/components/02.molecules/expandable/expanding-panel/tests/ExpandingPanel.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel/tests/__snapshots__/ExpandingPanel.spec.ts.snap +8 -8
- package/app/components/02.molecules/expandable/expanding-panel-classic/CONSUMER-STYLING.md +1 -0
- package/app/components/02.molecules/expandable/expanding-panel-classic/ExpandingPanelClassic.vue +10 -5
- package/app/components/02.molecules/expandable/expanding-panel-classic/stories/ExpandingPanelClassic.stories.ts +83 -1
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/ExpandingPanelClassic.spec.ts +24 -30
- package/app/components/02.molecules/expandable/expanding-panel-classic/tests/__snapshots__/ExpandingPanelClassic.spec.ts.snap +8 -8
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +63 -44
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +74 -37
- package/app/components/02.molecules/navigation/deep-expanding-menu/stories/DeepExpandingMenu.stories.ts +139 -19
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/DeepExpandingMenu.spec.ts +90 -27
- package/app/components/02.molecules/navigation/deep-expanding-menu/tests/__snapshots__/DeepExpandingMenu.spec.ts.snap +8 -8
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +58 -26
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +113 -57
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/stories/DeepExpandingMenuClassic.stories.ts +139 -19
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/DeepExpandingMenuClassic.spec.ts +140 -22
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/tests/__snapshots__/DeepExpandingMenuClassic.spec.ts.snap +9 -9
- package/app/components/02.molecules/qr-code/CONSUMER-STYLING.md +22 -7
- package/app/components/02.molecules/qr-code/CaptureQrCode.vue +63 -71
- package/app/components/02.molecules/qr-code/DecodeQrCode.vue +47 -10
- package/app/components/02.molecules/qr-code/stories/CaptureQrCode.stories.ts +114 -26
- package/app/components/02.molecules/qr-code/stories/DecodeQrCode.stories.ts +115 -24
- package/app/components/02.molecules/qr-code/tests/CaptureQrCode.spec.ts +124 -51
- package/app/components/02.molecules/qr-code/tests/DecodeQrCode.spec.ts +74 -25
- package/app/components/02.molecules/samaritan-prompt/CONSUMER-STYLING.md +22 -10
- package/app/components/02.molecules/samaritan-prompt/SamaritanPrompt.vue +30 -12
- package/app/components/02.molecules/samaritan-prompt/SamaritanPromptMixed.vue +34 -10
- package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPrompt.stories.ts +55 -4
- package/app/components/02.molecules/samaritan-prompt/stories/SamaritanPromptMixed.stories.ts +67 -7
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPrompt.spec.ts +113 -29
- package/app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed.spec.ts +92 -45
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +82 -29
- package/app/components/03.organisms/image-galleries/carousel-flip/CarouselFlip.vue +176 -113
- package/app/components/03.organisms/image-galleries/carousel-flip/stories/CarouselFlip.stories.ts +153 -23
- package/app/components/03.organisms/image-galleries/carousel-flip/tests/CarouselFlip.spec.ts +119 -63
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/NavigationItems.spec.ts.snap +2 -2
- package/app/components/03.organisms/responsive-header/tests/__snapshots__/ResponsiveHeader.spec.ts.snap +2 -2
- package/app/components/03.organisms/services/service-detail/tests/ServiceDetail.spec.ts +8 -8
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +4 -4
- package/app/components/03.organisms/site-header/CONSUMER-STYLING.md +41 -23
- package/app/components/03.organisms/site-header/SiteHeader.vue +16 -21
- package/app/components/03.organisms/site-header/stories/SiteHeader.stories.ts +138 -0
- package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +53 -26
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +2 -2
- package/app/types/components/qr-code.d.ts +12 -0
- package/app/utils/graphemes.ts +8 -0
- package/package.json +1 -1
- package/.claude/skills/components/accordian-core.md +0 -180
- package/.vscode/srcdev-component-accordian-core.code-snippets +0 -74
- package/app/components/01.atoms/animations/entry/.nuxt/app.config.mjs +0 -21
- package/app/components/01.atoms/animations/entry/.nuxt/components.d.ts +0 -66
- package/app/components/01.atoms/animations/entry/.nuxt/imports.d.ts +0 -30
- package/app/components/01.atoms/animations/entry/.nuxt/manifest/meta/test.json +0 -1
- package/app/components/01.atoms/animations/entry/.nuxt/types/app.config.d.ts +0 -35
- package/app/components/01.atoms/animations/entry/.nuxt/types/build.d.ts +0 -23
- package/app/components/01.atoms/animations/entry/.nuxt/types/builder-env.d.ts +0 -1
- package/app/components/01.atoms/animations/entry/.nuxt/types/components.d.ts +0 -71
- package/app/components/01.atoms/animations/entry/.nuxt/types/imports.d.ts +0 -386
- package/app/components/01.atoms/animations/entry/.nuxt/types/middleware.d.ts +0 -11
- package/app/components/01.atoms/animations/entry/.nuxt/types/modules.d.ts +0 -79
- package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-layouts.d.ts +0 -17
- package/app/components/01.atoms/animations/entry/.nuxt/types/nitro-nuxt.d.ts +0 -64
- package/app/components/01.atoms/animations/entry/.nuxt/types/plugins.d.ts +0 -29
- package/app/components/01.atoms/animations/entry/.nuxt/types/runtime-config.d.ts +0 -32
- package/app/components/01.atoms/animations/entry/.nuxt/types/shared-imports.d.ts +0 -10
- package/app/components/01.atoms/animations/entry/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/01.atoms/animations/entry/.nuxtrc +0 -1
- package/app/components/01.atoms/animations/entry/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
- package/app/components/01.atoms/animations/entry/node_modules/.cache/vite/client/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/app/components/02.molecules/expandable/accordian/CONSUMER-STYLING.md +0 -100
- package/app/components/02.molecules/expandable/accordian/stories/AccordianCore.stories.ts +0 -272
- package/app/components/02.molecules/expandable/accordian/tests/AccordianCore.spec.ts +0 -257
- package/app/components/02.molecules/expandable/accordian/tests/__snapshots__/AccordianCore.spec.ts.snap +0 -95
- package/app/components/03.organisms/responsive-header/tests/.nuxt/app.config.mjs +0 -21
- package/app/components/03.organisms/responsive-header/tests/.nuxt/components.d.ts +0 -66
- package/app/components/03.organisms/responsive-header/tests/.nuxt/imports.d.ts +0 -30
- package/app/components/03.organisms/responsive-header/tests/.nuxt/manifest/meta/test.json +0 -1
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/app.config.d.ts +0 -35
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/build.d.ts +0 -23
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/builder-env.d.ts +0 -1
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/components.d.ts +0 -71
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/imports.d.ts +0 -386
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/middleware.d.ts +0 -11
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/modules.d.ts +0 -79
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-layouts.d.ts +0 -17
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/nitro-nuxt.d.ts +0 -64
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/plugins.d.ts +0 -29
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/runtime-config.d.ts +0 -32
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/shared-imports.d.ts +0 -10
- package/app/components/03.organisms/responsive-header/tests/.nuxt/types/vue-shim.d.ts +0 -0
- package/app/components/03.organisms/responsive-header/tests/node_modules/.cache/nuxt/chrome-workspace.json +0 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component
|
|
3
|
+
:is="tag"
|
|
4
|
+
class="sparkle-effect"
|
|
5
|
+
:class="elementClasses"
|
|
6
|
+
:style="rootStyle"
|
|
7
|
+
:data-continuous="continuous ? '' : undefined"
|
|
8
|
+
>
|
|
9
|
+
<slot></slot>
|
|
10
|
+
<span class="sparkle-effect-sparks" aria-hidden="true">
|
|
11
|
+
<span v-for="(spark, index) in sparks" :key="index" class="sparkle-effect-spark" :style="spark"></span>
|
|
12
|
+
</span>
|
|
13
|
+
</component>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script setup lang="ts">
|
|
17
|
+
interface Props {
|
|
18
|
+
count?: number;
|
|
19
|
+
radius?: string;
|
|
20
|
+
continuous?: boolean;
|
|
21
|
+
tag?: "span" | "div";
|
|
22
|
+
styleClassPassthrough?: string | string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
26
|
+
count: 5,
|
|
27
|
+
radius: undefined,
|
|
28
|
+
continuous: false,
|
|
29
|
+
tag: "span",
|
|
30
|
+
styleClassPassthrough: () => [],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const DEFAULT_COUNT = 5;
|
|
34
|
+
const MAX_COUNT = 12;
|
|
35
|
+
|
|
36
|
+
const SPARK_PRESETS = [
|
|
37
|
+
{ lightness: "80%", hue: 0, scale: 1, x: 0, y: 3, offset: 80, speed: 1 },
|
|
38
|
+
{ lightness: "80%", hue: 50, scale: 0.85, x: 0, y: -3, offset: 60, speed: 1 },
|
|
39
|
+
{ lightness: "70%", hue: 20, scale: 1.15, x: 0, y: -1, offset: 70, speed: 0.85 },
|
|
40
|
+
{ lightness: "80%", hue: -20, scale: 0.7, x: -2, y: 5, offset: 75, speed: 1.2 },
|
|
41
|
+
{ lightness: "80%", hue: -200, scale: 1, x: 0, y: 0, offset: 70, speed: 1 },
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
const sparkCount = computed(() =>
|
|
45
|
+
Number.isFinite(props.count) ? Math.min(MAX_COUNT, Math.max(1, Math.floor(props.count))) : DEFAULT_COUNT
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const sparks = computed(() =>
|
|
49
|
+
Array.from({ length: sparkCount.value }, (_, index) => {
|
|
50
|
+
const preset = SPARK_PRESETS[index % SPARK_PRESETS.length]!;
|
|
51
|
+
const cycle = Math.floor(index / SPARK_PRESETS.length);
|
|
52
|
+
const mirror = cycle % 2 === 1 ? -1 : 1;
|
|
53
|
+
return {
|
|
54
|
+
"--_sparkle-effect-spark-colour": `oklch(from var(--_sparkle-effect-colour) ${preset.lightness} c calc(h + ${preset.hue + cycle * 35}))`,
|
|
55
|
+
"--_sparkle-effect-spark-scale": `${preset.scale}`,
|
|
56
|
+
"--_sparkle-effect-spark-translate": `${preset.x * mirror}rem ${preset.y * mirror}rem`,
|
|
57
|
+
"--_sparkle-effect-spark-offset": `${(preset.offset + cycle * 13) % 100}%`,
|
|
58
|
+
"--_sparkle-effect-spark-speed": `${preset.speed + cycle * 0.1}`,
|
|
59
|
+
};
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const rootStyle = computed(() => (props.radius ? { "--sparkle-effect-radius": props.radius } : undefined));
|
|
64
|
+
|
|
65
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
66
|
+
|
|
67
|
+
watch(
|
|
68
|
+
() => props.styleClassPassthrough,
|
|
69
|
+
() => {
|
|
70
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<style lang="css">
|
|
76
|
+
@layer components {
|
|
77
|
+
.sparkle-effect {
|
|
78
|
+
--_sparkle-effect-colour: var(--sparkle-effect-colour, var(--theme-border));
|
|
79
|
+
|
|
80
|
+
position: relative;
|
|
81
|
+
display: inline-grid;
|
|
82
|
+
isolation: isolate;
|
|
83
|
+
border-radius: var(--sparkle-effect-radius, 100vw);
|
|
84
|
+
|
|
85
|
+
&:is(div) {
|
|
86
|
+
display: grid;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
&::before {
|
|
90
|
+
content: "";
|
|
91
|
+
position: absolute;
|
|
92
|
+
inset: -2px;
|
|
93
|
+
z-index: -1;
|
|
94
|
+
border-radius: inherit;
|
|
95
|
+
background-color: var(--_sparkle-effect-colour);
|
|
96
|
+
filter: blur(var(--sparkle-effect-glow-blur, 1.2rem));
|
|
97
|
+
opacity: 0;
|
|
98
|
+
pointer-events: none;
|
|
99
|
+
transition: opacity 0.5s;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
& > .sparkle-effect-sparks {
|
|
103
|
+
display: none;
|
|
104
|
+
position: absolute;
|
|
105
|
+
inset: 0;
|
|
106
|
+
border-radius: inherit;
|
|
107
|
+
pointer-events: none;
|
|
108
|
+
|
|
109
|
+
& > .sparkle-effect-spark {
|
|
110
|
+
--_sparkle-effect-spark-timing: calc(
|
|
111
|
+
var(--sparkle-effect-duration, 1.5s) * var(--_sparkle-effect-spark-speed, 1)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
position: absolute;
|
|
115
|
+
offset-path: border-box;
|
|
116
|
+
offset-rotate: 0deg;
|
|
117
|
+
inline-size: calc(var(--sparkle-effect-size, 0.7rem) * var(--_sparkle-effect-spark-scale, 1));
|
|
118
|
+
aspect-ratio: 1 / 3;
|
|
119
|
+
border-radius: 50%;
|
|
120
|
+
background-color: var(--_sparkle-effect-colour);
|
|
121
|
+
filter: blur(var(--sparkle-effect-blur, 1px));
|
|
122
|
+
opacity: 0;
|
|
123
|
+
|
|
124
|
+
@supports (color: oklch(from red l c h)) {
|
|
125
|
+
background-color: var(--_sparkle-effect-spark-colour, var(--_sparkle-effect-colour));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
&::before,
|
|
129
|
+
&::after {
|
|
130
|
+
content: "";
|
|
131
|
+
position: absolute;
|
|
132
|
+
inline-size: inherit;
|
|
133
|
+
aspect-ratio: inherit;
|
|
134
|
+
border-radius: inherit;
|
|
135
|
+
background-color: inherit;
|
|
136
|
+
translate: -50%;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
&::before {
|
|
140
|
+
rotate: 55deg;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
&::after {
|
|
144
|
+
rotate: -55deg;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
&:is(:hover, :has(:focus-visible)):not(:has(> :disabled, > [aria-disabled="true"])) {
|
|
150
|
+
&::before {
|
|
151
|
+
opacity: var(--sparkle-effect-glow-opacity, 0.5);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
& > .sparkle-effect-sparks > .sparkle-effect-spark {
|
|
155
|
+
offset-distance: var(--_sparkle-effect-spark-offset, 70%);
|
|
156
|
+
translate: var(--_sparkle-effect-spark-translate, 0 0);
|
|
157
|
+
transition:
|
|
158
|
+
translate var(--_sparkle-effect-spark-timing),
|
|
159
|
+
offset-distance var(--_sparkle-effect-spark-timing);
|
|
160
|
+
animation: sparkle-effect-life calc(var(--_sparkle-effect-spark-timing) * 0.85) both;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
&[data-continuous] > .sparkle-effect-sparks > .sparkle-effect-spark {
|
|
164
|
+
transition: none;
|
|
165
|
+
animation: sparkle-effect-travel var(--_sparkle-effect-spark-timing) infinite;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@supports (offset-path: border-box) {
|
|
171
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
172
|
+
.sparkle-effect > .sparkle-effect-sparks {
|
|
173
|
+
display: block;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@keyframes sparkle-effect-life {
|
|
179
|
+
0% {
|
|
180
|
+
opacity: 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
20%,
|
|
184
|
+
70% {
|
|
185
|
+
opacity: 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
100% {
|
|
189
|
+
opacity: 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@keyframes sparkle-effect-travel {
|
|
194
|
+
0% {
|
|
195
|
+
offset-distance: 0%;
|
|
196
|
+
translate: 0 0;
|
|
197
|
+
opacity: 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
20%,
|
|
201
|
+
60% {
|
|
202
|
+
opacity: 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
85%,
|
|
206
|
+
100% {
|
|
207
|
+
offset-distance: var(--_sparkle-effect-spark-offset, 70%);
|
|
208
|
+
translate: var(--_sparkle-effect-spark-translate, 0 0);
|
|
209
|
+
opacity: 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
</style>
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
|
+
import StorybookComponent from "../SparkleEffect.vue";
|
|
4
|
+
import InputButton from "../../../../05.forms/input-button/InputButton.vue";
|
|
5
|
+
import CanvasSwitcher from "../../../canvas-switcher/CanvasSwitcher.vue";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
7
|
+
|
|
8
|
+
const COLOUR_OPTIONS = ["", "hsl(260 80% 70%)", "hsl(330 85% 65%)", "hsl(170 70% 45%)", "hsl(40 95% 55%)"] as const;
|
|
9
|
+
const SIZE_OPTIONS = ["0.5rem", "0.7rem", "1rem", "1.4rem"] as const;
|
|
10
|
+
const SPARK_BLUR_OPTIONS = ["0px", "0.5px", "1px", "2px", "3px"] as const;
|
|
11
|
+
const DURATION_OPTIONS = ["0.8s", "1.5s", "2.5s", "4s"] as const;
|
|
12
|
+
const BLUR_OPTIONS = ["0.6rem", "1.2rem", "2rem"] as const;
|
|
13
|
+
const OPACITY_OPTIONS = ["0.3", "0.5", "0.8"] as const;
|
|
14
|
+
|
|
15
|
+
type StoryArgs = {
|
|
16
|
+
count: number;
|
|
17
|
+
radius: string;
|
|
18
|
+
continuous: boolean;
|
|
19
|
+
tag: "span" | "div";
|
|
20
|
+
colour: (typeof COLOUR_OPTIONS)[number];
|
|
21
|
+
size: (typeof SIZE_OPTIONS)[number];
|
|
22
|
+
blur: (typeof SPARK_BLUR_OPTIONS)[number];
|
|
23
|
+
duration: (typeof DURATION_OPTIONS)[number];
|
|
24
|
+
glowBlur: (typeof BLUR_OPTIONS)[number];
|
|
25
|
+
glowOpacity: (typeof OPACITY_OPTIONS)[number];
|
|
26
|
+
buttonText: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const tokenCategory = "CSS tokens (story only, set in your CSS)";
|
|
30
|
+
const tokenControl = (token: string, example: string, options: readonly string[]) => ({
|
|
31
|
+
control: { type: "select" as const },
|
|
32
|
+
options,
|
|
33
|
+
description: `**Story control, not a prop.** Sets \`${token}\`. In your CSS: \`.promo { ${token}: ${example}; }\``,
|
|
34
|
+
table: { category: tokenCategory },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
title: "Atoms/Effects/SparkleEffect",
|
|
39
|
+
component: StorybookComponent,
|
|
40
|
+
argTypes: {
|
|
41
|
+
count: {
|
|
42
|
+
control: { type: "range", min: 1, max: 12, step: 1 },
|
|
43
|
+
description: "Number of sparkles (floored, clamped to 1 to 12).",
|
|
44
|
+
},
|
|
45
|
+
radius: {
|
|
46
|
+
control: { type: "text" },
|
|
47
|
+
description: "Corner radius the sparks travel around; match the slotted element's radius. Sets `--sparkle-effect-radius`.",
|
|
48
|
+
},
|
|
49
|
+
continuous: {
|
|
50
|
+
control: { type: "boolean" },
|
|
51
|
+
description: "Keep the sparkles looping for as long as the hover or focus lasts, instead of one burst per hover.",
|
|
52
|
+
},
|
|
53
|
+
tag: {
|
|
54
|
+
control: { type: "inline-radio" },
|
|
55
|
+
options: ["span", "div"],
|
|
56
|
+
description: "Root element. `span` wraps inline content like a button; `div` wraps block content like a card.",
|
|
57
|
+
},
|
|
58
|
+
colour: {
|
|
59
|
+
...tokenControl("--sparkle-effect-colour", "hsl(260 80% 70%)", COLOUR_OPTIONS),
|
|
60
|
+
control: {
|
|
61
|
+
type: "select",
|
|
62
|
+
labels: { "": "Theme default", "hsl(260 80% 70%)": "Lavender (the original pen)", "hsl(330 85% 65%)": "Pink", "hsl(170 70% 45%)": "Teal", "hsl(40 95% 55%)": "Amber" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
size: tokenControl("--sparkle-effect-size", "1rem", SIZE_OPTIONS),
|
|
66
|
+
blur: tokenControl("--sparkle-effect-blur", "0px", SPARK_BLUR_OPTIONS),
|
|
67
|
+
duration: tokenControl("--sparkle-effect-duration", "2.5s", DURATION_OPTIONS),
|
|
68
|
+
glowBlur: tokenControl("--sparkle-effect-glow-blur", "2rem", BLUR_OPTIONS),
|
|
69
|
+
glowOpacity: tokenControl("--sparkle-effect-glow-opacity", "0.8", OPACITY_OPTIONS),
|
|
70
|
+
buttonText: {
|
|
71
|
+
control: { type: "text" },
|
|
72
|
+
description: "**Story control, not a prop.** Text of the demo button in the slot.",
|
|
73
|
+
table: { category: "Slot content (story only)" },
|
|
74
|
+
},
|
|
75
|
+
styleClassPassthrough: { table: { disable: true } },
|
|
76
|
+
class: { table: { disable: true } },
|
|
77
|
+
style: { table: { disable: true } },
|
|
78
|
+
},
|
|
79
|
+
args: {
|
|
80
|
+
count: 5,
|
|
81
|
+
radius: "",
|
|
82
|
+
continuous: false,
|
|
83
|
+
tag: "span",
|
|
84
|
+
colour: "hsl(260 80% 70%)",
|
|
85
|
+
size: "0.7rem",
|
|
86
|
+
blur: "1px",
|
|
87
|
+
duration: "1.5s",
|
|
88
|
+
glowBlur: "1.2rem",
|
|
89
|
+
glowOpacity: "0.5",
|
|
90
|
+
buttonText: "Whimsy",
|
|
91
|
+
},
|
|
92
|
+
parameters: {
|
|
93
|
+
docs: {
|
|
94
|
+
description: {
|
|
95
|
+
component:
|
|
96
|
+
"Wraps its default slot and, on hover or keyboard focus inside it, sends small sparkles along the wrapper's rounded border while a soft glow fades in behind. " +
|
|
97
|
+
"`count` sets how many sparkles (1 to 12), `continuous` keeps them looping while hovered or focused, `radius` sets the shape they follow (match the slotted element's radius), and `tag` picks `span` or `div`. " +
|
|
98
|
+
"Sparkles need `offset-path: border-box` (Safari 17.2+), so older browsers and `prefers-reduced-motion: reduce` get the glow only. " +
|
|
99
|
+
"Effect adapted from a Kevin Powell CodePen. Hover or tab to each example to see it.",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
} as Meta<StoryArgs>;
|
|
104
|
+
|
|
105
|
+
function useStorySetup(args: StoryArgs) {
|
|
106
|
+
const componentArgs = computed(() => ({
|
|
107
|
+
count: args.count,
|
|
108
|
+
radius: args.radius || undefined,
|
|
109
|
+
continuous: args.continuous,
|
|
110
|
+
tag: args.tag,
|
|
111
|
+
}));
|
|
112
|
+
const tokenStyle = computed(() => ({
|
|
113
|
+
"--sparkle-effect-colour": args.colour || undefined,
|
|
114
|
+
"--sparkle-effect-size": args.size,
|
|
115
|
+
"--sparkle-effect-blur": args.blur,
|
|
116
|
+
"--sparkle-effect-duration": args.duration,
|
|
117
|
+
"--sparkle-effect-glow-blur": args.glowBlur,
|
|
118
|
+
"--sparkle-effect-glow-opacity": args.glowOpacity,
|
|
119
|
+
}));
|
|
120
|
+
return { args, componentArgs, tokenStyle };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const stage = "padding: 6rem 4rem; display: flex; flex-wrap: wrap; gap: 4rem; align-items: center;";
|
|
124
|
+
|
|
125
|
+
export const Default: StoryFn<StoryArgs> = (args) => ({
|
|
126
|
+
components: { StorybookComponent, InputButton },
|
|
127
|
+
setup() {
|
|
128
|
+
return useStorySetup(args);
|
|
129
|
+
},
|
|
130
|
+
template: `
|
|
131
|
+
<div style="${stage}" :style="tokenStyle">
|
|
132
|
+
<StorybookComponent v-bind="componentArgs">
|
|
133
|
+
<InputButton :button-text="args.buttonText" variant="secondary" is-pill></InputButton>
|
|
134
|
+
</StorybookComponent>
|
|
135
|
+
</div>
|
|
136
|
+
`,
|
|
137
|
+
});
|
|
138
|
+
Default.parameters = {
|
|
139
|
+
docs: {
|
|
140
|
+
description: {
|
|
141
|
+
story:
|
|
142
|
+
"The original pen's look: a pill button with a lavender colour and five sparkles. Hover the button, or tab to it and check its own focus ring is still visible.",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const ThemeColour: StoryFn<StoryArgs> = Default.bind({});
|
|
148
|
+
ThemeColour.args = { colour: "" };
|
|
149
|
+
ThemeColour.parameters = {
|
|
150
|
+
docs: {
|
|
151
|
+
description: {
|
|
152
|
+
story:
|
|
153
|
+
"With no colour set, the effect takes `--theme-border`. The sparkles keep the theme hue but are lightened, so they stay pastel on any theme.",
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const ManySparks: StoryFn<StoryArgs> = Default.bind({});
|
|
159
|
+
ManySparks.args = { count: 12, buttonText: "Twelve sparkles" };
|
|
160
|
+
ManySparks.parameters = {
|
|
161
|
+
docs: {
|
|
162
|
+
description: {
|
|
163
|
+
story:
|
|
164
|
+
"Sparkles 6 to 12 reuse the five tuned presets with a hue shift, a further end point and mirrored drift, so they spread out rather than doubling up.",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const Continuous: StoryFn<StoryArgs> = Default.bind({});
|
|
170
|
+
Continuous.args = { continuous: true, count: 7, buttonText: "Keep hovering" };
|
|
171
|
+
Continuous.parameters = {
|
|
172
|
+
docs: {
|
|
173
|
+
description: {
|
|
174
|
+
story:
|
|
175
|
+
"With `continuous`, each sparkle loops (travel, drift and fade in one keyframe) for as long as the pointer stays or focus remains inside. Their different speeds stop them pulsing in step.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const OnCard: StoryFn<StoryArgs> = (args) => ({
|
|
181
|
+
components: { StorybookComponent },
|
|
182
|
+
setup() {
|
|
183
|
+
return useStorySetup(args);
|
|
184
|
+
},
|
|
185
|
+
template: `
|
|
186
|
+
<div style="${stage}" :style="tokenStyle">
|
|
187
|
+
<StorybookComponent v-bind="componentArgs" style="inline-size: min(100%, 36rem);">
|
|
188
|
+
<a
|
|
189
|
+
href="#"
|
|
190
|
+
style="display: grid; gap: 0.8rem; padding: 2.4rem; border-radius: 1.2rem; border: 1px solid var(--theme-border); background: var(--theme-surface-subtle); color: var(--theme-text); text-decoration: none;"
|
|
191
|
+
>
|
|
192
|
+
<strong style="font-size: var(--step-5);">Northtown spring workshop</strong>
|
|
193
|
+
<span>Hover or tab to this card. The sparkles follow its 1.2rem corners because the wrapper has the same radius.</span>
|
|
194
|
+
</a>
|
|
195
|
+
</StorybookComponent>
|
|
196
|
+
</div>
|
|
197
|
+
`,
|
|
198
|
+
});
|
|
199
|
+
OnCard.args = { tag: "div", radius: "1.2rem", count: 7 };
|
|
200
|
+
OnCard.parameters = {
|
|
201
|
+
docs: {
|
|
202
|
+
description: {
|
|
203
|
+
story:
|
|
204
|
+
"Block content uses `tag=\"div\"`, and `radius` matches the card's own corners. Keyboard focus on the link inside triggers the effect too.",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const Disabled: StoryFn<StoryArgs> = (args) => ({
|
|
210
|
+
components: { StorybookComponent, InputButton },
|
|
211
|
+
setup() {
|
|
212
|
+
return useStorySetup(args);
|
|
213
|
+
},
|
|
214
|
+
template: `
|
|
215
|
+
<div style="${stage}" :style="tokenStyle">
|
|
216
|
+
<StorybookComponent v-bind="componentArgs">
|
|
217
|
+
<button type="button" disabled style="padding: 1.2rem 3rem; border-radius: 100vw;">Disabled button</button>
|
|
218
|
+
</StorybookComponent>
|
|
219
|
+
<StorybookComponent v-bind="componentArgs">
|
|
220
|
+
<InputButton button-text="Read-only InputButton" variant="secondary" is-pill readonly></InputButton>
|
|
221
|
+
</StorybookComponent>
|
|
222
|
+
</div>
|
|
223
|
+
`,
|
|
224
|
+
});
|
|
225
|
+
Disabled.parameters = {
|
|
226
|
+
docs: {
|
|
227
|
+
description: {
|
|
228
|
+
story:
|
|
229
|
+
"No glow or sparkles while the slotted control is `:disabled` or `aria-disabled=\"true\"` (InputButton's `readonly` sets the latter).",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export const StressTest: StoryFn<StoryArgs> = (args) => ({
|
|
235
|
+
components: { StorybookComponent, InputButton },
|
|
236
|
+
setup() {
|
|
237
|
+
const longUnbroken = "DonaudampfschifffahrtselektrizitΓ€tenhauptbetriebswerkbauunterbeamtengesellschaft";
|
|
238
|
+
const hostileCounts = [0, -3, 2.5, 99, Number.NaN];
|
|
239
|
+
return { ...useStorySetup(args), longUnbroken, hostileCounts };
|
|
240
|
+
},
|
|
241
|
+
template: `
|
|
242
|
+
<div style="padding: 4rem 1.6rem; display: grid; gap: 4rem; justify-items: start;" :style="tokenStyle">
|
|
243
|
+
<div style="display: flex; flex-wrap: wrap; gap: 3rem;">
|
|
244
|
+
<StorybookComponent v-for="(count, index) in hostileCounts" :key="index" :count="count">
|
|
245
|
+
<InputButton :button-text="'count=' + count" variant="secondary" is-pill></InputButton>
|
|
246
|
+
</StorybookComponent>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<StorybookComponent v-bind="componentArgs" style="max-inline-size: 100%;">
|
|
250
|
+
<InputButton :button-text="longUnbroken" variant="secondary" is-pill></InputButton>
|
|
251
|
+
</StorybookComponent>
|
|
252
|
+
|
|
253
|
+
<StorybookComponent v-bind="componentArgs" tag="div" radius="0" style="inline-size: 100%;">
|
|
254
|
+
<div style="padding: 2.4rem; background: var(--theme-surface-subtle); overflow-wrap: anywhere;">
|
|
255
|
+
Full-width block child with square corners. {{ longUnbroken }}
|
|
256
|
+
</div>
|
|
257
|
+
</StorybookComponent>
|
|
258
|
+
|
|
259
|
+
<StorybookComponent v-bind="componentArgs" radius="50%">
|
|
260
|
+
<InputButton button-text="Settings" variant="secondary" is-pill>
|
|
261
|
+
<template #iconOnly><Icon name="material-symbols:settings" class="icon"></Icon></template>
|
|
262
|
+
</InputButton>
|
|
263
|
+
</StorybookComponent>
|
|
264
|
+
|
|
265
|
+
<StorybookComponent v-bind="componentArgs" tag="div" radius="1.6rem" style="padding: 2rem; border: 1px dashed currentColor; border-radius: 1.6rem;">
|
|
266
|
+
<StorybookComponent v-bind="componentArgs">
|
|
267
|
+
<InputButton button-text="Nested SparkleEffect" variant="secondary" is-pill></InputButton>
|
|
268
|
+
</StorybookComponent>
|
|
269
|
+
</StorybookComponent>
|
|
270
|
+
|
|
271
|
+
<StorybookComponent v-bind="componentArgs"></StorybookComponent>
|
|
272
|
+
</div>
|
|
273
|
+
`,
|
|
274
|
+
});
|
|
275
|
+
StressTest.storyName = "Stress Test (Worst-Case Data)";
|
|
276
|
+
StressTest.decorators = [
|
|
277
|
+
(story, context) => ({
|
|
278
|
+
components: { story, CanvasSwitcher },
|
|
279
|
+
setup() {
|
|
280
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "mobileCanvas");
|
|
281
|
+
return { canvasName };
|
|
282
|
+
},
|
|
283
|
+
template: `
|
|
284
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
285
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
286
|
+
</div>
|
|
287
|
+
<div :class="canvasName" style="margin-inline: auto; outline: 1px dashed currentColor;">
|
|
288
|
+
<story />
|
|
289
|
+
</div>
|
|
290
|
+
`,
|
|
291
|
+
}),
|
|
292
|
+
];
|
|
293
|
+
StressTest.parameters = {
|
|
294
|
+
initialCanvas: "mobileCanvas",
|
|
295
|
+
docs: {
|
|
296
|
+
description: {
|
|
297
|
+
story:
|
|
298
|
+
"Hostile counts (0 and -3 render one sparkle, 2.5 renders two, 99 renders twelve, NaN falls back to five), a button with a long unbroken label " +
|
|
299
|
+
"(it should wrap or overflow the same as without the wrapper, and the sparkles follow its box), a full-width square block, a round icon-only button, " +
|
|
300
|
+
"a nested SparkleEffect (hovering the inner one runs both) and an empty wrapper (renders nothing visible). Hover each at every canvas width.",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import SparkleEffect from "../SparkleEffect.vue";
|
|
4
|
+
|
|
5
|
+
type Wrapper = Awaited<ReturnType<typeof mountSuspended<typeof SparkleEffect>>>;
|
|
6
|
+
|
|
7
|
+
const sparkStyle = (el: Element) => (el as HTMLElement).style;
|
|
8
|
+
|
|
9
|
+
describe("SparkleEffect", () => {
|
|
10
|
+
let wrapper: Wrapper | undefined;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
wrapper?.unmount();
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// βββ Mount and root βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
18
|
+
|
|
19
|
+
it("mounts without error", async () => {
|
|
20
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
21
|
+
expect(wrapper!.vm).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders as <span> by default with the sparkle-effect class", async () => {
|
|
25
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
26
|
+
expect(wrapper!.element.tagName).toBe("SPAN");
|
|
27
|
+
expect(wrapper!.classes()).toContain("sparkle-effect");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders as <div> when tag='div'", async () => {
|
|
31
|
+
wrapper = await mountSuspended(SparkleEffect, { props: { tag: "div" } });
|
|
32
|
+
expect(wrapper!.element.tagName).toBe("DIV");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// βββ Slot βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
36
|
+
|
|
37
|
+
it("renders the slotted content unchanged", async () => {
|
|
38
|
+
wrapper = await mountSuspended(SparkleEffect, {
|
|
39
|
+
slots: { default: "<button type='button' class='slotted'>Whimsy</button>" },
|
|
40
|
+
});
|
|
41
|
+
const button = wrapper!.find(".slotted");
|
|
42
|
+
expect(button.exists()).toBe(true);
|
|
43
|
+
expect(button.text()).toBe("Whimsy");
|
|
44
|
+
expect(button.attributes("type")).toBe("button");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// βββ Sparks: count ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
48
|
+
|
|
49
|
+
it("renders five sparks by default", async () => {
|
|
50
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
51
|
+
expect(wrapper!.findAll(".sparkle-effect-spark")).toHaveLength(5);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it.each([
|
|
55
|
+
[1, 1],
|
|
56
|
+
[7, 7],
|
|
57
|
+
[12, 12],
|
|
58
|
+
[99, 12],
|
|
59
|
+
[0, 1],
|
|
60
|
+
[-3, 1],
|
|
61
|
+
[2.5, 2],
|
|
62
|
+
[Number.NaN, 5],
|
|
63
|
+
[Number.POSITIVE_INFINITY, 5],
|
|
64
|
+
])("renders count=%s as %s sparks", async (count, expected) => {
|
|
65
|
+
wrapper = await mountSuspended(SparkleEffect, { props: { count } });
|
|
66
|
+
expect(wrapper!.findAll(".sparkle-effect-spark")).toHaveLength(expected);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("updates the spark count when the count prop changes", async () => {
|
|
70
|
+
wrapper = await mountSuspended(SparkleEffect, { props: { count: 3 } });
|
|
71
|
+
await wrapper!.setProps({ count: 8 });
|
|
72
|
+
expect(wrapper!.findAll(".sparkle-effect-spark")).toHaveLength(8);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// βββ Sparks: presets ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
76
|
+
|
|
77
|
+
it("gives the first five sparks the tuned presets", async () => {
|
|
78
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
79
|
+
const sparks = wrapper!.findAll(".sparkle-effect-spark");
|
|
80
|
+
const offsets = sparks.map((s) => sparkStyle(s.element).getPropertyValue("--_sparkle-effect-spark-offset"));
|
|
81
|
+
const colours = sparks.map((s) => sparkStyle(s.element).getPropertyValue("--_sparkle-effect-spark-colour"));
|
|
82
|
+
expect(offsets).toEqual(["80%", "60%", "70%", "75%", "70%"]);
|
|
83
|
+
expect(colours[1]).toBe("oklch(from var(--_sparkle-effect-colour) 80% c calc(h + 50))");
|
|
84
|
+
expect(colours[2]).toBe("oklch(from var(--_sparkle-effect-colour) 70% c calc(h + 20))");
|
|
85
|
+
expect(colours[4]).toContain("calc(h + -200)");
|
|
86
|
+
expect(sparkStyle(sparks[3]!.element).getPropertyValue("--_sparkle-effect-spark-translate")).toBe("-2rem 5rem");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("nudges hue and offset and mirrors drift for sparks past the fifth", async () => {
|
|
90
|
+
wrapper = await mountSuspended(SparkleEffect, { props: { count: 6 } });
|
|
91
|
+
const sixth = sparkStyle(wrapper!.findAll(".sparkle-effect-spark")[5]!.element);
|
|
92
|
+
expect(sixth.getPropertyValue("--_sparkle-effect-spark-colour")).toContain("calc(h + 35)");
|
|
93
|
+
expect(sixth.getPropertyValue("--_sparkle-effect-spark-offset")).toBe("93%");
|
|
94
|
+
expect(sixth.getPropertyValue("--_sparkle-effect-spark-translate")).toBe("0rem -3rem");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// βββ Radius βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
98
|
+
|
|
99
|
+
it("sets --sparkle-effect-radius inline when radius is given", async () => {
|
|
100
|
+
wrapper = await mountSuspended(SparkleEffect, { props: { radius: "1.2rem" } });
|
|
101
|
+
expect((wrapper!.element as HTMLElement).style.getPropertyValue("--sparkle-effect-radius")).toBe("1.2rem");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("leaves the radius token unset by default so an ancestor value applies", async () => {
|
|
105
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
106
|
+
expect((wrapper!.element as HTMLElement).style.getPropertyValue("--sparkle-effect-radius")).toBe("");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// βββ Continuous βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
110
|
+
|
|
111
|
+
it("is one-shot by default (no data-continuous)", async () => {
|
|
112
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
113
|
+
expect(wrapper!.attributes("data-continuous")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("sets data-continuous when continuous is on, and clears it when turned off", async () => {
|
|
117
|
+
wrapper = await mountSuspended(SparkleEffect, { props: { continuous: true } });
|
|
118
|
+
expect(wrapper!.attributes("data-continuous")).toBe("");
|
|
119
|
+
await wrapper!.setProps({ continuous: false });
|
|
120
|
+
expect(wrapper!.attributes("data-continuous")).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// βββ Accessibility ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
124
|
+
|
|
125
|
+
it("hides the sparks from assistive tech and keeps them out of the tab order", async () => {
|
|
126
|
+
wrapper = await mountSuspended(SparkleEffect);
|
|
127
|
+
const sparks = wrapper!.find(".sparkle-effect-sparks");
|
|
128
|
+
expect(sparks.attributes("aria-hidden")).toBe("true");
|
|
129
|
+
expect(wrapper!.findAll("[tabindex]")).toHaveLength(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// βββ styleClassPassthrough ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
133
|
+
|
|
134
|
+
it("applies styleClassPassthrough classes", async () => {
|
|
135
|
+
wrapper = await mountSuspended(SparkleEffect, {
|
|
136
|
+
props: { styleClassPassthrough: ["promo", "featured"] },
|
|
137
|
+
});
|
|
138
|
+
expect(wrapper!.classes()).toContain("promo");
|
|
139
|
+
expect(wrapper!.classes()).toContain("featured");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("updates classes when styleClassPassthrough prop changes", async () => {
|
|
143
|
+
wrapper = await mountSuspended(SparkleEffect, {
|
|
144
|
+
props: { styleClassPassthrough: ["promo"] },
|
|
145
|
+
});
|
|
146
|
+
await wrapper!.setProps({ styleClassPassthrough: ["featured"] });
|
|
147
|
+
expect(wrapper!.classes()).not.toContain("promo");
|
|
148
|
+
expect(wrapper!.classes()).toContain("featured");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -106,7 +106,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
106
106
|
docsPageNavAriaLabel: "On this page",
|
|
107
107
|
// Defaults to "classic" β the docs/admin nav toggle relies on contentIsOnTop on mobile, and
|
|
108
108
|
// ExpandingPanel's ::details-content-based positioning silently fails on WebKit (Safari/iOS),
|
|
109
|
-
// making the nav unreachable there. See
|
|
109
|
+
// making the nav unreachable there. See AccordionGroup's own variant prop and CLAUDE.md
|
|
110
110
|
// pitfall #19 for the full mechanism. "modern" is opt-in.
|
|
111
111
|
panelVariant: "classic",
|
|
112
112
|
styleClassPassthrough: () => [],
|