@slidev-polls/shared 0.0.1

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.
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ label: string;
4
+ invalid?: boolean;
5
+ mono?: boolean;
6
+ }>();
7
+ defineEmits<{ remove: [] }>();
8
+ </script>
9
+
10
+ <template>
11
+ <span class="sp-chip" :data-invalid="invalid || undefined" :data-mono="mono || undefined">
12
+ <slot name="leading" />
13
+ <span class="sp-chip__label">{{ label }}</span>
14
+ <button class="sp-chip__remove" :aria-label="`Remove ${label}`" @click="$emit('remove')">
15
+ ×
16
+ </button>
17
+ </span>
18
+ </template>
19
+
20
+ <style scoped>
21
+ .sp-chip {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ gap: 6px;
25
+ padding: 4px 4px 4px 10px;
26
+ background: var(--sp-bg-muted);
27
+ border: 1px solid var(--sp-border);
28
+ border-radius: var(--sp-radius-sm);
29
+ font-size: 12px;
30
+ font-family: var(--sp-font-sans);
31
+ color: var(--sp-fg);
32
+ }
33
+ .sp-chip[data-mono] {
34
+ font-family: var(--sp-font-mono);
35
+ }
36
+ .sp-chip[data-invalid] {
37
+ background: var(--sp-danger-bg);
38
+ border-color: var(--sp-danger);
39
+ color: var(--sp-danger-fg);
40
+ }
41
+ .sp-chip__remove {
42
+ border: 0;
43
+ background: transparent;
44
+ color: var(--sp-fg-faint);
45
+ font-size: 14px;
46
+ line-height: 1;
47
+ padding: 2px 6px;
48
+ cursor: pointer;
49
+ }
50
+ .sp-chip[data-invalid] .sp-chip__remove {
51
+ color: var(--sp-danger-fg);
52
+ }
53
+ .sp-chip__remove:focus-visible {
54
+ outline: 2px solid var(--sp-accent-ring);
55
+ border-radius: 4px;
56
+ }
57
+ </style>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <svg
3
+ width="14"
4
+ height="14"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2"
9
+ aria-hidden="true"
10
+ >
11
+ <circle cx="12" cy="12" r="10" />
12
+ <line x1="12" y1="8" x2="12" y2="12" />
13
+ <line x1="12" y1="16" x2="12.01" y2="16" />
14
+ </svg>
15
+ </template>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <svg
3
+ width="14"
4
+ height="14"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2.5"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ aria-hidden="true"
12
+ >
13
+ <path d="M20 6L9 17l-5-5" />
14
+ </svg>
15
+ </template>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <svg
3
+ width="12"
4
+ height="12"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2.5"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ aria-hidden="true"
12
+ >
13
+ <polyline points="6 9 12 15 18 9" />
14
+ </svg>
15
+ </template>
@@ -0,0 +1,16 @@
1
+ <template>
2
+ <svg
3
+ width="12"
4
+ height="12"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2.5"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ aria-hidden="true"
12
+ >
13
+ <line x1="18" y1="6" x2="6" y2="18" />
14
+ <line x1="6" y1="6" x2="18" y2="18" />
15
+ </svg>
16
+ </template>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <svg
3
+ width="16"
4
+ height="16"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ aria-hidden="true"
12
+ >
13
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
14
+ </svg>
15
+ </template>
@@ -0,0 +1,18 @@
1
+ <template>
2
+ <svg
3
+ width="16"
4
+ height="16"
5
+ viewBox="0 0 24 24"
6
+ fill="none"
7
+ stroke="currentColor"
8
+ stroke-width="2"
9
+ stroke-linecap="round"
10
+ stroke-linejoin="round"
11
+ aria-hidden="true"
12
+ >
13
+ <circle cx="12" cy="12" r="4" />
14
+ <path
15
+ d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
16
+ />
17
+ </svg>
18
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ modelValue?: string;
4
+ placeholder?: string;
5
+ type?: string;
6
+ invalid?: boolean;
7
+ }>();
8
+ defineEmits<{ "update:modelValue": [v: string] }>();
9
+ </script>
10
+
11
+ <template>
12
+ <input
13
+ class="sp-input"
14
+ :data-invalid="invalid || undefined"
15
+ :type="type ?? 'text'"
16
+ :value="modelValue"
17
+ :placeholder="placeholder"
18
+ @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
19
+ />
20
+ </template>
21
+
22
+ <style scoped>
23
+ .sp-input {
24
+ width: 100%;
25
+ box-sizing: border-box;
26
+ padding: 10px 12px;
27
+ font-family: var(--sp-font-sans);
28
+ font-size: 13px;
29
+ border: 1px solid var(--sp-border);
30
+ border-radius: var(--sp-radius);
31
+ background: var(--sp-bg);
32
+ color: var(--sp-fg);
33
+ }
34
+ .sp-input::placeholder {
35
+ color: var(--sp-fg-faint);
36
+ }
37
+ .sp-input:focus {
38
+ outline: 2px solid var(--sp-accent-ring);
39
+ outline-offset: 0;
40
+ border-color: var(--sp-accent);
41
+ }
42
+ .sp-input[data-invalid] {
43
+ border-color: var(--sp-danger);
44
+ }
45
+ </style>
@@ -0,0 +1,31 @@
1
+ <template>
2
+ <span class="sp-livedot" aria-hidden="true" />
3
+ </template>
4
+
5
+ <style scoped>
6
+ .sp-livedot {
7
+ display: inline-block;
8
+ width: 6px;
9
+ height: 6px;
10
+ border-radius: 50%;
11
+ background: var(--sp-success);
12
+ box-shadow: 0 0 0 0 var(--sp-success);
13
+ animation: sp-pulse 2s var(--sp-ease) infinite;
14
+ }
15
+ @keyframes sp-pulse {
16
+ 0% {
17
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--sp-success) 60%, transparent);
18
+ }
19
+ 70% {
20
+ box-shadow: 0 0 0 6px transparent;
21
+ }
22
+ 100% {
23
+ box-shadow: 0 0 0 0 transparent;
24
+ }
25
+ }
26
+ @media (prefers-reduced-motion: reduce) {
27
+ .sp-livedot {
28
+ animation: none;
29
+ }
30
+ }
31
+ </style>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ tone?: "neutral" | "success" | "danger";
4
+ withDot?: boolean;
5
+ }>();
6
+ </script>
7
+
8
+ <template>
9
+ <span class="sp-pill" :data-tone="tone ?? 'neutral'">
10
+ <span v-if="withDot" class="sp-pill__dot" />
11
+ <slot />
12
+ </span>
13
+ </template>
14
+
15
+ <style scoped>
16
+ .sp-pill {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ gap: 5px;
20
+ padding: 2px 8px;
21
+ border-radius: 999px;
22
+ font-size: 11px;
23
+ font-weight: 500;
24
+ font-family: var(--sp-font-sans);
25
+ }
26
+ .sp-pill[data-tone="neutral"] {
27
+ background: var(--sp-bg-subtle);
28
+ color: var(--sp-fg-muted);
29
+ }
30
+ .sp-pill[data-tone="success"] {
31
+ background: var(--sp-success-bg);
32
+ color: var(--sp-success-fg);
33
+ }
34
+ .sp-pill[data-tone="danger"] {
35
+ background: var(--sp-danger-bg);
36
+ color: var(--sp-danger-fg);
37
+ }
38
+ .sp-pill__dot {
39
+ width: 5px;
40
+ height: 5px;
41
+ border-radius: 50%;
42
+ background: currentColor;
43
+ }
44
+ .sp-pill[data-tone="success"] .sp-pill__dot {
45
+ background: var(--sp-success);
46
+ }
47
+ </style>
@@ -0,0 +1,223 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import LiveDot from "./LiveDot.vue";
4
+
5
+ interface OptionLite {
6
+ id: string;
7
+ label: string;
8
+ }
9
+ interface TallyLite {
10
+ optionId: string;
11
+ count: number;
12
+ }
13
+ interface QuestionLite {
14
+ prompt: string;
15
+ options: OptionLite[];
16
+ tally: TallyLite[];
17
+ }
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ question: QuestionLite;
22
+ mode?: "flat" | "scrim-dark" | "scrim-light";
23
+ showLive?: boolean;
24
+ }>(),
25
+ { mode: "flat", showLive: true }
26
+ );
27
+
28
+ const total = computed(() => props.question.tally.reduce((s, t) => s + t.count, 0));
29
+
30
+ const leaderId = computed(() => {
31
+ if (total.value === 0) return null;
32
+ let best: { id: string; count: number } | null = null;
33
+ let tied = false;
34
+ for (const o of props.question.options) {
35
+ const c = props.question.tally.find((t) => t.optionId === o.id)?.count ?? 0;
36
+ if (!best || c > best.count) {
37
+ best = { id: o.id, count: c };
38
+ tied = false;
39
+ } else if (c === best.count) {
40
+ tied = true;
41
+ }
42
+ }
43
+ // No leader on a tie — the bright accent on a single bar would imply that
44
+ // option is winning when it actually isn't.
45
+ if (tied) return null;
46
+ return best?.id ?? null;
47
+ });
48
+
49
+ function countOf(id: string): number {
50
+ return props.question.tally.find((t) => t.optionId === id)?.count ?? 0;
51
+ }
52
+
53
+ function pctOf(id: string): number {
54
+ if (total.value === 0) return 0;
55
+ return Math.round((countOf(id) / total.value) * 100);
56
+ }
57
+ </script>
58
+
59
+ <template>
60
+ <section class="sp-rp" :data-mode="mode" data-testid="results-panel">
61
+ <header class="sp-rp__head">
62
+ <h3 class="sp-rp__prompt">{{ question.prompt }}</h3>
63
+ <div class="sp-rp__meta">
64
+ <LiveDot v-if="showLive" />
65
+ <span>{{ total }} {{ total === 1 ? "vote" : "votes" }}</span>
66
+ </div>
67
+ </header>
68
+ <ol class="sp-rp__rows" :aria-live="showLive ? 'polite' : 'off'">
69
+ <li
70
+ v-for="opt in question.options"
71
+ :key="opt.id"
72
+ class="sp-rp__row"
73
+ :data-leader="leaderId === opt.id ? '' : undefined"
74
+ :data-empty="total === 0 ? '' : undefined"
75
+ data-testid="rp-row"
76
+ :data-option-id="opt.id"
77
+ >
78
+ <span class="sp-rp__fill" :style="{ width: pctOf(opt.id) + '%' }" />
79
+ <span class="sp-rp__label">{{ opt.label }}</span>
80
+ <span class="sp-rp__count" aria-hidden="true">{{ countOf(opt.id) }}</span>
81
+ <span class="sp-rp__pct">{{ total === 0 ? "—" : pctOf(opt.id) + "%" }}</span>
82
+ </li>
83
+ </ol>
84
+ </section>
85
+ </template>
86
+
87
+ <style scoped>
88
+ .sp-rp {
89
+ font-family: var(--sp-font-sans);
90
+ background: var(--sp-bg);
91
+ color: var(--sp-fg);
92
+ border-radius: var(--sp-radius-xl);
93
+ padding: 24px 28px;
94
+ width: 100%;
95
+ box-sizing: border-box;
96
+ }
97
+ .sp-rp[data-mode="scrim-dark"] {
98
+ background: rgba(10, 10, 10, 0.55);
99
+ backdrop-filter: blur(12px);
100
+ -webkit-backdrop-filter: blur(12px);
101
+ color: #fafafa;
102
+ }
103
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
104
+ .sp-rp[data-mode="scrim-dark"] {
105
+ background: rgba(10, 10, 10, 0.78);
106
+ box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.5);
107
+ }
108
+ }
109
+ .sp-rp[data-mode="scrim-light"] {
110
+ background: rgba(255, 255, 255, 0.7);
111
+ backdrop-filter: blur(14px) saturate(140%);
112
+ -webkit-backdrop-filter: blur(14px) saturate(140%);
113
+ border: 1px solid rgba(255, 255, 255, 0.5);
114
+ color: #0a0a0a;
115
+ }
116
+
117
+ .sp-rp__head {
118
+ display: flex;
119
+ justify-content: space-between;
120
+ align-items: baseline;
121
+ margin-bottom: 14px;
122
+ }
123
+ .sp-rp__prompt {
124
+ font-size: 18px;
125
+ font-weight: 600;
126
+ letter-spacing: -0.01em;
127
+ margin: 0;
128
+ }
129
+ .sp-rp__meta {
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 6px;
133
+ font-size: 11px;
134
+ color: inherit;
135
+ opacity: 0.75;
136
+ }
137
+
138
+ .sp-rp__rows {
139
+ list-style: none;
140
+ margin: 0;
141
+ padding: 0;
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 8px;
145
+ }
146
+
147
+ .sp-rp__row {
148
+ position: relative;
149
+ overflow: hidden;
150
+ border-radius: var(--sp-radius-lg);
151
+ background: var(--sp-bg-subtle);
152
+ display: grid;
153
+ grid-template-columns: 1fr auto;
154
+ align-items: center;
155
+ padding: 12px 16px;
156
+ font-size: 14px;
157
+ }
158
+ .sp-rp[data-mode="scrim-dark"] .sp-rp__row {
159
+ background: rgba(255, 255, 255, 0.08);
160
+ }
161
+ .sp-rp[data-mode="scrim-light"] .sp-rp__row {
162
+ background: rgba(0, 0, 0, 0.06);
163
+ }
164
+
165
+ .sp-rp__row[data-empty] {
166
+ background: transparent;
167
+ border: 1px solid var(--sp-border);
168
+ }
169
+ .sp-rp[data-mode="scrim-dark"] .sp-rp__row[data-empty] {
170
+ border-color: rgba(255, 255, 255, 0.16);
171
+ }
172
+
173
+ .sp-rp__fill {
174
+ position: absolute;
175
+ inset: 0;
176
+ width: 0;
177
+ background: var(--sp-accent-soft);
178
+ transition: width var(--sp-dur) var(--sp-ease);
179
+ }
180
+ .sp-rp[data-mode="scrim-dark"] .sp-rp__fill {
181
+ background: rgba(255, 255, 255, 0.18);
182
+ }
183
+ .sp-rp[data-mode="scrim-light"] .sp-rp__fill {
184
+ background: rgba(0, 0, 0, 0.12);
185
+ }
186
+
187
+ .sp-rp__row[data-leader] .sp-rp__fill {
188
+ background: var(--sp-accent);
189
+ }
190
+ .sp-rp__row[data-leader] {
191
+ color: var(--sp-accent-fg);
192
+ }
193
+ .sp-rp[data-mode="scrim-dark"] .sp-rp__row[data-leader] {
194
+ color: #fff;
195
+ }
196
+ .sp-rp[data-mode="scrim-light"] .sp-rp__row[data-leader] {
197
+ color: #fff;
198
+ }
199
+ .sp-rp__row[data-leader] .sp-rp__label,
200
+ .sp-rp__row[data-leader] .sp-rp__pct {
201
+ font-weight: 600;
202
+ }
203
+
204
+ .sp-rp__label,
205
+ .sp-rp__pct,
206
+ .sp-rp__count {
207
+ position: relative;
208
+ }
209
+ .sp-rp__pct {
210
+ font-variant-numeric: tabular-nums;
211
+ }
212
+ .sp-rp__count {
213
+ position: absolute;
214
+ width: 1px;
215
+ height: 1px;
216
+ padding: 0;
217
+ margin: -1px;
218
+ overflow: hidden;
219
+ clip: rect(0, 0, 0, 0);
220
+ white-space: nowrap;
221
+ border: 0;
222
+ }
223
+ </style>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ modelValue?: string;
4
+ placeholder?: string;
5
+ maxlength?: number;
6
+ }>();
7
+ defineEmits<{ "update:modelValue": [v: string] }>();
8
+ </script>
9
+
10
+ <template>
11
+ <div class="sp-textarea-wrap">
12
+ <textarea
13
+ class="sp-textarea"
14
+ :value="modelValue"
15
+ :placeholder="placeholder"
16
+ :maxlength="maxlength"
17
+ @input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
18
+ />
19
+ <div v-if="maxlength" class="sp-textarea-count">
20
+ {{ (modelValue ?? "").length }} / {{ maxlength }}
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <style scoped>
26
+ .sp-textarea-wrap {
27
+ display: flex;
28
+ flex-direction: column;
29
+ gap: 6px;
30
+ }
31
+ .sp-textarea {
32
+ width: 100%;
33
+ box-sizing: border-box;
34
+ font-family: var(--sp-font-sans);
35
+ font-size: 14px;
36
+ padding: 12px 14px;
37
+ border: 1px solid var(--sp-border);
38
+ border-radius: var(--sp-radius);
39
+ background: var(--sp-bg);
40
+ color: var(--sp-fg);
41
+ line-height: 1.5;
42
+ resize: none;
43
+ min-height: 120px;
44
+ }
45
+ .sp-textarea:focus {
46
+ outline: 2px solid var(--sp-accent-ring);
47
+ border-color: var(--sp-accent);
48
+ }
49
+ .sp-textarea-count {
50
+ font-size: 11px;
51
+ color: var(--sp-fg-faint);
52
+ text-align: right;
53
+ font-variant-numeric: tabular-nums;
54
+ }
55
+ </style>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { useTheme } from "./useTheme";
4
+ import IconSun from "./IconSun.vue";
5
+ import IconMoon from "./IconMoon.vue";
6
+
7
+ const t = useTheme();
8
+ const ariaLabel = computed(() =>
9
+ t.mode.value === "dark" ? "Switch to light mode" : "Switch to dark mode"
10
+ );
11
+ </script>
12
+
13
+ <template>
14
+ <button class="sp-themetoggle" :aria-label="ariaLabel" @click="t.toggle">
15
+ <IconMoon v-if="t.mode.value === 'light'" />
16
+ <IconSun v-else />
17
+ </button>
18
+ </template>
19
+
20
+ <style scoped>
21
+ .sp-themetoggle {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ width: 32px;
26
+ height: 32px;
27
+ background: transparent;
28
+ border: 1px solid var(--sp-border);
29
+ border-radius: var(--sp-radius);
30
+ cursor: pointer;
31
+ color: var(--sp-fg-muted);
32
+ }
33
+ .sp-themetoggle:hover {
34
+ background: var(--sp-bg-muted);
35
+ color: var(--sp-fg);
36
+ }
37
+ .sp-themetoggle:focus-visible {
38
+ outline: 2px solid var(--sp-accent-ring);
39
+ outline-offset: 2px;
40
+ }
41
+ </style>
@@ -0,0 +1,17 @@
1
+ export { default as Button } from "./Button.vue";
2
+ export { default as Input } from "./Input.vue";
3
+ export { default as Textarea } from "./Textarea.vue";
4
+ export { default as Pill } from "./Pill.vue";
5
+ export { default as Chip } from "./Chip.vue";
6
+ export { default as LiveDot } from "./LiveDot.vue";
7
+ export { default as ThemeToggle } from "./ThemeToggle.vue";
8
+ export { default as ResultsPanel } from "./ResultsPanel.vue";
9
+ export { default as AllowedOriginsField } from "./AllowedOriginsField.vue";
10
+ export { default as IconCheck } from "./IconCheck.vue";
11
+ export { default as IconAlert } from "./IconAlert.vue";
12
+ export { default as IconSun } from "./IconSun.vue";
13
+ export { default as IconMoon } from "./IconMoon.vue";
14
+ export { default as IconChevronDown } from "./IconChevronDown.vue";
15
+ export { default as IconClose } from "./IconClose.vue";
16
+ export { useTheme } from "./useTheme";
17
+ export { validateOrigin, type OriginValidation } from "./origin-validator";
@@ -0,0 +1,28 @@
1
+ export type OriginValidation = { ok: true; normalized: string } | { ok: false; message: string };
2
+
3
+ export function validateOrigin(input: string): OriginValidation {
4
+ const v = input.trim();
5
+ if (v.length === 0) return { ok: false, message: "Origin is empty" };
6
+ if (v === "*") return { ok: true, normalized: "*" };
7
+ let url: URL;
8
+ try {
9
+ url = new URL(v);
10
+ } catch {
11
+ // If the input already has an http/https scheme but still fails to parse,
12
+ // it's missing a valid host rather than a scheme.
13
+ if (/^https?:\/\//i.test(v)) {
14
+ return { ok: false, message: "Origin needs a host" };
15
+ }
16
+ return {
17
+ ok: false,
18
+ message: "Origin needs a scheme (e.g. https://example.com)"
19
+ };
20
+ }
21
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
22
+ return { ok: false, message: "Origin scheme must be http or https" };
23
+ }
24
+ if (!url.host) {
25
+ return { ok: false, message: "Origin needs a host" };
26
+ }
27
+ return { ok: true, normalized: `${url.protocol}//${url.host}` };
28
+ }