@hyvor/design 2.1.7 → 2.1.8-beta.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.
@@ -111,7 +111,7 @@
111
111
  {/snippet}
112
112
  </ActionListItem>
113
113
  </a>
114
- <a href={SOCIAL_LINKS.blueksy} target="_blank">
114
+ <a href={SOCIAL_LINKS.bluesky} target="_blank">
115
115
  <ActionListItem>
116
116
  Bluesky
117
117
  {#snippet start()}
@@ -28,7 +28,7 @@
28
28
  position="bottom"
29
29
  text="Switch Organization"
30
30
  disabled={disableTooltip}
31
- wrapperProps={{ style: 'height: 100%' }}
31
+ style="height: 100%"
32
32
  >
33
33
  <button class="wrap" onclick={() => (show = true)} bind:this={trigger}>
34
34
  <div class="data">
@@ -1,43 +1,22 @@
1
+ <!-- @migration-task Error while migrating Svelte code: This migration would change the name of a slot making the component unusable -->
1
2
  <script lang="ts">
2
- import { tick, onMount } from 'svelte';
3
+ import { tick, onMount, onDestroy } from 'svelte';
3
4
  import { fade } from 'svelte/transition';
4
- import type { Snippet } from 'svelte';
5
- import type { HTMLAttributes } from 'svelte/elements';
6
-
7
- interface Props {
8
- text?: string;
9
- position?: 'top' | 'bottom' | 'left' | 'right';
10
- color?: 'black' | 'accent' | 'soft' | 'danger';
11
- show?: boolean;
12
- maxWidth?: number;
13
- disabled?: boolean;
14
- delay?: number;
15
- children?: Snippet;
16
- tooltip?: Snippet;
17
- wrapperProps?: HTMLAttributes<HTMLDivElement>;
18
- }
19
5
 
20
- let {
21
- text = '',
22
- position = 'top',
23
- color = 'black',
24
- show = $bindable(false),
25
- maxWidth = 300,
26
- disabled = false,
27
- delay = 100,
28
- children,
29
- tooltip,
30
- wrapperProps = {}
31
- }: Props = $props();
32
-
33
- let wrap: HTMLDivElement | undefined = $state();
34
- let tooltipEl: HTMLDivElement | undefined = $state();
35
- let showTimeout: ReturnType<typeof setTimeout>;
6
+ export let text: string = '';
7
+ export let position: 'top' | 'bottom' | 'left' | 'right' = 'top';
8
+ export let color: 'black' | 'accent' | 'soft' | 'danger' = 'black';
9
+ export let show = false;
10
+ export let maxWidth: number = 300;
11
+ export let disabled = false;
12
+
13
+ let wrap: HTMLDivElement;
14
+ let tooltip: HTMLDivElement;
36
15
 
37
16
  function positionTooltip() {
38
- if (wrap && tooltipEl && show) {
17
+ if (wrap && tooltip && show) {
39
18
  const wrapRect = wrap.getBoundingClientRect();
40
- const tooltipRect = tooltipEl.getBoundingClientRect();
19
+ const tooltipRect = tooltip.getBoundingClientRect();
41
20
 
42
21
  let top, left;
43
22
 
@@ -57,41 +36,36 @@
57
36
  left = position === 'left' ? leftVal : wrapRect.right + 10;
58
37
  }
59
38
 
60
- tooltipEl.style.top = top + 'px';
61
- tooltipEl.style.left = left + 'px';
39
+ tooltip.style.top = top + 'px';
40
+ tooltip.style.left = left + 'px';
62
41
  }
63
42
  }
64
43
 
65
- function handleMouseEnter() {
44
+ async function handleMouseEnter() {
66
45
  if (!disabled) {
67
- clearTimeout(showTimeout);
68
- showTimeout = setTimeout(async () => {
69
- show = true;
70
- await tick();
71
- positionTooltip();
72
- }, delay);
46
+ show = true;
47
+ await tick();
48
+ positionTooltip();
73
49
  }
74
50
  }
75
51
 
76
52
  function handleMouseLeave() {
77
- clearTimeout(showTimeout);
78
53
  show = false;
79
54
  }
80
55
 
81
- $effect(() => {
56
+ $: {
82
57
  if (text) {
83
58
  tick().then(() => {
84
59
  positionTooltip();
85
60
  });
86
61
  }
87
- });
62
+ }
88
63
 
89
64
  onMount(() => {
90
65
  positionTooltip();
91
66
  document.addEventListener('scroll', positionTooltip);
92
67
 
93
68
  return () => {
94
- clearTimeout(showTimeout);
95
69
  document.removeEventListener('scroll', positionTooltip);
96
70
  };
97
71
  });
@@ -99,23 +73,23 @@
99
73
 
100
74
  <div
101
75
  class="tooltip-wrap {color}"
102
- onmouseenter={handleMouseEnter}
103
- onmouseleave={handleMouseLeave}
76
+ on:mouseenter={handleMouseEnter}
77
+ on:mouseleave={handleMouseLeave}
104
78
  role="tooltip"
105
79
  bind:this={wrap}
106
- {...wrapperProps}
80
+ {...$$restProps}
107
81
  >
108
- {@render children?.()}
82
+ <slot />
109
83
 
110
84
  {#if !disabled && show}
111
85
  <div
112
86
  class="tooltip {position}"
113
87
  style:max-width={maxWidth + 'px'}
114
- bind:this={tooltipEl}
88
+ bind:this={tooltip}
115
89
  transition:fade={{ duration: 100 }}
116
90
  >
117
- {#if tooltip}
118
- {@render tooltip()}
91
+ {#if $$slots.tooltip}
92
+ <slot name="tooltip" />
119
93
  {:else}
120
94
  {text}
121
95
  {/if}
@@ -1,17 +1,37 @@
1
- import type { Snippet } from 'svelte';
2
- import type { HTMLAttributes } from 'svelte/elements';
3
- interface Props {
4
- text?: string;
5
- position?: 'top' | 'bottom' | 'left' | 'right';
6
- color?: 'black' | 'accent' | 'soft' | 'danger';
7
- show?: boolean;
8
- maxWidth?: number;
9
- disabled?: boolean;
10
- delay?: number;
11
- children?: Snippet;
12
- tooltip?: Snippet;
13
- wrapperProps?: HTMLAttributes<HTMLDivElement>;
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: Props & {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
14
13
  }
15
- declare const Tooltip: import("svelte").Component<Props, {}, "show">;
16
- type Tooltip = ReturnType<typeof Tooltip>;
14
+ type $$__sveltets_2_PropsWithChildren<Props, Slots> = Props & (Slots extends {
15
+ default: any;
16
+ } ? Props extends Record<string, never> ? any : {
17
+ children?: any;
18
+ } : {});
19
+ declare const Tooltip: $$__sveltets_2_IsomorphicComponent<$$__sveltets_2_PropsWithChildren<{
20
+ [x: string]: any;
21
+ text?: string | undefined;
22
+ position?: "top" | "bottom" | "left" | "right" | undefined;
23
+ color?: "black" | "accent" | "soft" | "danger" | undefined;
24
+ show?: boolean | undefined;
25
+ maxWidth?: number | undefined;
26
+ disabled?: boolean | undefined;
27
+ }, {
28
+ default: {};
29
+ tooltip: {};
30
+ }>, {
31
+ [evt: string]: CustomEvent<any>;
32
+ }, {
33
+ default: {};
34
+ tooltip: {};
35
+ }, {}, string>;
36
+ type Tooltip = InstanceType<typeof Tooltip>;
17
37
  export default Tooltip;
@@ -1,17 +1,18 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import Container from '../Container/Container.svelte';
4
+ import IconEnvelope from '@hyvor/icons/IconEnvelope';
3
5
  import IconCopy from '@hyvor/icons/IconCopy';
4
6
  import IconDiscord from '@hyvor/icons/IconDiscord';
5
- import IconEnvelope from '@hyvor/icons/IconEnvelope';
6
7
  import IconGithub from '@hyvor/icons/IconGithub';
7
8
  import IconLinkedin from '@hyvor/icons/IconLinkedin';
8
9
  import IconTwitterX from '@hyvor/icons/IconTwitterX';
9
10
  import IconYoutube from '@hyvor/icons/IconYoutube';
10
- import Link from '../../components/Link/Link.svelte';
11
+ import IconBluesky from '@hyvor/icons/IconBluesky';
12
+ import IconLockFill from '@hyvor/icons/IconLockFill';
11
13
  import IconButton from '../../components/IconButton/IconButton.svelte';
12
14
  import Tooltip from '../../components/Tooltip/Tooltip.svelte';
13
15
  import LanguageToggle from '../../components/Internationalization/LanguageToggle.svelte';
14
- import IconBluesky from '@hyvor/icons/IconBluesky';
15
16
  import { SOCIAL_LINKS, type Socials } from '../social.js';
16
17
  import Affiliate from '../Affiliate/Affiliate.svelte';
17
18
  import RecordVisit from './RecordVisit.svelte';
@@ -19,104 +20,207 @@
19
20
  const year = new Date().getFullYear();
20
21
 
21
22
  interface Props {
23
+ name?: string;
24
+ subname?: string;
25
+ product?: string;
26
+ instance?: string;
27
+ logo?: string;
28
+ background?: string;
29
+ backgroundDark?: string;
30
+ card?: boolean;
22
31
  email?: string | null;
23
32
  social?: Partial<Socials>;
24
- center?: import('svelte').Snippet;
33
+ languageToggle?: boolean;
34
+ gdpr?: boolean;
25
35
  affiliate?: boolean;
26
36
  recordVisit?: boolean;
37
+ center?: Snippet;
27
38
  max?: boolean;
28
39
  }
29
40
 
30
41
  let {
42
+ name = 'HYVOR',
43
+ subname,
44
+ product,
45
+ instance = 'https://hyvor.com',
46
+ logo,
47
+ background,
48
+ backgroundDark,
49
+ card = false,
31
50
  email = null,
32
- social = $bindable({} as Record<string, string | null>),
33
- center,
51
+ social = {},
52
+ languageToggle = true,
53
+ gdpr = true,
34
54
  affiliate = true,
35
55
  recordVisit = true,
56
+ center,
36
57
  max = false
37
58
  }: Props = $props();
38
59
 
39
- social = {
40
- ...SOCIAL_LINKS,
41
- ...social
42
- };
60
+ // same "explicit logo, else derive from product" pattern as Header — pass
61
+ // `product` (e.g. "blogs") for the mascot to follow that product's logo
62
+ // automatically, or `logo` to override it directly. Neither given means
63
+ // no mascot.
64
+ const mascotLogo = $derived(
65
+ logo || (product ? `${instance}/api/public/logo/${product}.svg` : undefined)
66
+ );
43
67
 
44
- let emailCopied = $state(false);
68
+ const socialLinks = $derived({ ...SOCIAL_LINKS, ...social });
45
69
 
46
- function handleCopy() {
47
- navigator.clipboard.writeText(email!);
70
+ const SOCIAL_PLATFORMS = [
71
+ { key: 'x', icon: IconTwitterX, label: 'X (Twitter)' },
72
+ { key: 'github', icon: IconGithub, label: 'GitHub' },
73
+ { key: 'discord', icon: IconDiscord, label: 'Discord' },
74
+ { key: 'linkedin', icon: IconLinkedin, label: 'LinkedIn' },
75
+ { key: 'youtube', icon: IconYoutube, label: 'YouTube' },
76
+ { key: 'bluesky', icon: IconBluesky, label: 'Bluesky' }
77
+ ] as const satisfies { key: keyof Socials; icon: unknown; label: string }[];
78
+
79
+ let emailCopied = $state(false);
80
+ function handleCopyEmail() {
81
+ if (!email) return;
82
+ navigator.clipboard.writeText(email);
48
83
  emailCopied = true;
49
- setTimeout(() => {
50
- emailCopied = false;
51
- }, 1000);
84
+ setTimeout(() => (emailCopied = false), 1000);
85
+ }
86
+
87
+ // the floating mascot (only shown when a `logo` is given) tilts/fades in
88
+ // once it's scrolled into view, instead of just appearing on page load
89
+ let mascotInView = $state(false);
90
+ function onView(node: HTMLElement, callback: () => void) {
91
+ const observer = new IntersectionObserver(
92
+ (entries) => {
93
+ if (entries[0]?.isIntersecting) {
94
+ setTimeout(callback, 400);
95
+ observer.disconnect();
96
+ }
97
+ },
98
+ { threshold: 0.3 }
99
+ );
100
+ observer.observe(node);
101
+ return {
102
+ destroy() {
103
+ observer.disconnect();
104
+ }
105
+ };
52
106
  }
107
+
108
+ // a single 5-pointed star (outer/inner vertices alternating), centered on
109
+ // its own origin — reused via `transform="translate(...)"` per position
110
+ // below, rather than a plain dot, for the EU ring on the GDPR badge
111
+ function starPath(outerR: number, innerR: number) {
112
+ const points: string[] = [];
113
+ for (let i = 0; i < 10; i++) {
114
+ const angle = -Math.PI / 2 + (i * Math.PI) / 5;
115
+ const r = i % 2 === 0 ? outerR : innerR;
116
+ points.push(`${(r * Math.cos(angle)).toFixed(2)},${(r * Math.sin(angle)).toFixed(2)}`);
117
+ }
118
+ return `M${points.join('L')}Z`;
119
+ }
120
+ const gdprStarPath = starPath(1.7, 0.68);
121
+
122
+ // positions for the 12-star EU ring on the small GDPR badge
123
+ const gdprStars = Array.from({ length: 12 }, (_, i) => {
124
+ const angle = (i * 30 * Math.PI) / 180;
125
+ return {
126
+ x: 16 + 12 * Math.cos(angle),
127
+ y: 16 + 12 * Math.sin(angle)
128
+ };
129
+ });
53
130
  </script>
54
131
 
55
- <footer>
56
- <Container {max}>
57
- <div class="footer-top">
58
- <div class="footer-top-left">
59
- {#if email}
60
- <div class="email-wrap">
61
- <Link href="mailto:{email}" underline={false} color="text" rel="nofollow">
62
- {#snippet start()}
63
- <IconEnvelope />
64
- {/snippet}
65
- {email}
66
- </Link>
67
- <Tooltip text={emailCopied ? 'Copied!' : 'Copy email'} position="top">
68
- <IconButton
69
- size="small"
70
- variant="invisible"
71
- on:click={handleCopy}
72
- on:mouseleave={() => (emailCopied = false)}
73
- >
74
- <IconCopy size={12} />
75
- </IconButton>
76
- </Tooltip>
77
- </div>
78
- {/if}
132
+ <div class="footer-outer">
133
+ {#if mascotLogo}
134
+ <div class="mascot-wrap" use:onView={() => (mascotInView = true)}>
135
+ <img
136
+ src={mascotLogo}
137
+ alt="{name} Logo"
138
+ width="100"
139
+ height="100"
140
+ class:in-view={mascotInView}
141
+ />
142
+ </div>
143
+ {/if}
79
144
 
80
- <div class="social-media">
81
- {#if social.github}
82
- <a href={social.github} target="_blank" rel="nofollow"><IconGithub /></a>
83
- {/if}
84
- {#if social.discord}
85
- <a href={social.discord} target="_blank" rel="nofollow"><IconDiscord /></a>
86
- {/if}
87
- {#if social.blueksy}
88
- <a href={social.blueksy} target="_blank" rel="nofollow"><IconBluesky /></a>
89
- {/if}
90
- {#if social.x}
91
- <a href={social.x} target="_blank" rel="nofollow"><IconTwitterX /></a>
145
+ <footer class:card style:--footer-bg={background} style:--footer-bg-dark={backgroundDark}>
146
+ <Container {max}>
147
+ <div class="top-row">
148
+ <div class="brand">
149
+ <span>{name}</span>
150
+ {#if subname}
151
+ <div class="subname">{subname}</div>
92
152
  {/if}
93
- {#if social.youtube}
94
- <a href={social.youtube} target="_blank" rel="nofollow"><IconYoutube /></a>
153
+ </div>
154
+
155
+ <div class="top-row-right">
156
+ {#if email}
157
+ <div class="email-wrap">
158
+ <a class="email" href="mailto:{email}">
159
+ <IconEnvelope size={14} />
160
+ {email}
161
+ </a>
162
+ <Tooltip text={emailCopied ? 'Copied!' : 'Copy email'} position="top">
163
+ <IconButton
164
+ size="small"
165
+ variant="invisible"
166
+ onclick={handleCopyEmail}
167
+ onmouseleave={() => (emailCopied = false)}
168
+ >
169
+ <IconCopy size={12} />
170
+ </IconButton>
171
+ </Tooltip>
172
+ </div>
95
173
  {/if}
96
- {#if social.linkedin}
97
- <a href={social.linkedin} target="_blank" rel="nofollow"><IconLinkedin /></a>
174
+
175
+ <div class="socials">
176
+ {#each SOCIAL_PLATFORMS as platform (platform.key)}
177
+ {@const href = socialLinks[platform.key]}
178
+ {#if href}
179
+ <a {href} target="_blank" rel="nofollow" aria-label={platform.label}>
180
+ <platform.icon size={16} />
181
+ </a>
182
+ {/if}
183
+ {/each}
184
+ </div>
185
+
186
+ {#if languageToggle}
187
+ <span class="language-toggle-wrap">
188
+ <LanguageToggle align="end" position="top" staticPage />
189
+ </span>
98
190
  {/if}
99
191
  </div>
100
192
  </div>
101
193
 
102
- <div class="footer-top-right">
103
- <span class="language-toggle-wrap">
104
- <LanguageToggle align="end" position="top" staticPage />
105
- </span>
194
+ <div class="footer-center">
195
+ {@render center?.()}
106
196
  </div>
107
- </div>
108
197
 
109
- <div class="footer-center">
110
- {@render center?.()}
111
- </div>
198
+ <div class="bottom-bar">
199
+ <div>HYVOR &copy; {year}</div>
200
+
201
+ {#if gdpr}
202
+ <div class="bottom-center">
203
+ <a class="gdpr-chip" href="https://hyvor.com/compliance" target="_blank">
204
+ <span class="gdpr-chip-icon">
205
+ <svg class="ring" viewBox="0 0 32 32" aria-hidden="true">
206
+ <circle cx="16" cy="16" r="16" fill="#173a8a" />
207
+ {#each gdprStars as s}
208
+ <path d={gdprStarPath} fill="#ffcd3c" transform="translate({s.x}, {s.y})" />
209
+ {/each}
210
+ </svg>
211
+ <span class="lock"><IconLockFill size={10} /></span>
212
+ </span>
213
+ <span class="gdpr-chip-text">GDPR Compliant</span>
214
+ </a>
215
+ </div>
216
+ {/if}
112
217
 
113
- <div class="footer-bottom">
114
- <div class="footer-bottom-left">
115
- HYVOR © {year}
218
+ <div class="bottom-right">
219
+ <div class="france">From France <span class="flag">🇫🇷</span></div>
220
+ </div>
116
221
  </div>
117
- <div class="footer-bottom-right">From France 🇫🇷</div>
118
- </div>
119
- </Container>
222
+ </Container>
223
+ </footer>
120
224
 
121
225
  {#if affiliate}
122
226
  <Affiliate />
@@ -125,57 +229,252 @@
125
229
  {#if recordVisit}
126
230
  <RecordVisit />
127
231
  {/if}
128
- </footer>
232
+ </div>
129
233
 
130
234
  <style>
131
- footer {
132
- border-top: 1px solid var(--border);
235
+ .footer-outer {
236
+ position: relative;
237
+ margin-top: 100px;
133
238
  }
134
239
 
135
- .footer-top {
136
- padding-top: 60px;
240
+ .mascot-wrap {
241
+ position: absolute;
242
+ z-index: 10;
137
243
  display: flex;
138
- align-items: flex-start;
244
+ justify-content: center;
245
+ pointer-events: none;
246
+ left: 0;
247
+ bottom: 100%;
248
+ transform: translate(-35%, 45%) rotate(20deg);
249
+ }
250
+
251
+ .mascot-wrap img {
252
+ opacity: 0;
253
+ }
254
+
255
+ .mascot-wrap img.in-view {
256
+ animation: mascot-tilt-in 0.8s ease forwards;
257
+ }
258
+
259
+ @keyframes mascot-tilt-in {
260
+ 0% {
261
+ opacity: 0;
262
+ transform: rotate(-14deg) scale(0.9) translateY(16px);
263
+ }
264
+ 55% {
265
+ opacity: 1;
266
+ transform: rotate(10deg) scale(1.06) translateY(-4px);
267
+ }
268
+ 100% {
269
+ opacity: 1;
270
+ transform: rotate(0deg) scale(1) translateY(0);
271
+ }
139
272
  }
140
- .footer-center {
273
+
274
+ @media (prefers-reduced-motion: reduce) {
275
+ .mascot-wrap img {
276
+ opacity: 1;
277
+ }
278
+ .mascot-wrap img.in-view {
279
+ animation: none;
280
+ }
281
+ }
282
+
283
+ footer {
284
+ --footer-text: var(--text);
285
+ --footer-text-strong: var(--text);
286
+ --footer-muted: var(--text-light);
287
+ --footer-border: var(--border);
288
+ --footer-hover: var(--accent);
289
+
290
+ position: relative;
291
+ z-index: 1;
292
+ background: var(--footer-bg, transparent);
293
+ color: var(--footer-text);
141
294
  padding-top: 50px;
142
295
  }
143
- .footer-top-left {
144
- flex: 1;
296
+
297
+ /*
298
+ `backgroundDark` only when needed for dark mode - not complusory
299
+ */
300
+ :global(:root.dark) footer {
301
+ background: var(--footer-bg-dark, var(--footer-bg, transparent));
302
+ }
303
+
304
+ footer.card {
305
+ --footer-text: rgba(255, 255, 255, 0.75);
306
+ --footer-text-strong: #fff;
307
+ --footer-muted: rgba(255, 255, 255, 0.45);
308
+ --footer-border: rgba(255, 255, 255, 0.08);
309
+ --footer-hover: #fff;
310
+ }
311
+
312
+ .top-row {
313
+ display: flex;
314
+ align-items: center;
315
+ justify-content: space-between;
316
+ flex-wrap: wrap;
317
+ gap: 20px;
318
+ padding-bottom: 40px;
319
+ border-bottom: 1px solid var(--footer-border);
320
+ }
321
+
322
+ .brand {
323
+ font-size: 16px;
324
+ font-weight: 700;
325
+ color: var(--footer-text-strong);
326
+ }
327
+
328
+ .subname {
329
+ font-size: 13px;
330
+ font-weight: 400;
331
+ color: var(--footer-muted);
145
332
  }
146
- .footer-top-right {
333
+
334
+ .top-row-right {
147
335
  display: flex;
336
+ align-items: center;
337
+ gap: 24px;
338
+ flex-wrap: wrap;
148
339
  }
340
+
149
341
  .email-wrap {
150
342
  display: inline-flex;
151
343
  align-items: center;
152
- gap: 4px;
153
- margin-bottom: 15px;
344
+ gap: 2px;
154
345
  }
155
- .social-media a {
156
- display: inline-block;
157
- padding: 3px;
158
- margin-right: 8px;
159
- color: var(--text-light);
346
+
347
+ .email {
348
+ display: inline-flex;
349
+ align-items: center;
350
+ gap: 6px;
351
+ font-size: 13px;
352
+ color: var(--footer-text);
160
353
  }
161
- .social-media a:first-child {
162
- padding-left: 0;
354
+
355
+ .email:hover {
356
+ color: var(--footer-hover);
163
357
  }
164
- .social-media a:hover {
165
- color: var(--accent);
358
+
359
+ .socials {
360
+ display: flex;
361
+ align-items: center;
362
+ gap: 14px;
166
363
  }
167
364
 
168
- .footer-bottom {
169
- padding-top: 50px;
170
- padding-bottom: 30px;
365
+ .socials a {
366
+ color: var(--footer-text);
171
367
  display: flex;
172
368
  }
173
- .footer-bottom-left {
174
- flex: 1;
369
+
370
+ .socials a:hover {
371
+ color: var(--footer-hover);
175
372
  }
176
373
 
177
374
  .language-toggle-wrap {
178
- margin-left: 8px;
179
375
  font-size: 18px;
180
376
  }
377
+
378
+ .footer-center:not(:empty) {
379
+ padding: 48px 0;
380
+ }
381
+
382
+ /* only if there is no center element - to prevent double border apperance */
383
+ .footer-center:not(:empty) + .bottom-bar {
384
+ border-top: 1px solid var(--footer-border);
385
+ }
386
+
387
+ .bottom-bar {
388
+ display: flex;
389
+ align-items: center;
390
+ justify-content: space-between;
391
+ flex-wrap: wrap;
392
+ gap: 10px;
393
+ padding: 24px 0 32px;
394
+ font-size: 13px;
395
+ color: var(--footer-muted);
396
+ }
397
+
398
+ .bottom-right {
399
+ display: flex;
400
+ align-items: center;
401
+ gap: 16px;
402
+ flex-wrap: wrap;
403
+ }
404
+
405
+ .france {
406
+ display: inline-flex;
407
+ align-items: center;
408
+ gap: 6px;
409
+ }
410
+
411
+ .gdpr-chip {
412
+ display: inline-flex;
413
+ align-items: center;
414
+ gap: 8px;
415
+ padding: 5px 12px 5px 5px;
416
+ border-radius: 100px;
417
+ background: #1f2f57;
418
+ color: #fff;
419
+ opacity: 0.9;
420
+ transition: opacity 0.15s ease;
421
+ }
422
+
423
+ .gdpr-chip:hover {
424
+ opacity: 1;
425
+ }
426
+
427
+ .gdpr-chip-icon {
428
+ position: relative;
429
+ width: 22px;
430
+ height: 22px;
431
+ flex-shrink: 0;
432
+ }
433
+
434
+ .gdpr-chip-icon .ring {
435
+ position: absolute;
436
+ inset: 0;
437
+ width: 100%;
438
+ height: 100%;
439
+ display: block;
440
+ }
441
+
442
+ .gdpr-chip-icon .lock {
443
+ position: absolute;
444
+ inset: 0;
445
+ display: flex;
446
+ align-items: center;
447
+ justify-content: center;
448
+ color: #fff;
449
+ }
450
+
451
+ .gdpr-chip-text {
452
+ font-size: 11px;
453
+ font-weight: 800;
454
+ letter-spacing: 0.02em;
455
+ color: #fff;
456
+ }
457
+
458
+ /* emoji glyphs are pre-colored — never let the surrounding muted text
459
+ color dim or filter them */
460
+ .flag {
461
+ color: initial;
462
+ filter: none;
463
+ }
464
+
465
+ @media (max-width: 560px) {
466
+ .mascot-wrap {
467
+ /* keep it clear of the brand wordmark that sits right below it */
468
+ margin-bottom: -40px;
469
+ }
470
+
471
+ footer {
472
+ padding-top: 130px;
473
+ }
474
+
475
+ .top-row {
476
+ flex-direction: column;
477
+ align-items: flex-start;
478
+ }
479
+ }
181
480
  </style>
@@ -1,12 +1,23 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import { type Socials } from '../social.js';
2
3
  interface Props {
4
+ name?: string;
5
+ subname?: string;
6
+ product?: string;
7
+ instance?: string;
8
+ logo?: string;
9
+ background?: string;
10
+ backgroundDark?: string;
11
+ card?: boolean;
3
12
  email?: string | null;
4
13
  social?: Partial<Socials>;
5
- center?: import('svelte').Snippet;
14
+ languageToggle?: boolean;
15
+ gdpr?: boolean;
6
16
  affiliate?: boolean;
7
17
  recordVisit?: boolean;
18
+ center?: Snippet;
8
19
  max?: boolean;
9
20
  }
10
- declare const Footer: import("svelte").Component<Props, {}, "social">;
21
+ declare const Footer: import("svelte").Component<Props, {}, "">;
11
22
  type Footer = ReturnType<typeof Footer>;
12
23
  export default Footer;
@@ -17,12 +17,16 @@
17
17
  <style>
18
18
  .link-list {
19
19
  flex: 1;
20
+ min-width: 150px;
20
21
  }
21
22
 
22
23
  .title {
23
- font-weight: 600;
24
+ font-size: 12px;
25
+ font-weight: 700;
24
26
  text-transform: uppercase;
25
- margin-bottom: 10px;
27
+ letter-spacing: 0.05em;
28
+ color: var(--footer-muted, var(--text-light));
29
+ margin-bottom: 14px;
26
30
  }
27
31
 
28
32
  .links {
@@ -32,9 +36,12 @@
32
36
  }
33
37
 
34
38
  .links :global(a) {
35
- margin-top: 12px;
39
+ font-size: 14px;
40
+ color: var(--footer-text, var(--text-light));
41
+ margin-top: 10px;
36
42
  }
37
43
  .links :global(a:hover) {
44
+ color: var(--footer-text-strong, var(--text));
38
45
  text-decoration: underline;
39
46
  }
40
47
  </style>
@@ -1,9 +1,12 @@
1
1
  <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import type { Snippet } from 'svelte';
2
4
  import Container from './../Container/Container.svelte';
3
5
  import DarkToggle from '../../components/Dark/DarkToggle.svelte';
4
6
  import IconButton from '../../components/IconButton/IconButton.svelte';
5
7
  import Dropdown from '../../components/Dropdown/Dropdown.svelte';
6
8
  import IconList from '@hyvor/icons/IconList';
9
+ import IconX from '@hyvor/icons/IconX';
7
10
  import HeaderNotification from './HeaderNotification.svelte';
8
11
 
9
12
  interface Props {
@@ -14,8 +17,8 @@
14
17
  href?: string;
15
18
  subName?: undefined | string;
16
19
  darkToggle?: boolean;
17
- center?: import('svelte').Snippet;
18
- end?: import('svelte').Snippet;
20
+ center?: Snippet;
21
+ end?: Snippet;
19
22
  max?: boolean;
20
23
  }
21
24
 
@@ -31,9 +34,27 @@
31
34
  end,
32
35
  max = false
33
36
  }: Props = $props();
37
+
38
+ let scrolled = $state(false);
39
+ let mobileOpen = $state(false);
40
+
41
+ onMount(() => {
42
+ const onScroll = () => (scrolled = window.scrollY > 8);
43
+ onScroll();
44
+ window.addEventListener('scroll', onScroll, { passive: true });
45
+ return () => window.removeEventListener('scroll', onScroll);
46
+ });
47
+
48
+ // close the mobile menu once a link inside it is clicked
49
+ function closeOnLinkClick(e: MouseEvent) {
50
+ const target = e.target as HTMLElement;
51
+ if (target.tagName === 'A' || target.closest('a')) {
52
+ mobileOpen = false;
53
+ }
54
+ }
34
55
  </script>
35
56
 
36
- <header>
57
+ <header class:scrolled>
37
58
  <HeaderNotification {instance} {product} />
38
59
  <Container as="nav" {max}>
39
60
  <div class="nav-start">
@@ -41,8 +62,8 @@
41
62
  <img
42
63
  src={logo || `${instance}/api/public/logo/${product}.svg`}
43
64
  alt="{name + (subName ? ' ' + subName : '')} Logo"
44
- width="30"
45
- height="30"
65
+ width="26"
66
+ height="26"
46
67
  />
47
68
  <span class="brand-product">
48
69
  <span class="brand">{name}</span>
@@ -70,20 +91,30 @@
70
91
  </div>
71
92
 
72
93
  <span class="mobile-nav-wrap">
73
- <Dropdown align="end" width={300}>
94
+ <Dropdown bind:show={mobileOpen} align="end" width={300} contentPadding={8}>
74
95
  {#snippet trigger()}
75
- <IconButton variant="invisible">
76
- <IconList size={18} />
96
+ <IconButton variant="invisible" aria-label="Menu">
97
+ {#if mobileOpen}
98
+ <IconX size={18} />
99
+ {:else}
100
+ <IconList size={18} />
101
+ {/if}
77
102
  </IconButton>
78
103
  {/snippet}
79
104
  {#snippet content()}
80
- <div class="mobile-content">
81
- <div class="mobile-inner center">
82
- {@render center?.()}
83
- </div>
84
- <div class="mobile-inner end">
85
- {@render end?.()}
86
- </div>
105
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
106
+ <div class="mobile-content" onclick={closeOnLinkClick}>
107
+ {#if center}
108
+ <div class="mobile-inner">
109
+ {@render center()}
110
+ </div>
111
+ {/if}
112
+ {#if end}
113
+ <div class="mobile-divider"></div>
114
+ <div class="mobile-inner end">
115
+ {@render end()}
116
+ </div>
117
+ {/if}
87
118
  </div>
88
119
  {/snippet}
89
120
  </Dropdown>
@@ -105,10 +136,15 @@
105
136
  width: 100%;
106
137
  z-index: 100;
107
138
  background-color: var(--background, var(--accent-lightest));
108
- border-bottom: 1px solid var(--border);
139
+ border-bottom: 1px solid transparent;
109
140
  height: var(--header-height);
110
141
  display: flex;
111
142
  flex-direction: column;
143
+ transition: border-color 0.2s ease;
144
+ }
145
+
146
+ header.scrolled {
147
+ border-bottom-color: var(--border);
112
148
  }
113
149
 
114
150
  header :global(> nav) {
@@ -117,7 +153,9 @@
117
153
  flex: 1;
118
154
  }
119
155
  .nav-brand {
120
- display: inline-block;
156
+ display: inline-flex;
157
+ align-items: center;
158
+ gap: 8px;
121
159
  line-height: inherit;
122
160
  white-space: nowrap;
123
161
  color: inherit;
@@ -125,11 +163,10 @@
125
163
  font-weight: 600;
126
164
  }
127
165
  .nav-brand img {
128
- vertical-align: middle;
166
+ display: block;
129
167
  }
130
168
 
131
169
  .brand-product {
132
- vertical-align: middle;
133
170
  display: inline-flex;
134
171
  flex-direction: column;
135
172
  justify-content: center;
@@ -148,7 +185,7 @@
148
185
  flex: 1;
149
186
  display: flex;
150
187
  align-items: center;
151
- gap: 6px;
188
+ gap: 2px;
152
189
  justify-content: center;
153
190
  }
154
191
 
@@ -192,18 +229,21 @@
192
229
  .mobile-inner {
193
230
  display: flex;
194
231
  flex-direction: column;
195
- }
196
- .mobile-content {
197
- gap: 10px;
232
+ gap: 2px;
198
233
  }
199
234
 
200
235
  .mobile-content :global(.button) {
201
236
  display: flex;
202
237
  }
203
238
 
204
- /*
205
- Scroll padding top is used to prevent the content from being hidden behind the header
206
- https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-padding-top
239
+ .mobile-divider {
240
+ height: 1px;
241
+ background: var(--border);
242
+ margin: 6px 4px;
243
+ }
244
+
245
+ /*
246
+ used to prevent the content from being hidden behind the header
207
247
  */
208
248
  :global(html) {
209
249
  scroll-padding-top: calc(var(--header-height) + 20px);
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  interface Props {
2
3
  product: string;
3
4
  instance?: string;
@@ -6,8 +7,8 @@ interface Props {
6
7
  href?: string;
7
8
  subName?: undefined | string;
8
9
  darkToggle?: boolean;
9
- center?: import('svelte').Snippet;
10
- end?: import('svelte').Snippet;
10
+ center?: Snippet;
11
+ end?: Snippet;
11
12
  max?: boolean;
12
13
  }
13
14
  declare const Header: import("svelte").Component<Props, {}, "">;
@@ -0,0 +1,148 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ interface Props {
5
+ href?: string;
6
+ active?: boolean;
7
+ menu?: boolean;
8
+ target?: string;
9
+ rel?: string;
10
+ start?: Snippet;
11
+ children?: Snippet;
12
+ end?: Snippet;
13
+ description?: Snippet;
14
+ onclick?: (event: MouseEvent) => void;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ let {
19
+ href,
20
+ active = false,
21
+ menu = false,
22
+ target,
23
+ rel,
24
+ start,
25
+ children,
26
+ end,
27
+ description,
28
+ onclick,
29
+ ...rest
30
+ }: Props = $props();
31
+ </script>
32
+
33
+ <svelte:element
34
+ this={href ? 'a' : 'span'}
35
+ {href}
36
+ {target}
37
+ {rel}
38
+ {onclick}
39
+ role={href ? undefined : 'button'}
40
+ tabindex={href ? undefined : 0}
41
+ class="header-nav-link"
42
+ class:active
43
+ class:menu={menu || !!description}
44
+ class:rich={!!description}
45
+ {...rest}
46
+ >
47
+ {#if description}
48
+ {#if start}
49
+ <span class="icon-box">{@render start()}</span>
50
+ {/if}
51
+ <span class="text-stack">
52
+ <span class="title">{@render children?.()}</span>
53
+ <span class="desc">{@render description()}</span>
54
+ </span>
55
+ {:else}
56
+ {#if start}
57
+ <span class="start">{@render start()}</span>
58
+ {/if}
59
+ {@render children?.()}
60
+ {#if end}
61
+ <span class="end">{@render end()}</span>
62
+ {/if}
63
+ {/if}
64
+ </svelte:element>
65
+
66
+ <style>
67
+ .header-nav-link {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ gap: 6px;
71
+ padding: 6px 16px;
72
+ border-radius: 20px;
73
+ font-size: 13px;
74
+ font-weight: 500;
75
+ color: var(--text-light);
76
+ text-decoration: none;
77
+ white-space: nowrap;
78
+ cursor: pointer;
79
+ user-select: none;
80
+ transition:
81
+ 0.15s background-color,
82
+ 0.15s color;
83
+ }
84
+
85
+ .header-nav-link:hover {
86
+ background: var(--hover, var(--accent-lightest));
87
+ color: var(--text);
88
+ }
89
+
90
+ .header-nav-link.active {
91
+ background: var(--accent-light);
92
+ color: var(--text);
93
+ }
94
+
95
+ .header-nav-link.menu {
96
+ width: 100%;
97
+ padding: 8px 10px;
98
+ border-radius: 12px;
99
+ }
100
+
101
+ .start,
102
+ .end {
103
+ display: inline-flex;
104
+ align-items: center;
105
+ }
106
+
107
+ /* icon + title + description layout, see `description` above */
108
+ .header-nav-link.rich {
109
+ align-items: flex-start;
110
+ gap: 10px;
111
+ white-space: normal;
112
+ }
113
+
114
+ .icon-box {
115
+ flex: none;
116
+ display: inline-flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ width: 30px;
120
+ height: 30px;
121
+ border-radius: 8px;
122
+ background: var(--accent-light);
123
+ color: var(--accent);
124
+ }
125
+
126
+ .header-nav-link.rich.active .icon-box {
127
+ background: var(--background);
128
+ }
129
+
130
+ .text-stack {
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 1px;
134
+ padding-top: 1px;
135
+ }
136
+
137
+ .title {
138
+ font-size: 13px;
139
+ font-weight: 600;
140
+ color: var(--text);
141
+ }
142
+
143
+ .desc {
144
+ font-size: 12px;
145
+ font-weight: 400;
146
+ color: var(--text-light);
147
+ }
148
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ href?: string;
4
+ active?: boolean;
5
+ menu?: boolean;
6
+ target?: string;
7
+ rel?: string;
8
+ start?: Snippet;
9
+ children?: Snippet;
10
+ end?: Snippet;
11
+ description?: Snippet;
12
+ onclick?: (event: MouseEvent) => void;
13
+ [key: string]: unknown;
14
+ }
15
+ declare const HeaderNavLink: import("svelte").Component<Props, {}, "">;
16
+ type HeaderNavLink = ReturnType<typeof HeaderNavLink>;
17
+ export default HeaderNavLink;
@@ -1,5 +1,6 @@
1
1
  export { default as Accordion } from './DetailsAccordion/DetailsAccordion.svelte';
2
2
  export { default as Header } from './Header/Header.svelte';
3
+ export { default as HeaderNavLink } from './Header/HeaderNavLink.svelte';
3
4
  export { default as Container } from './Container/Container.svelte';
4
5
  export { default as Docs } from './Docs/Docs.svelte';
5
6
  export { default as DocsImage } from './Docs/DocsImage.svelte';
@@ -1,5 +1,6 @@
1
1
  export { default as Accordion } from './DetailsAccordion/DetailsAccordion.svelte';
2
2
  export { default as Header } from './Header/Header.svelte';
3
+ export { default as HeaderNavLink } from './Header/HeaderNavLink.svelte';
3
4
  export { default as Container } from './Container/Container.svelte';
4
5
  export { default as Docs } from './Docs/Docs.svelte';
5
6
  export { default as DocsImage } from './Docs/DocsImage.svelte';
@@ -1,6 +1,6 @@
1
1
  export declare const SOCIAL_LINKS: {
2
2
  readonly x: "https://x.com/HyvorHQ";
3
- readonly blueksy: "https://bsky.app/profile/hyvor.com";
3
+ readonly bluesky: "https://bsky.app/profile/hyvor.com";
4
4
  readonly discord: "https://discord.com/invite/2WRJxQB";
5
5
  readonly github: "https://github.com/hyvor";
6
6
  readonly youtube: "https://www.youtube.com/@HYVOR";
@@ -1,6 +1,6 @@
1
1
  export const SOCIAL_LINKS = {
2
2
  x: 'https://x.com/HyvorHQ',
3
- blueksy: 'https://bsky.app/profile/hyvor.com',
3
+ bluesky: 'https://bsky.app/profile/hyvor.com',
4
4
  discord: 'https://discord.com/invite/2WRJxQB',
5
5
  github: 'https://github.com/hyvor',
6
6
  youtube: 'https://www.youtube.com/@HYVOR',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyvor/design",
3
- "version": "2.1.7",
3
+ "version": "2.1.8-beta.1",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "repository": {