@levino/shipyard-base 0.5.11 → 0.6.0
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/astro/components/Admonition.astro +44 -0
- package/astro/components/AnnouncementBar.astro +131 -0
- package/astro/components/GlobalDesktopNavigation.astro +2 -0
- package/astro/components/Npm2YarnScript.astro +54 -0
- package/astro/components/SidebarElement.astro +64 -12
- package/astro/components/SidebarNavigation.astro +5 -0
- package/astro/components/TabItem.astro +39 -0
- package/astro/components/Tabs.astro +200 -0
- package/astro/components/ThemeToggle.astro +98 -0
- package/astro/components/index.ts +6 -0
- package/astro/components/types.ts +9 -0
- package/astro/layouts/Markdown.astro +77 -2
- package/astro/layouts/Page.astro +43 -2
- package/package.json +27 -5
- package/src/globals.css +233 -0
- package/src/remark/index.ts +20 -0
- package/src/remark/remarkAdmonitions.ts +131 -0
- package/src/remark/remarkNpm2Yarn.test.ts +139 -0
- package/src/remark/remarkNpm2Yarn.ts +230 -0
- package/src/schemas/config.ts +56 -0
- package/src/shiki/index.ts +266 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Admonition component for displaying callouts/alerts.
|
|
4
|
+
* Supports: note, tip, info, warning, danger
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```astro
|
|
8
|
+
* <Admonition type="note">
|
|
9
|
+
* This is a note
|
|
10
|
+
* </Admonition>
|
|
11
|
+
*
|
|
12
|
+
* <Admonition type="warning" title="Be Careful">
|
|
13
|
+
* This is a warning with custom title
|
|
14
|
+
* </Admonition>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export type AdmonitionType = 'note' | 'tip' | 'info' | 'warning' | 'danger'
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
type: AdmonitionType
|
|
21
|
+
title?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { type, title } = Astro.props
|
|
25
|
+
|
|
26
|
+
const defaultTitles: Record<AdmonitionType, string> = {
|
|
27
|
+
note: 'Note',
|
|
28
|
+
tip: 'Tip',
|
|
29
|
+
info: 'Info',
|
|
30
|
+
warning: 'Warning',
|
|
31
|
+
danger: 'Danger',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const displayTitle = title ?? defaultTitles[type]
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<div class={`admonition admonition-${type}`} data-admonition-type={type}>
|
|
38
|
+
<div class="admonition-heading">
|
|
39
|
+
{displayTitle}
|
|
40
|
+
</div>
|
|
41
|
+
<div class="admonition-content">
|
|
42
|
+
<slot />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { AnnouncementBar } from '../../src/schemas/config'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
config: AnnouncementBar
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { config } = Astro.props
|
|
9
|
+
const {
|
|
10
|
+
id = 'announcement-bar',
|
|
11
|
+
content,
|
|
12
|
+
backgroundColor = 'primary',
|
|
13
|
+
textColor = 'primary-content',
|
|
14
|
+
isCloseable = true,
|
|
15
|
+
} = config
|
|
16
|
+
|
|
17
|
+
// Determine CSS classes for colors
|
|
18
|
+
const getDaisyClass = (
|
|
19
|
+
color: string,
|
|
20
|
+
type: 'bg' | 'text',
|
|
21
|
+
): string | undefined => {
|
|
22
|
+
const daisyColors = [
|
|
23
|
+
'primary',
|
|
24
|
+
'secondary',
|
|
25
|
+
'accent',
|
|
26
|
+
'info',
|
|
27
|
+
'success',
|
|
28
|
+
'warning',
|
|
29
|
+
'error',
|
|
30
|
+
'primary-content',
|
|
31
|
+
'secondary-content',
|
|
32
|
+
'accent-content',
|
|
33
|
+
'base-content',
|
|
34
|
+
]
|
|
35
|
+
if (daisyColors.includes(color)) {
|
|
36
|
+
return `${type}-${color}`
|
|
37
|
+
}
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const bgClass = getDaisyClass(backgroundColor, 'bg')
|
|
42
|
+
const textClass = getDaisyClass(textColor, 'text')
|
|
43
|
+
|
|
44
|
+
// Use custom style if not a DaisyUI color
|
|
45
|
+
const customStyle = [
|
|
46
|
+
bgClass ? undefined : `background-color: ${backgroundColor}`,
|
|
47
|
+
textClass ? undefined : `color: ${textColor}`,
|
|
48
|
+
]
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.join('; ')
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
<div
|
|
54
|
+
id={id}
|
|
55
|
+
class:list={[
|
|
56
|
+
'announcement-bar',
|
|
57
|
+
'py-2 px-4 text-center text-sm font-medium relative',
|
|
58
|
+
bgClass,
|
|
59
|
+
textClass,
|
|
60
|
+
]}
|
|
61
|
+
style={customStyle || undefined}
|
|
62
|
+
role="banner"
|
|
63
|
+
>
|
|
64
|
+
<div class="announcement-content" set:html={content} />
|
|
65
|
+
{
|
|
66
|
+
isCloseable && (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
class="announcement-close absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:opacity-70 transition-opacity"
|
|
70
|
+
aria-label="Close announcement"
|
|
71
|
+
data-announcement-id={id}
|
|
72
|
+
>
|
|
73
|
+
<svg
|
|
74
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
75
|
+
class="h-4 w-4"
|
|
76
|
+
fill="none"
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
stroke="currentColor"
|
|
79
|
+
stroke-width="2"
|
|
80
|
+
>
|
|
81
|
+
<path
|
|
82
|
+
stroke-linecap="round"
|
|
83
|
+
stroke-linejoin="round"
|
|
84
|
+
d="M6 18L18 6M6 6l12 12"
|
|
85
|
+
/>
|
|
86
|
+
</svg>
|
|
87
|
+
</button>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<script>
|
|
93
|
+
// Handle announcement bar dismissal with localStorage persistence
|
|
94
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
95
|
+
const closeButtons = document.querySelectorAll('.announcement-close')
|
|
96
|
+
|
|
97
|
+
closeButtons.forEach((button) => {
|
|
98
|
+
const announcementId = button.getAttribute('data-announcement-id')
|
|
99
|
+
const storageKey = `shipyard-announcement-dismissed-${announcementId}`
|
|
100
|
+
|
|
101
|
+
// Check if already dismissed
|
|
102
|
+
if (localStorage.getItem(storageKey) === 'true') {
|
|
103
|
+
const bar = button.closest('.announcement-bar')
|
|
104
|
+
if (bar) {
|
|
105
|
+
bar.remove()
|
|
106
|
+
}
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Handle close click
|
|
111
|
+
button.addEventListener('click', () => {
|
|
112
|
+
const bar = button.closest('.announcement-bar')
|
|
113
|
+
if (bar) {
|
|
114
|
+
bar.remove()
|
|
115
|
+
localStorage.setItem(storageKey, 'true')
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<style>
|
|
123
|
+
.announcement-content :global(a) {
|
|
124
|
+
text-decoration: underline;
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.announcement-content :global(a:hover) {
|
|
129
|
+
opacity: 0.8;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { Config } from '../../src/schemas/config'
|
|
3
3
|
import { cn } from '../../src/tools/cn'
|
|
4
4
|
import LanguageSwitcher from './LanguageSwitcher.astro'
|
|
5
|
+
import ThemeToggle from './ThemeToggle.astro'
|
|
5
6
|
|
|
6
7
|
type Props = Pick<Config, 'brand' | 'navigation'> & { showBrand: boolean }
|
|
7
8
|
|
|
@@ -69,6 +70,7 @@ const { brand, navigation, showBrand = false } = Astro.props as Props
|
|
|
69
70
|
)
|
|
70
71
|
}
|
|
71
72
|
</ul>
|
|
73
|
+
<ThemeToggle class="btn btn-ghost btn-circle" />
|
|
72
74
|
<LanguageSwitcher variant="dropdown" />
|
|
73
75
|
</div>
|
|
74
76
|
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Client-side script for npm2yarn tabs functionality.
|
|
4
|
+
* Include this component once in your layout to enable tab switching.
|
|
5
|
+
*/
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<script>
|
|
9
|
+
// Initialize npm2yarn tabs
|
|
10
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
11
|
+
const tabContainers = document.querySelectorAll('.npm2yarn-tabs')
|
|
12
|
+
|
|
13
|
+
tabContainers.forEach((container) => {
|
|
14
|
+
const buttons = container.querySelectorAll('.tab-button')
|
|
15
|
+
const contents = container.querySelectorAll('.tab-content')
|
|
16
|
+
|
|
17
|
+
buttons.forEach((button) => {
|
|
18
|
+
button.addEventListener('click', () => {
|
|
19
|
+
const tabId = button.getAttribute('data-tab')
|
|
20
|
+
|
|
21
|
+
// Update buttons
|
|
22
|
+
buttons.forEach((btn) => btn.classList.remove('active'))
|
|
23
|
+
button.classList.add('active')
|
|
24
|
+
|
|
25
|
+
// Update content
|
|
26
|
+
contents.forEach((content) => {
|
|
27
|
+
const contentId = content.getAttribute('data-tab-content')
|
|
28
|
+
if (contentId === tabId) {
|
|
29
|
+
content.classList.add('active')
|
|
30
|
+
} else {
|
|
31
|
+
content.classList.remove('active')
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Store preference in localStorage
|
|
36
|
+
if (tabId) {
|
|
37
|
+
localStorage.setItem('shipyard-package-manager', tabId)
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Restore preference from localStorage
|
|
43
|
+
const savedPm = localStorage.getItem('shipyard-package-manager')
|
|
44
|
+
if (savedPm) {
|
|
45
|
+
const savedButton = container.querySelector(
|
|
46
|
+
`.tab-button[data-tab="${savedPm}"]`,
|
|
47
|
+
)
|
|
48
|
+
if (savedButton) {
|
|
49
|
+
;(savedButton as HTMLButtonElement).click()
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
</script>
|
|
@@ -30,20 +30,72 @@ const isActiveOrHasActiveChild = (entryValue: Entry[string]): boolean => {
|
|
|
30
30
|
const label = entryValue.label ?? key;
|
|
31
31
|
const isActive = entryValue.href && normalizePath(entryValue.href) === normalizePath(currentPath);
|
|
32
32
|
const hasActiveChild = entryValue.subEntry && Object.values(entryValue.subEntry).some(isActiveOrHasActiveChild);
|
|
33
|
+
const hasChildren = entryValue.subEntry && Object.keys(entryValue.subEntry).length > 0;
|
|
34
|
+
const isCollapsible = hasChildren && (entryValue.collapsible !== false);
|
|
35
|
+
// Auto-expand if has active child, otherwise use collapsed setting (default true)
|
|
36
|
+
const shouldBeOpen = hasActiveChild || !(entryValue.collapsed ?? true);
|
|
37
|
+
|
|
38
|
+
// Extract badge info from customProps
|
|
39
|
+
const badge = entryValue.customProps?.badge as string | undefined;
|
|
40
|
+
const badgeType = (entryValue.customProps?.badgeType as string) ?? 'info';
|
|
41
|
+
const badgeClass = badge ? `badge badge-${badgeType} badge-sm` : undefined;
|
|
42
|
+
|
|
43
|
+
// Helper to render label with optional badge
|
|
44
|
+
const renderLabelWithBadge = (labelContent: any, extraClass?: string) => (
|
|
45
|
+
<span class={cn('flex items-center gap-2', extraClass)}>
|
|
46
|
+
{labelContent}
|
|
47
|
+
{badge && <span class={badgeClass}>{badge}</span>}
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
|
|
33
51
|
return (
|
|
34
52
|
<li class={entryValue.className}>
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
{hasChildren && isCollapsible ? (
|
|
54
|
+
// Collapsible category with children
|
|
55
|
+
<details open={shouldBeOpen}>
|
|
56
|
+
<summary>
|
|
57
|
+
{entryValue.href ? (
|
|
58
|
+
entryValue.labelHtml
|
|
59
|
+
? <Fragment set:html={entryValue.labelHtml} />
|
|
60
|
+
: <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{renderLabelWithBadge(label)}</a>
|
|
61
|
+
) : (
|
|
62
|
+
entryValue.labelHtml
|
|
63
|
+
? <Fragment set:html={entryValue.labelHtml} />
|
|
64
|
+
: <span class='menu-title'>{renderLabelWithBadge(label)}</span>
|
|
65
|
+
)}
|
|
66
|
+
</summary>
|
|
67
|
+
<ul>
|
|
68
|
+
<Astro.self entry={entryValue.subEntry} currentPath={currentPath} />
|
|
69
|
+
</ul>
|
|
70
|
+
</details>
|
|
71
|
+
) : hasChildren ? (
|
|
72
|
+
// Non-collapsible category with children (always expanded)
|
|
73
|
+
<>
|
|
74
|
+
{entryValue.href ? (
|
|
75
|
+
entryValue.labelHtml
|
|
76
|
+
? <Fragment set:html={entryValue.labelHtml} />
|
|
77
|
+
: <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{renderLabelWithBadge(label)}</a>
|
|
78
|
+
) : (
|
|
79
|
+
entryValue.labelHtml
|
|
80
|
+
? <Fragment set:html={entryValue.labelHtml} />
|
|
81
|
+
: <span class='menu-title'>{renderLabelWithBadge(label)}</span>
|
|
82
|
+
)}
|
|
83
|
+
<ul>
|
|
84
|
+
<Astro.self entry={entryValue.subEntry} currentPath={currentPath} />
|
|
85
|
+
</ul>
|
|
86
|
+
</>
|
|
87
|
+
) : (
|
|
88
|
+
// Leaf node (no children)
|
|
89
|
+
entryValue.href ? (
|
|
90
|
+
entryValue.labelHtml
|
|
91
|
+
? <Fragment set:html={entryValue.labelHtml} />
|
|
92
|
+
: <a href={entryValue.href} class={cn({ 'bg-base-200/50 font-medium text-primary': isActive })}>{renderLabelWithBadge(label)}</a>
|
|
93
|
+
) : (
|
|
94
|
+
entryValue.labelHtml
|
|
95
|
+
? <Fragment set:html={entryValue.labelHtml} />
|
|
96
|
+
: <span class='menu-title'>{renderLabelWithBadge(label)}</span>
|
|
97
|
+
)
|
|
98
|
+
)}
|
|
47
99
|
</li>
|
|
48
100
|
)
|
|
49
101
|
})}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { cn } from '../../src/tools/cn'
|
|
3
3
|
import LanguageSwitcher from './LanguageSwitcher.astro'
|
|
4
4
|
import SidebarElement from './SidebarElement.astro'
|
|
5
|
+
import ThemeToggle from './ThemeToggle.astro'
|
|
5
6
|
import type { Entry } from './types'
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
@@ -36,6 +37,10 @@ const withLocale = (path: string) =>
|
|
|
36
37
|
}
|
|
37
38
|
</li>
|
|
38
39
|
<LanguageSwitcher variant="list" />
|
|
40
|
+
<li class="flex flex-row items-center gap-2 px-4 py-2">
|
|
41
|
+
<span class="text-sm opacity-70">Theme</span>
|
|
42
|
+
<ThemeToggle />
|
|
43
|
+
</li>
|
|
39
44
|
<div class={cn("divider my-1 block md:hidden", { hidden: !local })}></div>
|
|
40
45
|
</div>
|
|
41
46
|
{local && <div data-testid="sidebar-local-nav"><SidebarElement entry={local} /></div>}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* TabItem component for use inside Tabs.
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```astro
|
|
7
|
+
* <Tabs items={['Tab 1', 'Tab 2']}>
|
|
8
|
+
* <TabItem value="Tab 1">
|
|
9
|
+
* Content for tab 1
|
|
10
|
+
* </TabItem>
|
|
11
|
+
* <TabItem value="Tab 2">
|
|
12
|
+
* Content for tab 2
|
|
13
|
+
* </TabItem>
|
|
14
|
+
* </Tabs>
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
/**
|
|
20
|
+
* The value that matches the tab button label
|
|
21
|
+
*/
|
|
22
|
+
value: string
|
|
23
|
+
/**
|
|
24
|
+
* Whether this tab is shown by default
|
|
25
|
+
*/
|
|
26
|
+
default?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { value, default: isDefault } = Astro.props
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<div
|
|
33
|
+
class="shipyard-tab-panel"
|
|
34
|
+
role="tabpanel"
|
|
35
|
+
data-tab-value={value}
|
|
36
|
+
hidden={!isDefault}
|
|
37
|
+
>
|
|
38
|
+
<slot />
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Tabs component for organizing content into tabbed panels.
|
|
4
|
+
* Supports groupId for syncing tabs across the page.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```astro
|
|
8
|
+
* <Tabs items={['JavaScript', 'TypeScript', 'Python']}>
|
|
9
|
+
* <TabItem value="JavaScript">
|
|
10
|
+
* JS content
|
|
11
|
+
* </TabItem>
|
|
12
|
+
* <TabItem value="TypeScript">
|
|
13
|
+
* TS content
|
|
14
|
+
* </TabItem>
|
|
15
|
+
* <TabItem value="Python">
|
|
16
|
+
* Python content
|
|
17
|
+
* </TabItem>
|
|
18
|
+
* </Tabs>
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
/**
|
|
24
|
+
* Array of tab labels
|
|
25
|
+
*/
|
|
26
|
+
items: string[]
|
|
27
|
+
/**
|
|
28
|
+
* Group ID for syncing tabs across the page
|
|
29
|
+
*/
|
|
30
|
+
groupId?: string
|
|
31
|
+
/**
|
|
32
|
+
* Whether to persist selection in URL query string
|
|
33
|
+
*/
|
|
34
|
+
queryString?: boolean | string
|
|
35
|
+
/**
|
|
36
|
+
* Default selected tab (by value/label)
|
|
37
|
+
*/
|
|
38
|
+
defaultValue?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { items, groupId, queryString, defaultValue } = Astro.props
|
|
42
|
+
|
|
43
|
+
const tabId = `tabs-${Math.random().toString(36).slice(2, 9)}`
|
|
44
|
+
const defaultTab = defaultValue ?? items[0]
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<div
|
|
48
|
+
class="shipyard-tabs"
|
|
49
|
+
data-tabs-id={tabId}
|
|
50
|
+
data-group-id={groupId}
|
|
51
|
+
data-query-string={queryString === true ? groupId : (queryString || undefined)}
|
|
52
|
+
>
|
|
53
|
+
<div class="shipyard-tabs-list" role="tablist" aria-label="Content tabs">
|
|
54
|
+
{items.map((item, index) => (
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
role="tab"
|
|
58
|
+
class="shipyard-tab-button"
|
|
59
|
+
id={`${tabId}-tab-${index}`}
|
|
60
|
+
aria-controls={`${tabId}-panel-${index}`}
|
|
61
|
+
aria-selected={item === defaultTab ? 'true' : 'false'}
|
|
62
|
+
tabindex={item === defaultTab ? 0 : -1}
|
|
63
|
+
data-tab-value={item}
|
|
64
|
+
>
|
|
65
|
+
{item}
|
|
66
|
+
</button>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
<slot />
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<script>
|
|
73
|
+
// Tab synchronization and interaction logic
|
|
74
|
+
function initTabs() {
|
|
75
|
+
const tabContainers = document.querySelectorAll('.shipyard-tabs')
|
|
76
|
+
|
|
77
|
+
tabContainers.forEach((container) => {
|
|
78
|
+
const buttons = container.querySelectorAll<HTMLButtonElement>('.shipyard-tab-button')
|
|
79
|
+
const panels = container.querySelectorAll<HTMLDivElement>('.shipyard-tab-panel')
|
|
80
|
+
const groupId = container.getAttribute('data-group-id')
|
|
81
|
+
const queryStringParam = container.getAttribute('data-query-string')
|
|
82
|
+
|
|
83
|
+
// Check URL for initial selection
|
|
84
|
+
if (queryStringParam) {
|
|
85
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
86
|
+
const urlValue = urlParams.get(queryStringParam)
|
|
87
|
+
if (urlValue) {
|
|
88
|
+
const matchingButton = Array.from(buttons).find(
|
|
89
|
+
(button) => button.getAttribute('data-tab-value') === urlValue
|
|
90
|
+
)
|
|
91
|
+
if (matchingButton) {
|
|
92
|
+
selectTab(matchingButton, buttons, panels, false)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check localStorage for group selection
|
|
98
|
+
if (groupId) {
|
|
99
|
+
const storedValue = localStorage.getItem(`shipyard-tabs-${groupId}`)
|
|
100
|
+
if (storedValue) {
|
|
101
|
+
const matchingButton = Array.from(buttons).find(
|
|
102
|
+
(button) => button.getAttribute('data-tab-value') === storedValue
|
|
103
|
+
)
|
|
104
|
+
if (matchingButton) {
|
|
105
|
+
selectTab(matchingButton, buttons, panels, false)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
buttons.forEach((button) => {
|
|
111
|
+
button.addEventListener('click', () => {
|
|
112
|
+
selectTab(button, buttons, panels, true)
|
|
113
|
+
|
|
114
|
+
const value = button.getAttribute('data-tab-value')
|
|
115
|
+
|
|
116
|
+
// Sync with other tabs in the same group
|
|
117
|
+
if (groupId && value) {
|
|
118
|
+
localStorage.setItem(`shipyard-tabs-${groupId}`, value)
|
|
119
|
+
syncTabsInGroup(groupId, value)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Update URL query string
|
|
123
|
+
if (queryStringParam && value) {
|
|
124
|
+
const url = new URL(window.location.href)
|
|
125
|
+
url.searchParams.set(queryStringParam, value)
|
|
126
|
+
window.history.replaceState({}, '', url.toString())
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Keyboard navigation
|
|
131
|
+
button.addEventListener('keydown', (event: KeyboardEvent) => {
|
|
132
|
+
const currentIndex = Array.from(buttons).indexOf(button)
|
|
133
|
+
let newIndex = currentIndex
|
|
134
|
+
|
|
135
|
+
if (event.key === 'ArrowRight') {
|
|
136
|
+
newIndex = (currentIndex + 1) % buttons.length
|
|
137
|
+
} else if (event.key === 'ArrowLeft') {
|
|
138
|
+
newIndex = (currentIndex - 1 + buttons.length) % buttons.length
|
|
139
|
+
} else if (event.key === 'Home') {
|
|
140
|
+
newIndex = 0
|
|
141
|
+
} else if (event.key === 'End') {
|
|
142
|
+
newIndex = buttons.length - 1
|
|
143
|
+
} else {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
event.preventDefault()
|
|
148
|
+
buttons[newIndex].focus()
|
|
149
|
+
buttons[newIndex].click()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function selectTab(
|
|
156
|
+
selectedButton: HTMLButtonElement,
|
|
157
|
+
allButtons: NodeListOf<HTMLButtonElement>,
|
|
158
|
+
panels: NodeListOf<HTMLDivElement>,
|
|
159
|
+
focus: boolean
|
|
160
|
+
) {
|
|
161
|
+
const selectedValue = selectedButton.getAttribute('data-tab-value')
|
|
162
|
+
|
|
163
|
+
allButtons.forEach((button, index) => {
|
|
164
|
+
const isSelected = button === selectedButton
|
|
165
|
+
button.setAttribute('aria-selected', isSelected ? 'true' : 'false')
|
|
166
|
+
button.tabIndex = isSelected ? 0 : -1
|
|
167
|
+
|
|
168
|
+
if (panels[index]) {
|
|
169
|
+
panels[index].hidden = !isSelected
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
if (focus) {
|
|
174
|
+
selectedButton.focus()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function syncTabsInGroup(groupId: string, value: string) {
|
|
179
|
+
const tabContainers = document.querySelectorAll(
|
|
180
|
+
`.shipyard-tabs[data-group-id="${groupId}"]`
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
tabContainers.forEach((container) => {
|
|
184
|
+
const buttons = container.querySelectorAll<HTMLButtonElement>('.shipyard-tab-button')
|
|
185
|
+
const panels = container.querySelectorAll<HTMLDivElement>('.shipyard-tab-panel')
|
|
186
|
+
|
|
187
|
+
const matchingButton = Array.from(buttons).find(
|
|
188
|
+
(button) => button.getAttribute('data-tab-value') === value
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if (matchingButton) {
|
|
192
|
+
selectTab(matchingButton, buttons, panels, false)
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Initialize on DOMContentLoaded and after Astro page transitions
|
|
198
|
+
document.addEventListener('DOMContentLoaded', initTabs)
|
|
199
|
+
document.addEventListener('astro:page-load', initTabs)
|
|
200
|
+
</script>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Theme toggle component for switching between light and dark modes.
|
|
4
|
+
* Uses DaisyUI themes and persists preference in localStorage.
|
|
5
|
+
*/
|
|
6
|
+
interface Props {
|
|
7
|
+
/**
|
|
8
|
+
* The light theme name (DaisyUI theme).
|
|
9
|
+
* @default 'light'
|
|
10
|
+
*/
|
|
11
|
+
lightTheme?: string
|
|
12
|
+
/**
|
|
13
|
+
* The dark theme name (DaisyUI theme).
|
|
14
|
+
* @default 'dark'
|
|
15
|
+
*/
|
|
16
|
+
darkTheme?: string
|
|
17
|
+
/**
|
|
18
|
+
* Additional CSS classes for the toggle button.
|
|
19
|
+
*/
|
|
20
|
+
class?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
lightTheme = 'light',
|
|
25
|
+
darkTheme = 'dark',
|
|
26
|
+
class: className,
|
|
27
|
+
} = Astro.props
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<label class:list={['swap swap-rotate', className]}>
|
|
31
|
+
<input
|
|
32
|
+
type="checkbox"
|
|
33
|
+
class="theme-controller"
|
|
34
|
+
data-light-theme={lightTheme}
|
|
35
|
+
data-dark-theme={darkTheme}
|
|
36
|
+
/>
|
|
37
|
+
<!-- Sun icon (light mode) -->
|
|
38
|
+
<svg
|
|
39
|
+
class="swap-off h-6 w-6 fill-current"
|
|
40
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
41
|
+
viewBox="0 0 24 24"
|
|
42
|
+
>
|
|
43
|
+
<path
|
|
44
|
+
d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"
|
|
45
|
+
></path>
|
|
46
|
+
</svg>
|
|
47
|
+
<!-- Moon icon (dark mode) -->
|
|
48
|
+
<svg
|
|
49
|
+
class="swap-on h-6 w-6 fill-current"
|
|
50
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
51
|
+
viewBox="0 0 24 24"
|
|
52
|
+
>
|
|
53
|
+
<path
|
|
54
|
+
d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"
|
|
55
|
+
></path>
|
|
56
|
+
</svg>
|
|
57
|
+
</label>
|
|
58
|
+
|
|
59
|
+
<script>
|
|
60
|
+
// Initialize theme on page load
|
|
61
|
+
function initTheme() {
|
|
62
|
+
const toggles = document.querySelectorAll<HTMLInputElement>('.theme-controller')
|
|
63
|
+
|
|
64
|
+
toggles.forEach((toggle) => {
|
|
65
|
+
const lightTheme = toggle.dataset.lightTheme || 'light'
|
|
66
|
+
const darkTheme = toggle.dataset.darkTheme || 'dark'
|
|
67
|
+
|
|
68
|
+
// Get saved theme or detect system preference
|
|
69
|
+
const savedTheme = localStorage.getItem('theme')
|
|
70
|
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
71
|
+
const currentTheme = savedTheme || (prefersDark ? darkTheme : lightTheme)
|
|
72
|
+
|
|
73
|
+
// Set initial state
|
|
74
|
+
document.documentElement.setAttribute('data-theme', currentTheme)
|
|
75
|
+
toggle.checked = currentTheme === darkTheme
|
|
76
|
+
|
|
77
|
+
// Handle toggle changes
|
|
78
|
+
toggle.addEventListener('change', () => {
|
|
79
|
+
const newTheme = toggle.checked ? darkTheme : lightTheme
|
|
80
|
+
document.documentElement.setAttribute('data-theme', newTheme)
|
|
81
|
+
localStorage.setItem('theme', newTheme)
|
|
82
|
+
|
|
83
|
+
// Sync other toggles on the page
|
|
84
|
+
toggles.forEach((otherToggle) => {
|
|
85
|
+
if (otherToggle !== toggle) {
|
|
86
|
+
otherToggle.checked = toggle.checked
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Run on initial load
|
|
94
|
+
initTheme()
|
|
95
|
+
|
|
96
|
+
// Re-run on Astro navigation (View Transitions)
|
|
97
|
+
document.addEventListener('astro:after-swap', initTheme)
|
|
98
|
+
</script>
|