@hyvor/design 2.1.7 → 2.1.8-beta.2

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.
@@ -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,9 +17,11 @@
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;
23
+ logoAltText?: string;
24
+ menuLabel?: string;
20
25
  }
21
26
 
22
27
  let {
@@ -29,20 +34,40 @@
29
34
  darkToggle = true,
30
35
  center,
31
36
  end,
32
- max = false
37
+ max = false,
38
+ logoAltText = 'Logo',
39
+ menuLabel = 'Menu'
33
40
  }: Props = $props();
41
+
42
+ let scrolled = $state(false);
43
+ let mobileOpen = $state(false);
44
+
45
+ onMount(() => {
46
+ const onScroll = () => (scrolled = window.scrollY > 8);
47
+ onScroll();
48
+ window.addEventListener('scroll', onScroll, { passive: true });
49
+ return () => window.removeEventListener('scroll', onScroll);
50
+ });
51
+
52
+ // close the mobile menu once a link inside it is clicked
53
+ function closeOnLinkClick(e: MouseEvent) {
54
+ const target = e.target as HTMLElement;
55
+ if (target.tagName === 'A' || target.closest('a')) {
56
+ mobileOpen = false;
57
+ }
58
+ }
34
59
  </script>
35
60
 
36
- <header>
61
+ <header class:scrolled>
37
62
  <HeaderNotification {instance} {product} />
38
63
  <Container as="nav" {max}>
39
64
  <div class="nav-start">
40
65
  <a class="nav-brand" {href}>
41
66
  <img
42
67
  src={logo || `${instance}/api/public/logo/${product}.svg`}
43
- alt="{name + (subName ? ' ' + subName : '')} Logo"
44
- width="30"
45
- height="30"
68
+ alt="{name + (subName ? ' ' + subName : '')} {logoAltText}"
69
+ width="26"
70
+ height="26"
46
71
  />
47
72
  <span class="brand-product">
48
73
  <span class="brand">{name}</span>
@@ -70,20 +95,30 @@
70
95
  </div>
71
96
 
72
97
  <span class="mobile-nav-wrap">
73
- <Dropdown align="end" width={300}>
98
+ <Dropdown bind:show={mobileOpen} align="end" width={300} contentPadding={8}>
74
99
  {#snippet trigger()}
75
- <IconButton variant="invisible">
76
- <IconList size={18} />
100
+ <IconButton variant="invisible" aria-label={menuLabel} aria-expanded={mobileOpen}>
101
+ {#if mobileOpen}
102
+ <IconX size={18} />
103
+ {:else}
104
+ <IconList size={18} />
105
+ {/if}
77
106
  </IconButton>
78
107
  {/snippet}
79
108
  {#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>
109
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
110
+ <div class="mobile-content" onclick={closeOnLinkClick}>
111
+ {#if center}
112
+ <div class="mobile-inner">
113
+ {@render center()}
114
+ </div>
115
+ {/if}
116
+ {#if end}
117
+ <div class="mobile-divider"></div>
118
+ <div class="mobile-inner end">
119
+ {@render end()}
120
+ </div>
121
+ {/if}
87
122
  </div>
88
123
  {/snippet}
89
124
  </Dropdown>
@@ -105,10 +140,15 @@
105
140
  width: 100%;
106
141
  z-index: 100;
107
142
  background-color: var(--background, var(--accent-lightest));
108
- border-bottom: 1px solid var(--border);
143
+ border-bottom: 1px solid transparent;
109
144
  height: var(--header-height);
110
145
  display: flex;
111
146
  flex-direction: column;
147
+ transition: border-color 0.2s ease;
148
+ }
149
+
150
+ header.scrolled {
151
+ border-bottom-color: var(--border);
112
152
  }
113
153
 
114
154
  header :global(> nav) {
@@ -117,7 +157,9 @@
117
157
  flex: 1;
118
158
  }
119
159
  .nav-brand {
120
- display: inline-block;
160
+ display: inline-flex;
161
+ align-items: center;
162
+ gap: 8px;
121
163
  line-height: inherit;
122
164
  white-space: nowrap;
123
165
  color: inherit;
@@ -125,11 +167,10 @@
125
167
  font-weight: 600;
126
168
  }
127
169
  .nav-brand img {
128
- vertical-align: middle;
170
+ display: block;
129
171
  }
130
172
 
131
173
  .brand-product {
132
- vertical-align: middle;
133
174
  display: inline-flex;
134
175
  flex-direction: column;
135
176
  justify-content: center;
@@ -148,7 +189,7 @@
148
189
  flex: 1;
149
190
  display: flex;
150
191
  align-items: center;
151
- gap: 6px;
192
+ gap: 2px;
152
193
  justify-content: center;
153
194
  }
154
195
 
@@ -192,18 +233,21 @@
192
233
  .mobile-inner {
193
234
  display: flex;
194
235
  flex-direction: column;
195
- }
196
- .mobile-content {
197
- gap: 10px;
236
+ gap: 2px;
198
237
  }
199
238
 
200
239
  .mobile-content :global(.button) {
201
240
  display: flex;
202
241
  }
203
242
 
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
243
+ .mobile-divider {
244
+ height: 1px;
245
+ background: var(--border);
246
+ margin: 6px 4px;
247
+ }
248
+
249
+ /*
250
+ used to prevent the content from being hidden behind the header
207
251
  */
208
252
  :global(html) {
209
253
  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,9 +7,11 @@ 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;
13
+ logoAltText?: string;
14
+ menuLabel?: string;
12
15
  }
13
16
  declare const Header: import("svelte").Component<Props, {}, "">;
14
17
  type Header = ReturnType<typeof Header>;
@@ -0,0 +1,121 @@
1
+ <script lang="ts">
2
+ import Dropdown from '../../components/Dropdown/Dropdown.svelte';
3
+ import HeaderNavLink from './HeaderNavLink.svelte';
4
+ import type {
5
+ DropdownAlign,
6
+ DropdownPosition
7
+ } from '../../components/Dropdown/dropdown.types.js';
8
+ import type { LanguageOption } from './language.js';
9
+
10
+ interface Props {
11
+ languages: LanguageOption[];
12
+ current: string;
13
+ href: (code: string) => string;
14
+ showName?: boolean;
15
+ align?: DropdownAlign;
16
+ position?: DropdownPosition;
17
+ label?: string;
18
+ }
19
+
20
+ let {
21
+ languages,
22
+ current,
23
+ href,
24
+ showName = false,
25
+ align = 'center',
26
+ position = 'bottom',
27
+ label = 'Change language'
28
+ }: Props = $props();
29
+
30
+ const currentLanguage = $derived(languages.find((l) => l.code === current) ?? languages[0]);
31
+
32
+ let show = $state(false);
33
+
34
+ // close the dropdown once a language link inside it is clicked
35
+ function closeOnLinkClick(e: MouseEvent) {
36
+ const target = e.target as HTMLElement;
37
+ if (target.tagName === 'A' || target.closest('a')) {
38
+ show = false;
39
+ }
40
+ }
41
+ </script>
42
+
43
+ {#if currentLanguage}
44
+ <div class="header-language-toggle">
45
+ <Dropdown bind:show {align} {position} contentPadding={8}>
46
+ {#snippet trigger()}
47
+ <HeaderNavLink aria-label={label} aria-expanded={show}>
48
+ <span class="flag">{currentLanguage.flag}</span>
49
+ {#if showName}{currentLanguage.name}{/if}
50
+ </HeaderNavLink>
51
+ {/snippet}
52
+ {#snippet content()}
53
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
54
+ <div class="menu" onclick={closeOnLinkClick}>
55
+ {#each languages as language (language.code)}
56
+ <HeaderNavLink
57
+ href={href(language.code)}
58
+ active={language.code === current}
59
+ data-lang={language.code}
60
+ menu
61
+ >
62
+ <span class="lang-row">
63
+ <span class="flag">{language.flag}</span>
64
+ <span class="name">{language.name}</span>
65
+ <span class="code">{language.code.toUpperCase()}</span>
66
+ </span>
67
+ </HeaderNavLink>
68
+ {/each}
69
+ </div>
70
+ {/snippet}
71
+ </Dropdown>
72
+ </div>
73
+ {/if}
74
+
75
+ <style>
76
+ .header-language-toggle {
77
+ position: relative;
78
+ display: inline-flex;
79
+ margin-left: 12px;
80
+ padding-left: 12px;
81
+ }
82
+
83
+ /* a short divider, centered on the toggle, separating it from nav links,
84
+ rather than a full-height border-left */
85
+ .header-language-toggle::before {
86
+ content: '';
87
+ position: absolute;
88
+ left: 0;
89
+ top: 50%;
90
+ transform: translateY(-50%);
91
+ width: 1px;
92
+ height: 16px;
93
+ background: var(--border);
94
+ }
95
+
96
+ .flag {
97
+ font-size: 15px;
98
+ line-height: 1;
99
+ }
100
+
101
+ .menu {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: 2px;
105
+ }
106
+
107
+ .lang-row {
108
+ display: flex;
109
+ align-items: center;
110
+ gap: 8px;
111
+ width: 100%;
112
+ }
113
+
114
+ .code {
115
+ margin-left: auto;
116
+ padding-left: 8px;
117
+ font-size: 11px;
118
+ font-weight: 600;
119
+ color: var(--text-light);
120
+ }
121
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { DropdownAlign, DropdownPosition } from '../../components/Dropdown/dropdown.types.js';
2
+ import type { LanguageOption } from './language.js';
3
+ interface Props {
4
+ languages: LanguageOption[];
5
+ current: string;
6
+ href: (code: string) => string;
7
+ showName?: boolean;
8
+ align?: DropdownAlign;
9
+ position?: DropdownPosition;
10
+ label?: string;
11
+ }
12
+ declare const HeaderLanguageToggle: import("svelte").Component<Props, {}, "">;
13
+ type HeaderLanguageToggle = ReturnType<typeof HeaderLanguageToggle>;
14
+ export default HeaderLanguageToggle;
@@ -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;
@@ -0,0 +1,18 @@
1
+ export interface LanguageOption {
2
+ /** language/locale code, e.g. "en", "fr" */
3
+ code: string;
4
+ /** an emoji flag shown next to the name */
5
+ flag: string;
6
+ name: string;
7
+ }
8
+ /**
9
+ * Rewrites `path` (the current pathname) from `currentLang` to `targetLang`,
10
+ * for the common convention where the default language is served without a
11
+ * prefix and every other language is served under `/{code}`.
12
+ *
13
+ * @example
14
+ * buildLocalizedUrl('/pricing', 'en', 'fr', 'en') // '/fr/pricing'
15
+ * buildLocalizedUrl('/fr/pricing', 'fr', 'en', 'en') // '/pricing'
16
+ * buildLocalizedUrl('/fr', 'fr', 'en', 'en') // '/'
17
+ */
18
+ export declare function buildLocalizedUrl(path: string, currentLang: string, targetLang: string, defaultLang: string): string;
@@ -0,0 +1,35 @@
1
+ // Shared helpers for marketing sites that serve translated pages on their own
2
+ // URLs (e.g. a SvelteKit `[[lang]]` route param) rather than switching
3
+ // language client-side on one page. This is a different case from
4
+ // `InternationalizationService`/`LanguageToggle` in
5
+ // `$lib/components/Internationalization`, which swap translated strings in
6
+ // place on a single page; use that instead if you don't have per-language
7
+ // routes.
8
+ /**
9
+ * Rewrites `path` (the current pathname) from `currentLang` to `targetLang`,
10
+ * for the common convention where the default language is served without a
11
+ * prefix and every other language is served under `/{code}`.
12
+ *
13
+ * @example
14
+ * buildLocalizedUrl('/pricing', 'en', 'fr', 'en') // '/fr/pricing'
15
+ * buildLocalizedUrl('/fr/pricing', 'fr', 'en', 'en') // '/pricing'
16
+ * buildLocalizedUrl('/fr', 'fr', 'en', 'en') // '/'
17
+ */
18
+ export function buildLocalizedUrl(path, currentLang, targetLang, defaultLang) {
19
+ let basePath = path;
20
+ // strip the current language prefix, if any, to get the language-agnostic path
21
+ if (currentLang !== defaultLang) {
22
+ const prefix = `/${currentLang}`;
23
+ if (basePath === prefix) {
24
+ basePath = '/';
25
+ }
26
+ else if (basePath.startsWith(`${prefix}/`)) {
27
+ basePath = basePath.slice(prefix.length);
28
+ }
29
+ }
30
+ // the default language is served without a prefix
31
+ if (targetLang === defaultLang) {
32
+ return basePath;
33
+ }
34
+ return basePath === '/' ? `/${targetLang}` : `/${targetLang}${basePath}`;
35
+ }
@@ -1,5 +1,9 @@
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';
4
+ export { default as HeaderLanguageToggle } from './Header/HeaderLanguageToggle.svelte';
5
+ export { buildLocalizedUrl } from './Header/language.js';
6
+ export type { LanguageOption } from './Header/language.js';
3
7
  export { default as Container } from './Container/Container.svelte';
4
8
  export { default as Docs } from './Docs/Docs.svelte';
5
9
  export { default as DocsImage } from './Docs/DocsImage.svelte';
@@ -1,5 +1,8 @@
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';
4
+ export { default as HeaderLanguageToggle } from './Header/HeaderLanguageToggle.svelte';
5
+ export { buildLocalizedUrl } from './Header/language.js';
3
6
  export { default as Container } from './Container/Container.svelte';
4
7
  export { default as Docs } from './Docs/Docs.svelte';
5
8
  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.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "repository": {