srcdev-nuxt-components 9.6.18 → 9.6.19
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/vue-dynamic-arg-no-spaces.md +16 -0
- package/.claude/skills/component-compliance-checklist.md +6 -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/expanding-panel-classic.md +9 -7
- package/.claude/skills/components/expanding-panel.md +9 -8
- package/.claude/skills/components/sparkle-effect.md +79 -0
- package/.claude/skills/index.md +4 -2
- package/.claude/skills/storybook-add-story.md +8 -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-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/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/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/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/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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV AccordionGroup": {
|
|
3
|
+
"description": "AccordionGroup with item-{n}-summary/content slots; a shared name makes it exclusive-open",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<AccordionGroup name=\"${1:faq}\">",
|
|
7
|
+
" <template #item-0-summary>${2:Question one?}</template>",
|
|
8
|
+
" <template #item-0-content><p>${3:Answer one.}</p></template>",
|
|
9
|
+
"",
|
|
10
|
+
" <template #item-1-summary>${4:Question two?}</template>",
|
|
11
|
+
" <template #item-1-content><p>${5:Answer two.}</p></template>",
|
|
12
|
+
"</AccordionGroup>"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"SRCDEV AccordionGroup Looped": {
|
|
16
|
+
"description": "AccordionGroup driven by an array via dynamic item-{n} slot names",
|
|
17
|
+
"scope": "vue,html",
|
|
18
|
+
"body": [
|
|
19
|
+
"<AccordionGroup name=\"${1:faq}\">",
|
|
20
|
+
" <template v-for=\"(item, i) in ${2:items}\" :key=\"`s\\${i}`\" #[`item-\\${i}-summary`]>{{ item.${3:question} }}</template>",
|
|
21
|
+
" <template v-for=\"(item, i) in ${2:items}\" :key=\"`c\\${i}`\" #[`item-\\${i}-content`]><p>{{ item.${4:answer} }}</p></template>",
|
|
22
|
+
"</AccordionGroup>"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"SRCDEV AccordionGroup CSS Override": {
|
|
26
|
+
"description": "AccordionGroup width and panel divider overrides scoped to a page or section class",
|
|
27
|
+
"scope": "css",
|
|
28
|
+
"body": [
|
|
29
|
+
".${1:faq-section} {",
|
|
30
|
+
" --accordion-group-max-width: ${2:72rem};",
|
|
31
|
+
"",
|
|
32
|
+
" .accordion-group-item:not(:last-child) {",
|
|
33
|
+
" border-block-end: 0.1rem solid ${3:var(--theme-border)};",
|
|
34
|
+
" }",
|
|
35
|
+
"}"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"scope": "vue,html",
|
|
21
21
|
"body": [
|
|
22
22
|
"<ActionMenu>",
|
|
23
|
-
" <template v-for=\"(
|
|
24
|
-
" <ActionMenuItem :label=\"
|
|
23
|
+
" <template v-for=\"(action, i) in ${1:actions}\" :key=\"`\\${i}-\\${action.label}`\" #[`item-\\${i}`]>",
|
|
24
|
+
" <ActionMenuItem :label=\"action.label\" :href=\"action.href\" @click=\"action.onClick\" />",
|
|
25
25
|
" </template>",
|
|
26
26
|
"</ActionMenu>"
|
|
27
27
|
]
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"description": "CSS override scaffold for ActionMenu tokens",
|
|
31
31
|
"scope": "css",
|
|
32
32
|
"body": [
|
|
33
|
-
".$
|
|
33
|
+
".${1:my-page} {",
|
|
34
34
|
" --action-menu-trigger-icon-color: $2;",
|
|
35
35
|
" --action-menu-popover-background: $3;",
|
|
36
36
|
" --action-menu-popover-border: $4;",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"SRCDEV SparkleEffect Button": {
|
|
3
|
+
"description": "SparkleEffect around a pill button (sparkles along its border on hover/focus)",
|
|
4
|
+
"scope": "vue,html",
|
|
5
|
+
"body": [
|
|
6
|
+
"<SparkleEffect :count=\"${1:5}\"${4| continuous,|}>",
|
|
7
|
+
" <InputButton button-text=\"${2:Button text}\" variant=\"${3|primary,secondary,tertiary|}\" is-pill />",
|
|
8
|
+
"</SparkleEffect>"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"SRCDEV SparkleEffect Card": {
|
|
12
|
+
"description": "SparkleEffect around block content with its own corner radius",
|
|
13
|
+
"scope": "vue,html",
|
|
14
|
+
"body": [
|
|
15
|
+
"<SparkleEffect tag=\"div\" radius=\"${1:1.2rem}\" :count=\"${2:7}\">",
|
|
16
|
+
" $0",
|
|
17
|
+
"</SparkleEffect>"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"SRCDEV SparkleEffect CSS Override": {
|
|
21
|
+
"description": "SparkleEffect token overrides scoped to a page or section class",
|
|
22
|
+
"scope": "css,vue",
|
|
23
|
+
"body": [
|
|
24
|
+
".${1:page-name} {",
|
|
25
|
+
" --sparkle-effect-colour: ${2:hsl(260 80% 70%)};",
|
|
26
|
+
" --sparkle-effect-size: ${3:0.7rem};",
|
|
27
|
+
" --sparkle-effect-blur: ${6:1px};",
|
|
28
|
+
" --sparkle-effect-duration: ${4:1.5s};",
|
|
29
|
+
" --sparkle-effect-glow-opacity: ${5:0.5};",
|
|
30
|
+
"}"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SparkleEffect — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
| Token | Default | Description |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| `--sparkle-effect-colour` | `var(--theme-border)` | Glow colour, and the base hue the sparkles shift from. With relative colour support each sparkle is a lightened, hue-shifted version of it; without, they use it as-is. |
|
|
8
|
+
| `--sparkle-effect-size` | `0.7rem` | Width of one sparkle arm (each arm is 1:3, and each sparkle scales this by its preset). |
|
|
9
|
+
| `--sparkle-effect-blur` | `1px` | Blur on each sparkle. `0` gives crisp sparkles; the original pen used `3px`. |
|
|
10
|
+
| `--sparkle-effect-duration` | `1.5s` | Base travel time; each sparkle multiplies it by its own speed. |
|
|
11
|
+
| `--sparkle-effect-radius` | `100vw` | Corner shape of the path the sparkles follow and of the glow. Match the slotted element's radius. Also set by the `radius` prop. |
|
|
12
|
+
| `--sparkle-effect-glow-blur` | `1.2rem` | Blur of the glow behind the slot. |
|
|
13
|
+
| `--sparkle-effect-glow-opacity` | `0.5` | Glow opacity while active. |
|
|
14
|
+
|
|
15
|
+
Private tokens, not public API: `--_sparkle-effect-colour` (the resolved colour) and the per-sparkle
|
|
16
|
+
`--_sparkle-effect-spark-*` values (colour, scale, translate, offset, speed, timing), which
|
|
17
|
+
the component writes inline on each sparkle from its preset table.
|
|
18
|
+
|
|
19
|
+
## State hooks
|
|
20
|
+
|
|
21
|
+
| Selector | Meaning |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `.sparkle-effect` | Root element (`span` or `div`, per `tag`). |
|
|
24
|
+
| `.sparkle-effect::before` | The glow, behind the slot. |
|
|
25
|
+
| `.sparkle-effect-sparks` | `aria-hidden` layer holding the sparkles, over the slot. |
|
|
26
|
+
| `.sparkle-effect-spark` | One sparkle. |
|
|
27
|
+
| `.sparkle-effect[data-continuous]` | Set by the `continuous` prop: sparkles loop while active. |
|
|
28
|
+
|
|
29
|
+
The effect runs on `.sparkle-effect:hover` and when anything inside has `:focus-visible`. It's
|
|
30
|
+
suppressed while the slotted element (direct child) is `:disabled` or `[aria-disabled="true"]`.
|
|
31
|
+
|
|
32
|
+
## Motion and browser support
|
|
33
|
+
|
|
34
|
+
- Sparkles travel along the wrapper's border box with `offset-path: border-box`. They render only
|
|
35
|
+
where that's supported (Chrome 116+, Safari 17.2+, Firefox 122+). Elsewhere, including Safari
|
|
36
|
+
16, the glow still works.
|
|
37
|
+
- Under `prefers-reduced-motion: reduce` the sparkles are hidden and only the glow fades in.
|
|
38
|
+
- Sparkles start at the top-left of the path and snap back when the pointer leaves, so each hover
|
|
39
|
+
replays the effect. With `continuous`, each sparkle loops instead (`sparkle-effect-travel` keyframes:
|
|
40
|
+
travel, drift and fade in one), at its own speed, until the hover or focus ends.
|
|
41
|
+
|
|
42
|
+
## Shape
|
|
43
|
+
|
|
44
|
+
The wrapper shrinks to its slot (`inline-grid` for `span`, `grid` for `div`) and can't read the
|
|
45
|
+
slotted element's own `border-radius`, so set `--sparkle-effect-radius` (or `radius`) to match it:
|
|
46
|
+
the default `100vw` suits pill buttons, a card might need `1.2rem`, a round icon button `50%`.
|
|
47
|
+
|
|
48
|
+
## Local overrides
|
|
49
|
+
|
|
50
|
+
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
51
|
+
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
52
|
+
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
53
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the component need
|
|
54
|
+
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
55
|
+
|
|
56
|
+
**Caveat:** the `radius` prop writes `--sparkle-effect-radius` inline on the root, so when it's set
|
|
57
|
+
an ancestor's value for that token never lands.
|
|
58
|
+
|
|
59
|
+
## Class passthrough
|
|
60
|
+
|
|
61
|
+
`styleClassPassthrough` adds classes to the root, for sizing or placing the wrapper (e.g.
|
|
62
|
+
`inline-size: 100%` around a full-width card) or for setting the tokens above on one instance.
|
|
63
|
+
|
|
64
|
+
```vue
|
|
65
|
+
<SparkleEffect style-class-passthrough="promo-sparkle">
|
|
66
|
+
<InputButton button-text="Join the waitlist" is-pill />
|
|
67
|
+
</SparkleEffect>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```css
|
|
71
|
+
.promo-sparkle {
|
|
72
|
+
--sparkle-effect-colour: hsl(260 80% 70%);
|
|
73
|
+
--sparkle-effect-duration: 2s;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
@@ -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
|
+
};
|