srcdev-nuxt-components 9.1.39 → 9.1.41

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.
@@ -24,7 +24,12 @@
24
24
  "Bash(git -C /Users/simoncornforth/websites/nuxt-components show 0f99d7a --stat)",
25
25
  "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff app/components/parallax/SectionParallax.vue)",
26
26
  "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff HEAD app/components/parallax/SectionParallax.vue)",
27
- "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)"
27
+ "Bash(git -C /Users/simoncornforth/websites/nuxt-components diff --cached)",
28
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/components/02.molecules/samaritan-prompt)",
29
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/composables/tests/useCancellableTimer app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed)",
30
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/components/02.molecules/samaritan-prompt/tests/SamaritanPromptMixed)",
31
+ "Bash(SRCDEV_STANDALONE=true npx vitest --run app/composables/tests/useCancellableTimer)",
32
+ "Bash(SRCDEV_STANDALONE=true npm run test:run -- --reporter=verbose)"
28
33
  ],
29
34
  "additionalDirectories": [
30
35
  "/Users/simoncornforth/websites/nuxt-components/app/components/01.atoms/content-wrappers/content-width",
@@ -182,6 +182,38 @@ await nextTick();
182
182
  vi.runAllTimers();
183
183
  ```
184
184
 
185
+ ## `mountSuspended` flushes component async setup — initial state may be past the first `await`
186
+
187
+ `mountSuspended` calls `flushPromises()` internally, draining all pending microtasks including `await nextTick()` calls inside the component's async functions. If a component's async lifecycle starts with `await nextTick()` (e.g. to let a CSS `v-bind` update before beginning a transition), **the component will have already executed past it by the time `mountSuspended` returns**.
188
+
189
+ Assert the post-flush state, not the state before the first await:
190
+
191
+ ```ts
192
+ // Component's runEffect() starts with: await nextTick(); opacity.value = 0;
193
+ // ❌ — by the time mountSuspended returns, opacity is already 0
194
+ const wrapper = await mountSuspended(MyComponent, { props });
195
+ expect(wrapper.find(".content").attributes("style")).toContain("opacity: 1");
196
+
197
+ // ✅ — assert the state that exists after mountSuspended's flush
198
+ const wrapper = await mountSuspended(MyComponent, { props });
199
+ expect(wrapper.find(".content").attributes("style")).toContain("opacity: 0");
200
+ ```
201
+
202
+ ## `.then().catch()` requires two microtask hops; use `.then(onFulfilled, onRejected)` for one
203
+
204
+ When asserting Promise settlement with a single `await Promise.resolve()`, put both handlers in the two-argument form of `.then()`. Chaining `.then().catch()` creates an intermediate Promise that adds a second hop, so `settled` won't be `true` after only one drain:
205
+
206
+ ```ts
207
+ // ❌ needs two await Promise.resolve() to settle
208
+ promise.then(() => { settled = true; }).catch(() => { settled = true; });
209
+
210
+ // ✅ settles after one await Promise.resolve()
211
+ promise.then(
212
+ () => { settled = true; },
213
+ () => { settled = true; },
214
+ );
215
+ ```
216
+
185
217
  ## Hyphenated prop attributes in tests
186
218
 
187
219
  When a component uses a hyphenated Vue prop like `:tab-index` or `:aria-label`, Vue renders it as the literal hyphenated DOM attribute. Assert with the hyphenated form — not the camelCase equivalent:
@@ -0,0 +1,231 @@
1
+ <template>
2
+ <div :class="['samaritan-prompt', elementClasses]">
3
+ <div class="samaritan-prompt__content" :style="effect === 'word-pulse' ? { opacity: textOpacity } : undefined">
4
+ <div class="samaritan-prompt__stage">
5
+ <span class="samaritan-prompt__text">{{ displayText }}</span>
6
+ </div>
7
+ <div class="samaritan-prompt__underline"></div>
8
+ </div>
9
+ <span class="samaritan-prompt__cursor" :style="{ opacity: cursorOpacity }" aria-hidden="true">▲</span>
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ interface Props {
15
+ messages: string[];
16
+ effect?: "typewriter" | "word-pulse";
17
+ typeSpeed?: number;
18
+ deleteSpeed?: number;
19
+ holdDuration?: number;
20
+ pauseDuration?: number;
21
+ wordDuration?: number;
22
+ fadeDuration?: number;
23
+ hideCursorInCycle?: boolean;
24
+ styleClassPassthrough?: string | string[];
25
+ }
26
+
27
+ const props = withDefaults(defineProps<Props>(), {
28
+ effect: "typewriter",
29
+ typeSpeed: 80,
30
+ deleteSpeed: 40,
31
+ holdDuration: 2000,
32
+ pauseDuration: 500,
33
+ wordDuration: 1200,
34
+ fadeDuration: 400,
35
+ hideCursorInCycle: true,
36
+ styleClassPassthrough: () => [],
37
+ });
38
+
39
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
40
+
41
+ const displayText = ref("");
42
+ const textOpacity = ref(1);
43
+ const cursorVisible = ref(true);
44
+ const fadeDurationCss = computed(() => `${props.fadeDuration}ms`);
45
+ const cursorOpacity = computed(() => (props.hideCursorInCycle && !cursorVisible.value ? 0 : 1));
46
+
47
+ const { wait, schedule, stop, start } = useCancellableTimer();
48
+
49
+ const startEffect = () => {
50
+ start();
51
+ displayText.value = "";
52
+ textOpacity.value = 1;
53
+ cursorVisible.value = true;
54
+ phase.value = "typing";
55
+ messageIndex.value = 0;
56
+ if (props.effect === "typewriter") {
57
+ schedule(typeTick, props.typeSpeed);
58
+ } else {
59
+ runWordPulse();
60
+ }
61
+ };
62
+
63
+ // --- Typewriter ---
64
+ type Phase = "typing" | "holding" | "deleting" | "pausing";
65
+ const phase = ref<Phase>("typing");
66
+ const messageIndex = ref(0);
67
+
68
+ const typeTick = () => {
69
+ const message = props.messages[messageIndex.value];
70
+ if (!message) return;
71
+
72
+ switch (phase.value) {
73
+ case "typing":
74
+ if (displayText.value.length === 0 && props.hideCursorInCycle) {
75
+ cursorVisible.value = false;
76
+ }
77
+ if (displayText.value.length < message.length) {
78
+ displayText.value = message.slice(0, displayText.value.length + 1);
79
+ schedule(typeTick, props.typeSpeed);
80
+ } else {
81
+ phase.value = "holding";
82
+ schedule(typeTick, props.holdDuration);
83
+ }
84
+ break;
85
+
86
+ case "holding":
87
+ phase.value = "deleting";
88
+ schedule(typeTick, props.deleteSpeed);
89
+ break;
90
+
91
+ case "deleting":
92
+ if (displayText.value.length > 0) {
93
+ displayText.value = displayText.value.slice(0, -1);
94
+ schedule(typeTick, props.deleteSpeed);
95
+ } else {
96
+ phase.value = "pausing";
97
+ if (props.hideCursorInCycle) cursorVisible.value = true;
98
+ schedule(typeTick, props.pauseDuration);
99
+ }
100
+ break;
101
+
102
+ case "pausing":
103
+ messageIndex.value = (messageIndex.value + 1) % props.messages.length;
104
+ phase.value = "typing";
105
+ schedule(typeTick, props.typeSpeed);
106
+ break;
107
+ }
108
+ };
109
+
110
+ // --- Word pulse ---
111
+ const runWordPulse = async () => {
112
+ try {
113
+ while (true) {
114
+ // Underline and cursor visible during the pre-cycle pause
115
+ await wait(props.pauseDuration);
116
+
117
+ if (props.hideCursorInCycle) cursorVisible.value = false;
118
+
119
+ // Fade out the underline before the first word appears
120
+ textOpacity.value = 0;
121
+ await wait(props.fadeDuration);
122
+
123
+ for (const message of props.messages) {
124
+ displayText.value = message;
125
+ await nextTick();
126
+ await wait(120);
127
+
128
+ textOpacity.value = 1;
129
+ await wait(props.wordDuration);
130
+
131
+ textOpacity.value = 0;
132
+ await wait(props.fadeDuration);
133
+ }
134
+
135
+ // Reset between cycles — underline and cursor visible again for next pause
136
+ displayText.value = "";
137
+ textOpacity.value = 1;
138
+ if (props.hideCursorInCycle) cursorVisible.value = true;
139
+ await nextTick();
140
+ }
141
+ } catch {
142
+ // component unmounted — exit cleanly
143
+ }
144
+ };
145
+
146
+ watch(
147
+ () => props.effect,
148
+ () => {
149
+ stop();
150
+ startEffect();
151
+ }
152
+ );
153
+
154
+ onMounted(startEffect);
155
+
156
+ onUnmounted(stop);
157
+ </script>
158
+
159
+ <style lang="css">
160
+ @font-face {
161
+ font-family: "Mono MMM 5";
162
+ src: url("/fonts/monoMMM_5.ttf") format("truetype");
163
+ font-weight: normal;
164
+ font-style: normal;
165
+ font-display: swap;
166
+ }
167
+
168
+ .samaritan-prompt {
169
+ --_font-size: var(--samaritan-font-size, 2rem);
170
+ --_color-text: var(--samaritan-color-text, #ffffff);
171
+ --_color-underline: var(--samaritan-color-underline, #ffffff);
172
+ --_color-cursor: var(--samaritan-color-cursor, #cc0000);
173
+ --_color-cursor-off: var(--samaritan-color-cursor-off, transparent);
174
+ --_font-family: var(--samaritan-font-family, "Mono MMM 5", "Nova Mono", "Courier New", monospace);
175
+ --_letter-spacing: var(--samaritan-letter-spacing, 0.08em);
176
+
177
+ display: flex;
178
+ flex-direction: column;
179
+ align-items: center;
180
+ row-gap: 0.6rem;
181
+ font-family: var(--_font-family);
182
+ font-size: var(--_font-size);
183
+ letter-spacing: var(--_letter-spacing);
184
+
185
+ .samaritan-prompt__content {
186
+ display: flex;
187
+ flex-direction: column;
188
+ align-items: center;
189
+ row-gap: 0.6rem;
190
+ width: 100%;
191
+ transition: opacity v-bind(fadeDurationCss) ease;
192
+
193
+ .samaritan-prompt__stage {
194
+ display: flex;
195
+ justify-content: center;
196
+ min-height: 1.2em;
197
+
198
+ .samaritan-prompt__text {
199
+ color: var(--_color-text);
200
+ white-space: nowrap;
201
+ text-transform: uppercase;
202
+ }
203
+ }
204
+
205
+ .samaritan-prompt__underline {
206
+ width: 100%;
207
+ min-width: 4ch;
208
+ height: 0.15rem;
209
+ background: var(--_color-underline);
210
+ }
211
+ }
212
+
213
+ .samaritan-prompt__cursor {
214
+ color: var(--_color-cursor);
215
+ font-size: 2.4rem;
216
+ line-height: 1;
217
+ animation: samaritan-pulse 2.5s ease-in-out infinite;
218
+ transition: opacity 400ms ease;
219
+ }
220
+ }
221
+
222
+ @keyframes samaritan-pulse {
223
+ 0%,
224
+ 100% {
225
+ color: var(--_color-cursor);
226
+ }
227
+ 50% {
228
+ color: var(--_color-cursor-off);
229
+ }
230
+ }
231
+ </style>
@@ -0,0 +1,235 @@
1
+ <template>
2
+ <div :class="['samaritan-prompt', elementClasses]">
3
+ <div class="samaritan-prompt__content" :style="{ opacity: textOpacity }">
4
+ <div class="samaritan-prompt__stage">
5
+ <span class="samaritan-prompt__text">{{ displayText }}</span>
6
+ </div>
7
+ <div class="samaritan-prompt__underline"></div>
8
+ </div>
9
+ <span class="samaritan-prompt__cursor" :style="{ opacity: cursorOpacity }" aria-hidden="true">▲</span>
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ export interface MessageConfig {
15
+ text: string;
16
+ effect?: "typewriter" | "word-pulse";
17
+ typeSpeed?: number;
18
+ deleteSpeed?: number;
19
+ holdDuration?: number;
20
+ pauseDuration?: number;
21
+ wordDuration?: number;
22
+ fadeDuration?: number;
23
+ hideCursorInCycle?: boolean;
24
+ }
25
+
26
+ interface Props {
27
+ messageConfigs: MessageConfig[];
28
+ effect?: "typewriter" | "word-pulse";
29
+ typeSpeed?: number;
30
+ deleteSpeed?: number;
31
+ holdDuration?: number;
32
+ pauseDuration?: number;
33
+ wordDuration?: number;
34
+ fadeDuration?: number;
35
+ introDelay?: number;
36
+ hideCursorInCycle?: boolean;
37
+ styleClassPassthrough?: string | string[];
38
+ }
39
+
40
+ const props = withDefaults(defineProps<Props>(), {
41
+ effect: "typewriter",
42
+ typeSpeed: 80,
43
+ deleteSpeed: 40,
44
+ holdDuration: 7000,
45
+ pauseDuration: 1000,
46
+ wordDuration: 1200,
47
+ fadeDuration: 400,
48
+ introDelay: 2000,
49
+ hideCursorInCycle: true,
50
+ styleClassPassthrough: () => [],
51
+ });
52
+
53
+ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
54
+
55
+ const displayText = ref("");
56
+ const textOpacity = ref(1);
57
+ const cursorVisible = ref(true);
58
+ const activeFadeDuration = ref(props.fadeDuration);
59
+ const fadeDurationCss = computed(() => `${activeFadeDuration.value}ms`);
60
+ const cursorOpacity = computed(() => (cursorVisible.value ? 1 : 0));
61
+
62
+ const { wait, stop, start } = useCancellableTimer();
63
+
64
+ type ResolvedConfig = Required<MessageConfig>;
65
+
66
+ const resolveConfig = (msg: MessageConfig): ResolvedConfig => ({
67
+ text: msg.text,
68
+ effect: msg.effect ?? props.effect,
69
+ typeSpeed: msg.typeSpeed ?? props.typeSpeed,
70
+ deleteSpeed: msg.deleteSpeed ?? props.deleteSpeed,
71
+ holdDuration: msg.holdDuration ?? props.holdDuration,
72
+ pauseDuration: msg.pauseDuration ?? props.pauseDuration,
73
+ wordDuration: msg.wordDuration ?? props.wordDuration,
74
+ fadeDuration: msg.fadeDuration ?? props.fadeDuration,
75
+ hideCursorInCycle: msg.hideCursorInCycle ?? props.hideCursorInCycle,
76
+ });
77
+
78
+ const runTypewriter = async (config: ResolvedConfig) => {
79
+ const { text, typeSpeed, deleteSpeed, holdDuration, pauseDuration, hideCursorInCycle } = config;
80
+
81
+ if (hideCursorInCycle) cursorVisible.value = false;
82
+
83
+ for (let i = 1; i <= text.length; i++) {
84
+ displayText.value = text.slice(0, i);
85
+ await wait(typeSpeed);
86
+ }
87
+
88
+ await wait(holdDuration);
89
+
90
+ while (displayText.value.length > 0) {
91
+ displayText.value = displayText.value.slice(0, -1);
92
+ await wait(deleteSpeed);
93
+ }
94
+
95
+ if (hideCursorInCycle) cursorVisible.value = true;
96
+ await wait(pauseDuration);
97
+ };
98
+
99
+ const runWordPulse = async (config: ResolvedConfig) => {
100
+ const { text, fadeDuration, wordDuration, pauseDuration, hideCursorInCycle } = config;
101
+
102
+ activeFadeDuration.value = fadeDuration;
103
+ await nextTick();
104
+
105
+ if (hideCursorInCycle) cursorVisible.value = false;
106
+
107
+ textOpacity.value = 0;
108
+ await wait(fadeDuration);
109
+
110
+ displayText.value = text;
111
+ await nextTick();
112
+ await wait(120);
113
+
114
+ textOpacity.value = 1;
115
+ await wait(wordDuration);
116
+
117
+ textOpacity.value = 0;
118
+ await wait(fadeDuration);
119
+
120
+ displayText.value = "";
121
+ textOpacity.value = 1;
122
+ if (hideCursorInCycle) cursorVisible.value = true;
123
+ await nextTick();
124
+
125
+ await wait(pauseDuration);
126
+ };
127
+
128
+ const runLoop = async () => {
129
+ try {
130
+ while (true) {
131
+ if (props.introDelay > 0) await wait(props.introDelay);
132
+
133
+ // if (props.hideCursorInCycle) cursorVisible.value = false;
134
+
135
+ for (const msg of props.messageConfigs) {
136
+ const config = resolveConfig(msg);
137
+ if (config.effect === "typewriter") {
138
+ await runTypewriter(config);
139
+ } else {
140
+ await runWordPulse(config);
141
+ }
142
+ }
143
+
144
+ // if (props.hideCursorInCycle) cursorVisible.value = true;
145
+ }
146
+ } catch {
147
+ // component unmounted — exit cleanly
148
+ }
149
+ };
150
+
151
+ const startLoop = () => {
152
+ start();
153
+ displayText.value = "";
154
+ textOpacity.value = 1;
155
+ cursorVisible.value = true;
156
+ runLoop();
157
+ };
158
+
159
+ onMounted(startLoop);
160
+ onUnmounted(stop);
161
+ </script>
162
+
163
+ <style lang="css">
164
+ @font-face {
165
+ font-family: "Mono MMM 5";
166
+ src: url("/fonts/monoMMM_5.ttf") format("truetype");
167
+ font-weight: normal;
168
+ font-style: normal;
169
+ font-display: swap;
170
+ }
171
+
172
+ .samaritan-prompt {
173
+ --_font-size: var(--samaritan-font-size, 2rem);
174
+ --_color-text: var(--samaritan-color-text, #ffffff);
175
+ --_color-underline: var(--samaritan-color-underline, #ffffff);
176
+ --_color-cursor: var(--samaritan-color-cursor, #cc0000);
177
+ --_color-cursor-off: var(--samaritan-color-cursor-off, transparent);
178
+ --_font-family: var(--samaritan-font-family, "Mono MMM 5", "Nova Mono", "Courier New", monospace);
179
+ --_letter-spacing: var(--samaritan-letter-spacing, 0.08em);
180
+
181
+ display: flex;
182
+ flex-direction: column;
183
+ align-items: center;
184
+ row-gap: 0.6rem;
185
+ font-family: var(--_font-family);
186
+ font-size: var(--_font-size);
187
+ letter-spacing: var(--_letter-spacing);
188
+
189
+ .samaritan-prompt__content {
190
+ display: flex;
191
+ flex-direction: column;
192
+ align-items: center;
193
+ row-gap: 0.6rem;
194
+ width: 100%;
195
+ transition: opacity v-bind(fadeDurationCss) ease;
196
+
197
+ .samaritan-prompt__stage {
198
+ display: flex;
199
+ justify-content: center;
200
+ min-height: 1.2em;
201
+
202
+ .samaritan-prompt__text {
203
+ color: var(--_color-text);
204
+ white-space: nowrap;
205
+ text-transform: uppercase;
206
+ }
207
+ }
208
+
209
+ .samaritan-prompt__underline {
210
+ width: 100%;
211
+ min-width: 4ch;
212
+ height: 0.15rem;
213
+ background: var(--_color-underline);
214
+ }
215
+ }
216
+
217
+ .samaritan-prompt__cursor {
218
+ color: var(--_color-cursor);
219
+ font-size: 2.4rem;
220
+ line-height: 1;
221
+ animation: samaritan-pulse 2.5s ease-in-out infinite;
222
+ transition: opacity 400ms ease;
223
+ }
224
+ }
225
+
226
+ @keyframes samaritan-pulse {
227
+ 0%,
228
+ 100% {
229
+ color: var(--_color-cursor);
230
+ }
231
+ 50% {
232
+ color: var(--_color-cursor-off);
233
+ }
234
+ }
235
+ </style>