@studiocms/ui 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.
- package/LICENSE +21 -0
- package/README.md +564 -0
- package/package.json +49 -0
- package/src/components/BaseHead.astro +22 -0
- package/src/components/Button.astro +338 -0
- package/src/components/Card.astro +62 -0
- package/src/components/Center.astro +16 -0
- package/src/components/Checkbox.astro +180 -0
- package/src/components/Divider.astro +39 -0
- package/src/components/Dropdown/Dropdown.astro +253 -0
- package/src/components/Dropdown/dropdown.ts +170 -0
- package/src/components/Dropdown/index.ts +2 -0
- package/src/components/Input.astro +93 -0
- package/src/components/Modal/Modal.astro +164 -0
- package/src/components/Modal/index.ts +2 -0
- package/src/components/Modal/modal.ts +129 -0
- package/src/components/RadioGroup.astro +175 -0
- package/src/components/Row.astro +38 -0
- package/src/components/SearchSelect.astro +430 -0
- package/src/components/Select.astro +334 -0
- package/src/components/Sidebar/Double.astro +91 -0
- package/src/components/Sidebar/Single.astro +42 -0
- package/src/components/Sidebar/helpers.ts +133 -0
- package/src/components/Sidebar/index.ts +3 -0
- package/src/components/Textarea.astro +102 -0
- package/src/components/ThemeToggle.astro +40 -0
- package/src/components/Toast/Toaster.astro +330 -0
- package/src/components/Toast/index.ts +2 -0
- package/src/components/Toast/toast.ts +16 -0
- package/src/components/Toggle.astro +146 -0
- package/src/components/User.astro +68 -0
- package/src/components/index.ts +25 -0
- package/src/components.ts +24 -0
- package/src/css/colors.css +106 -0
- package/src/css/global.css +2 -0
- package/src/css/resets.css +55 -0
- package/src/env.d.ts +15 -0
- package/src/icons/Checkmark.astro +13 -0
- package/src/icons/ChevronUpDown.astro +13 -0
- package/src/icons/User.astro +13 -0
- package/src/icons/X-Mark.astro +13 -0
- package/src/layouts/RootLayout.astro +34 -0
- package/src/layouts/index.ts +2 -0
- package/src/layouts.ts +1 -0
- package/src/types/index.ts +11 -0
- package/src/utils/Icon.astro +41 -0
- package/src/utils/ThemeHelper.ts +127 -0
- package/src/utils/colors.ts +1 -0
- package/src/utils/generateID.ts +5 -0
- package/src/utils/headers.ts +190 -0
- package/src/utils/iconStrings.ts +29 -0
- package/src/utils/iconType.ts +3 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[data-theme="light"],
|
|
2
|
+
[data-theme="light"] * {
|
|
3
|
+
color-scheme: light;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
[data-theme="dark"],
|
|
7
|
+
[data-theme="dark"] * {
|
|
8
|
+
color-scheme: dark;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
:root {
|
|
12
|
+
/* Default Styles (Dark Mode) */
|
|
13
|
+
--background-base: 0, 0%, 6%;
|
|
14
|
+
--background-step-1: 0, 0%, 8%;
|
|
15
|
+
--background-step-2: 0, 0%, 10%;
|
|
16
|
+
--background-step-3: 0, 0%, 14%;
|
|
17
|
+
|
|
18
|
+
--text-normal: 0, 0%, 100%;
|
|
19
|
+
--text-muted: 0, 0%, 54%;
|
|
20
|
+
--text-inverted: 0, 0%, 0%;
|
|
21
|
+
|
|
22
|
+
--border: 240, 5%, 17%;
|
|
23
|
+
|
|
24
|
+
--shadow: 0, 0%, 0%;
|
|
25
|
+
|
|
26
|
+
--default-base: 0, 0%, 14%;
|
|
27
|
+
--default-hover: 0, 0%, 15%;
|
|
28
|
+
--default-active: 0, 0%, 19%;
|
|
29
|
+
|
|
30
|
+
--primary-base: 259, 83%, 73%;
|
|
31
|
+
--primary-hover: 259, 77%, 71%;
|
|
32
|
+
--primary-active: 259, 73%, 67%;
|
|
33
|
+
|
|
34
|
+
--success-base: 142, 71%, 46%;
|
|
35
|
+
--success-hover: 142, 72%, 52%;
|
|
36
|
+
--success-active: 142, 87%, 59%;
|
|
37
|
+
|
|
38
|
+
--warning-base: 48, 96%, 53%;
|
|
39
|
+
--warning-hover: 48, 97%, 61%;
|
|
40
|
+
--warning-active: 48, 100%, 71%;
|
|
41
|
+
|
|
42
|
+
--danger-base: 339, 97%, 31%;
|
|
43
|
+
--danger-hover: 337, 98%, 37%;
|
|
44
|
+
--danger-active: 337, 88%, 45%;
|
|
45
|
+
|
|
46
|
+
/* Non-specific variables */
|
|
47
|
+
--text-light: 0, 0%, 100%;
|
|
48
|
+
--text-dark: 0, 0%, 0%;
|
|
49
|
+
|
|
50
|
+
/* Flat colorways since they use variables */
|
|
51
|
+
--default-flat: var(--default-base), 0.5;
|
|
52
|
+
--default-flat-hover: var(--default-base), 0.75;
|
|
53
|
+
--default-flat-active: var(--default-base), 0.85;
|
|
54
|
+
|
|
55
|
+
--primary-flat: var(--primary-base), 0.1;
|
|
56
|
+
--primary-flat-hover: var(--primary-base), 0.25;
|
|
57
|
+
--primary-flat-active: var(--primary-base), 0.35;
|
|
58
|
+
|
|
59
|
+
--success-flat: var(--success-base), 0.1;
|
|
60
|
+
--success-flat-hover: var(--success-base), 0.25;
|
|
61
|
+
--success-flat-active: var(--success-base), 0.35;
|
|
62
|
+
|
|
63
|
+
--warning-flat: var(--warning-base), 0.1;
|
|
64
|
+
--warning-flat-hover: var(--warning-base), 0.25;
|
|
65
|
+
--warning-flat-active: var(--warning-base), 0.35;
|
|
66
|
+
|
|
67
|
+
--danger-flat: var(--danger-base), 0.1;
|
|
68
|
+
--danger-flat-hover: var(--danger-base), 0.25;
|
|
69
|
+
--danger-flat-active: var(--danger-base), 0.35;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
[data-theme="light"] {
|
|
73
|
+
/* Light Mode Styles */
|
|
74
|
+
--background-base: 0, 0%, 97%;
|
|
75
|
+
--background-step-1: 0, 0%, 90%;
|
|
76
|
+
--background-step-2: 0, 0%, 85%;
|
|
77
|
+
--background-step-3: 0, 0%, 80%;
|
|
78
|
+
|
|
79
|
+
--text-normal: 0, 0%, 0%;
|
|
80
|
+
--text-muted: 0, 0%, 24%;
|
|
81
|
+
--text-inverted: 0, 0%, 100%;
|
|
82
|
+
|
|
83
|
+
--border: 263, 5%, 68%;
|
|
84
|
+
|
|
85
|
+
--shadow: 0, 0%, 65%;
|
|
86
|
+
|
|
87
|
+
--default-base: 0, 0%, 74%;
|
|
88
|
+
--default-hover: 0, 0%, 81%;
|
|
89
|
+
--default-active: 0, 0%, 91%;
|
|
90
|
+
|
|
91
|
+
--primary-base: 259, 85%, 61%;
|
|
92
|
+
--primary-hover: 259, 78%, 56%;
|
|
93
|
+
--primary-active: 259, 71%, 50%;
|
|
94
|
+
|
|
95
|
+
--success-base: 142, 59%, 47%;
|
|
96
|
+
--success-hover: 142, 62%, 56%;
|
|
97
|
+
--success-active: 142, 87%, 59%;
|
|
98
|
+
|
|
99
|
+
--warning-base: 48, 92%, 46%;
|
|
100
|
+
--warning-hover: 48, 88%, 43%;
|
|
101
|
+
--warning-active: 48, 85%, 40%;
|
|
102
|
+
|
|
103
|
+
--danger-base: 339, 97%, 31%;
|
|
104
|
+
--danger-hover: 337, 98%, 37%;
|
|
105
|
+
--danger-active: 337, 88%, 45%;
|
|
106
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
*,
|
|
2
|
+
*::before,
|
|
3
|
+
*::after {
|
|
4
|
+
box-sizing: border-box !important;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
html {
|
|
8
|
+
color-scheme: dark;
|
|
9
|
+
accent-color: hsl(var(--primary-base));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
html,
|
|
13
|
+
body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
html[data-theme="light"] {
|
|
18
|
+
color-scheme: light;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: "Onest Variable", sans-serif;
|
|
23
|
+
-webkit-font-smoothing: antialiased;
|
|
24
|
+
color: hsl(var(--text-normal));
|
|
25
|
+
background-color: hsl(var(--background-base));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
input,
|
|
29
|
+
button,
|
|
30
|
+
textarea,
|
|
31
|
+
select {
|
|
32
|
+
font: inherit;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
p,
|
|
36
|
+
h1,
|
|
37
|
+
h2,
|
|
38
|
+
h3,
|
|
39
|
+
h4,
|
|
40
|
+
h5,
|
|
41
|
+
h6,
|
|
42
|
+
code {
|
|
43
|
+
overflow-wrap: anywhere;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
code {
|
|
47
|
+
font-family: monospace;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
button {
|
|
51
|
+
border: none;
|
|
52
|
+
outline: none;
|
|
53
|
+
background: none;
|
|
54
|
+
padding: 0;
|
|
55
|
+
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/// <reference path="../.astro/types.d.ts" />
|
|
2
|
+
|
|
3
|
+
interface CustomEventMap {
|
|
4
|
+
createtoast: CustomEvent<import('./types').ToastProps>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
interface Document {
|
|
9
|
+
addEventListener<K extends keyof CustomEventMap>(
|
|
10
|
+
type: K,
|
|
11
|
+
listener: (this: Document, ev: CustomEventMap[K]) => void
|
|
12
|
+
): void;
|
|
13
|
+
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): void;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
|
|
4
|
+
interface Props extends HTMLAttributes<'svg'> {
|
|
5
|
+
height?: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { height = 24, width = 24, ...props } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
<svg role="presentation" viewBox="0 0 17 18" width={width} height={height} {...props}>
|
|
12
|
+
<polyline fill="none" points="1 9 7 14 15 4" stroke="currentColor" stroke-dasharray="22" stroke-dashoffset="66" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></polyline>
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
|
|
4
|
+
interface Props extends HTMLAttributes<'svg'> {
|
|
5
|
+
height?: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { height = 24, width = 24, ...props } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" width={width} height={height} {...props} stroke-width="1.5" stroke="currentColor">
|
|
12
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15 12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" />
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
|
|
4
|
+
interface Props extends HTMLAttributes<'svg'> {
|
|
5
|
+
height?: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { height = 24, width = 24, ...props } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width={width} height={height} {...props}>
|
|
12
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
3
|
+
|
|
4
|
+
interface Props extends HTMLAttributes<'svg'> {
|
|
5
|
+
height?: number;
|
|
6
|
+
width?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { height = 24, width = 24, ...props } = Astro.props;
|
|
10
|
+
---
|
|
11
|
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width={width} height={height} {...props}>
|
|
12
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '@fontsource-variable/onest/index.css';
|
|
3
|
+
import '../css/global.css';
|
|
4
|
+
import BaseHead from '../components/BaseHead.astro';
|
|
5
|
+
import type { HeadConfig } from '../utils/headers';
|
|
6
|
+
|
|
7
|
+
export interface RootLayoutProps {
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
image?: string | undefined;
|
|
11
|
+
headers?: HeadConfig | undefined;
|
|
12
|
+
lang?: string;
|
|
13
|
+
defaultTheme?: 'dark' | 'light';
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Props = RootLayoutProps;
|
|
17
|
+
|
|
18
|
+
const { title, description, image, headers, lang = 'en', defaultTheme = 'dark' } = Astro.props;
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
<!doctype html>
|
|
22
|
+
<html lang={lang} data-theme={defaultTheme}>
|
|
23
|
+
<head>
|
|
24
|
+
<BaseHead
|
|
25
|
+
{title}
|
|
26
|
+
{description}
|
|
27
|
+
{headers}
|
|
28
|
+
{image}
|
|
29
|
+
/>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<slot />
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
package/src/layouts.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { RootLayout, type RootLayoutProps } from './layouts/index';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ToastProps {
|
|
2
|
+
title: string;
|
|
3
|
+
/**
|
|
4
|
+
* This will get passed to the component as unsanitized HTML. DO NOT PUT USER-GENERATED CONTENT HERE!
|
|
5
|
+
*/
|
|
6
|
+
description?: string;
|
|
7
|
+
type: 'success' | 'warning' | 'danger' | 'info';
|
|
8
|
+
duration?: number;
|
|
9
|
+
persistent?: boolean;
|
|
10
|
+
closeButton?: boolean;
|
|
11
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { icons } from '@iconify-json/heroicons';
|
|
3
|
+
import { getIconData, iconToSVG, replaceIDs } from '@iconify/utils';
|
|
4
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
5
|
+
import { type HeroIconName } from './iconType';
|
|
6
|
+
|
|
7
|
+
interface SVGAttributes extends HTMLAttributes<'svg'> {
|
|
8
|
+
// biome-ignore lint/suspicious/noExplicitAny: Allow any string index
|
|
9
|
+
[key: string]: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props extends HTMLAttributes<'svg'> {
|
|
13
|
+
name: HeroIconName;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { name, ...props } = Astro.props;
|
|
17
|
+
const attributes = props as SVGAttributes;
|
|
18
|
+
const iconData = getIconData(icons, name);
|
|
19
|
+
|
|
20
|
+
if (!iconData) {
|
|
21
|
+
throw new Error(`Icon "${name}" is missing`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const renderData = iconToSVG(iconData, {
|
|
25
|
+
height: attributes.height || 24,
|
|
26
|
+
width: attributes.width || 24,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const body = replaceIDs(renderData.body);
|
|
30
|
+
|
|
31
|
+
let renderAttribsHTML =
|
|
32
|
+
body.indexOf('xlink:') === -1 ? '' : ' xmlns:xlink="http://www.w3.org/1999/xlink"';
|
|
33
|
+
|
|
34
|
+
for (const attr in attributes) {
|
|
35
|
+
renderAttribsHTML += ` ${attr}="${attributes[attr]}"`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg"${renderAttribsHTML}>${body}</svg>`;
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
<Fragment set:html={svg} />
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
type Theme = 'dark' | 'light' | 'system';
|
|
2
|
+
type ThemeChangeCallback = (newTheme: Theme, oldTheme: Theme) => void;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A helper to toggle, set and get the current StudioCMS UI theme.
|
|
6
|
+
*/
|
|
7
|
+
class ThemeHelper {
|
|
8
|
+
private themeManagerElement: HTMLElement;
|
|
9
|
+
private observer: MutationObserver | undefined;
|
|
10
|
+
private themeChangeCallbacks: ThemeChangeCallback[] = [];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A helper to toggle, set and get the current StudioCMS UI theme.
|
|
14
|
+
* @param themeProvider The element that should carry the data-theme attribute (replaces the document root)
|
|
15
|
+
*/
|
|
16
|
+
constructor(themeProvider?: HTMLElement) {
|
|
17
|
+
this.themeManagerElement = themeProvider || document.documentElement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the current theme.
|
|
22
|
+
* @param {boolean} resolveSystemTheme Whether to resolve the `system` theme to the actual theme (`dark` or `light`)
|
|
23
|
+
* @returns {Theme} The current theme.
|
|
24
|
+
*/
|
|
25
|
+
public getTheme = <T extends boolean>(resolveSystemTheme?: T): T extends true ? 'dark' | 'light' : Theme => {
|
|
26
|
+
const theme = this.themeManagerElement.dataset.theme as Theme || 'system';
|
|
27
|
+
|
|
28
|
+
if (!resolveSystemTheme) {
|
|
29
|
+
// Side note: Don't ask me why this type wizardry is needed but it gives proper return types so I don't care
|
|
30
|
+
return theme as T extends true ? 'dark' | 'light' : Theme;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (this.themeManagerElement.dataset.theme !== 'system') {
|
|
34
|
+
return this.themeManagerElement.dataset.theme as 'dark' | 'light';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
38
|
+
return 'dark';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
42
|
+
return 'light';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// This should (in theory) never happen since, at time of writing, window.matchMedia is supported
|
|
46
|
+
// by 96.83% of all browsers in use. (https://caniuse.com/mdn-api_window_matchmedia)
|
|
47
|
+
throw new Error('Unable to resolve theme. (Most likely cause: window.matchMedia is not supported by the browser)');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sets the current theme.
|
|
52
|
+
* @param theme The new theme. One of `dark`, `light` or `system`.
|
|
53
|
+
*/
|
|
54
|
+
public setTheme = (theme: Theme): void => {
|
|
55
|
+
this.themeManagerElement.dataset.theme = theme;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Toggles the current theme.
|
|
60
|
+
*
|
|
61
|
+
* If the theme is set to `system` (or no theme is set via the root element),
|
|
62
|
+
* the theme is set depending on the user's color scheme preference (set in the browser).
|
|
63
|
+
*/
|
|
64
|
+
public toggleTheme = (): void => {
|
|
65
|
+
const theme = this.getTheme();
|
|
66
|
+
|
|
67
|
+
if (theme === 'dark') {
|
|
68
|
+
this.setTheme('light');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (theme === 'light') {
|
|
73
|
+
this.setTheme('dark');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
78
|
+
this.setTheme('light');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
83
|
+
this.setTheme('dark');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register an element to act as a toggle! When clicked, it will toggle the theme.
|
|
90
|
+
* @param toggle The HTML element that should act as the toggle
|
|
91
|
+
*/
|
|
92
|
+
public registerToggle = (toggle: HTMLElement | null): void => {
|
|
93
|
+
if (!toggle) {
|
|
94
|
+
console.error('Element passed to toggle registration does not exist.');
|
|
95
|
+
return;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
toggle.addEventListener('click', this.toggleTheme);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Allows for adding a callback that gets called whenever the theme changes.
|
|
103
|
+
* @param callback The callback to be executed
|
|
104
|
+
*/
|
|
105
|
+
public onThemeChange = (callback: ThemeChangeCallback): void => {
|
|
106
|
+
if (!this.observer) {
|
|
107
|
+
this.observer = new MutationObserver(this.themeManagerMutationHandler);
|
|
108
|
+
this.observer.observe(this.themeManagerElement, { attributes: true, attributeOldValue: true, attributeFilter: ['data-theme'] });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.themeChangeCallbacks.push(callback);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Simply gets the first mutation and calls all registered callbacks.
|
|
116
|
+
* @param mutations The mutations array from the observer. Due to the specified options, this will always be a 1-length array,
|
|
117
|
+
*/
|
|
118
|
+
private themeManagerMutationHandler = (mutations: MutationRecord[]): void => {
|
|
119
|
+
if (!mutations[0]) return;
|
|
120
|
+
|
|
121
|
+
for (const callback of this.themeChangeCallbacks) {
|
|
122
|
+
callback(this.getTheme(), mutations[0].oldValue as Theme || 'system');
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { ThemeHelper, type Theme };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type StudioCMSColorway = 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { AstroGlobalPartial } from 'astro';
|
|
2
|
+
import { z } from 'astro/zod';
|
|
3
|
+
|
|
4
|
+
export const HeadConfigSchema = () =>
|
|
5
|
+
z
|
|
6
|
+
.array(
|
|
7
|
+
z.object({
|
|
8
|
+
/** Name of the HTML tag to add to `<head>`, e.g. `'meta'`, `'link'`, or `'script'`. */
|
|
9
|
+
tag: z.enum(['title', 'base', 'link', 'style', 'meta', 'script', 'noscript', 'template']),
|
|
10
|
+
/** Attributes to set on the tag, e.g. `{ rel: 'stylesheet', href: '/custom.css' }`. */
|
|
11
|
+
attrs: z.record(z.union([z.string(), z.boolean(), z.undefined()])).default({}),
|
|
12
|
+
/** Content to place inside the tag (optional). */
|
|
13
|
+
content: z.string().default(''),
|
|
14
|
+
})
|
|
15
|
+
)
|
|
16
|
+
.default([]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default Head Tags for use with createHead() helper
|
|
20
|
+
*
|
|
21
|
+
* @param title
|
|
22
|
+
* @param description
|
|
23
|
+
* @param lang
|
|
24
|
+
* @param Astro
|
|
25
|
+
* @param favicon
|
|
26
|
+
* @param ogImage
|
|
27
|
+
* @param canonical
|
|
28
|
+
* @returns
|
|
29
|
+
*/
|
|
30
|
+
export const headDefaults = (
|
|
31
|
+
title: string,
|
|
32
|
+
description: string,
|
|
33
|
+
Astro: AstroGlobalPartial,
|
|
34
|
+
ogImage: string | undefined,
|
|
35
|
+
canonical: URL | undefined
|
|
36
|
+
) => {
|
|
37
|
+
const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [
|
|
38
|
+
{ tag: 'meta', attrs: { charset: 'utf-8' } },
|
|
39
|
+
{
|
|
40
|
+
tag: 'meta',
|
|
41
|
+
attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
42
|
+
},
|
|
43
|
+
{ tag: 'title', content: `${title}` },
|
|
44
|
+
{ tag: 'meta', attrs: { name: 'title', content: title } },
|
|
45
|
+
{ tag: 'meta', attrs: { name: 'description', content: description } },
|
|
46
|
+
{ tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } },
|
|
47
|
+
{ tag: 'meta', attrs: { name: 'generator', content: Astro.generator } },
|
|
48
|
+
// Favicon
|
|
49
|
+
{
|
|
50
|
+
tag: 'link',
|
|
51
|
+
attrs: { rel: 'apple-touch-icon', href: '/apple-touch-icon.png', sizes: '180x180' },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
tag: 'link',
|
|
55
|
+
attrs: { rel: 'icon', href: '/favicon-32x32.png', type: 'image/png', sizes: '32x32' },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
tag: 'link',
|
|
59
|
+
attrs: { rel: 'icon', href: '/favicon-16x16.png', type: 'image/png', sizes: '16x16' },
|
|
60
|
+
},
|
|
61
|
+
{ tag: 'link', attrs: { rel: 'icon', href: '/favicon.png', type: 'image/png' } },
|
|
62
|
+
{ tag: 'link', attrs: { rel: 'manifest', href: '/site.webmanifest' } },
|
|
63
|
+
{ tag: 'link', attrs: { rel: 'mask-icon', href: '/safari-pinned-tab.svg', color: '#5bbad5' } },
|
|
64
|
+
{ tag: 'link', attrs: { rel: 'shortcut icon', href: '/favicon.ico' } },
|
|
65
|
+
{ tag: 'meta', attrs: { name: 'msapplication-TileColor', content: '#da532c' } },
|
|
66
|
+
{ tag: 'meta', attrs: { name: 'msapplication-config', content: '/browserconfig.xml' } },
|
|
67
|
+
{ tag: 'meta', attrs: { name: 'theme-color', content: '#aa87f4' } },
|
|
68
|
+
// OpenGraph Tags
|
|
69
|
+
{ tag: 'meta', attrs: { property: 'og:title', content: title } },
|
|
70
|
+
{ tag: 'meta', attrs: { property: 'og:type', content: 'website' } },
|
|
71
|
+
{ tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } },
|
|
72
|
+
{ tag: 'meta', attrs: { property: 'og:description', content: description } },
|
|
73
|
+
{ tag: 'meta', attrs: { property: 'og:site_name', content: title } },
|
|
74
|
+
// Twitter Tags
|
|
75
|
+
{
|
|
76
|
+
tag: 'meta',
|
|
77
|
+
attrs: { name: 'twitter:card', content: 'summary_large_image' },
|
|
78
|
+
},
|
|
79
|
+
{ tag: 'meta', attrs: { name: 'twitter:url', content: canonical?.href } },
|
|
80
|
+
{ tag: 'meta', attrs: { name: 'twitter:title', content: title } },
|
|
81
|
+
{ tag: 'meta', attrs: { name: 'twitter:description', content: description } },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
if (ogImage) {
|
|
85
|
+
headDefaults.push(
|
|
86
|
+
{ tag: 'meta', attrs: { property: 'og:image', content: ogImage } },
|
|
87
|
+
{ tag: 'meta', attrs: { name: 'twitter:image', content: ogImage } }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return headDefaults;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const HeadSchema = HeadConfigSchema();
|
|
95
|
+
|
|
96
|
+
export type HeadUserConfig = z.input<ReturnType<typeof HeadConfigSchema>>;
|
|
97
|
+
export type HeadConfig = z.output<ReturnType<typeof HeadConfigSchema>>;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Test if a head config object contains a matching `<title>` or `<meta>` tag.
|
|
101
|
+
*
|
|
102
|
+
* For example, will return true if `head` already contains
|
|
103
|
+
* `<meta name="description" content="A">` and the passed `tag`
|
|
104
|
+
* is `<meta name="description" content="B">`. Tests against `name`,
|
|
105
|
+
* `property`, and `http-equiv` attributes for `<meta>` tags.
|
|
106
|
+
*/
|
|
107
|
+
function hasTag(head: HeadConfig, entry: HeadConfig[number]): boolean {
|
|
108
|
+
switch (entry.tag) {
|
|
109
|
+
case 'title':
|
|
110
|
+
return head.some(({ tag }) => tag === 'title');
|
|
111
|
+
case 'meta':
|
|
112
|
+
return hasOneOf(head, entry, ['name', 'property', 'http-equiv']);
|
|
113
|
+
default:
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Test if a head config object contains a tag of the same type
|
|
120
|
+
* as `entry` and a matching attribute for one of the passed `keys`.
|
|
121
|
+
*/
|
|
122
|
+
function hasOneOf(head: HeadConfig, entry: HeadConfig[number], keys: string[]): boolean {
|
|
123
|
+
const attr = getAttr(keys, entry);
|
|
124
|
+
if (!attr) return false;
|
|
125
|
+
const [key, val] = attr;
|
|
126
|
+
return head.some(({ tag, attrs }) => tag === entry.tag && attrs[key] === val);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Find the first matching key–value pair in a head entry’s attributes. */
|
|
130
|
+
function getAttr(
|
|
131
|
+
keys: string[],
|
|
132
|
+
entry: HeadConfig[number]
|
|
133
|
+
): [key: string, value: string | boolean] | undefined {
|
|
134
|
+
let attr: [string, string | boolean] | undefined;
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
const val = entry.attrs[key];
|
|
137
|
+
if (val) {
|
|
138
|
+
attr = [key, val];
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return attr;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Merge two heads, overwriting entries in the first head that exist in the second. */
|
|
146
|
+
function mergeHead(oldHead: HeadConfig, newHead: HeadConfig) {
|
|
147
|
+
return [...oldHead.filter((tag) => !hasTag(newHead, tag)), ...newHead];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Sort head tags to place important tags first and relegate “SEO” meta tags. */
|
|
151
|
+
function sortHead(head: HeadConfig) {
|
|
152
|
+
return head.sort((a, b) => {
|
|
153
|
+
const aImportance = getImportance(a);
|
|
154
|
+
const bImportance = getImportance(b);
|
|
155
|
+
return aImportance > bImportance ? -1 : bImportance > aImportance ? 1 : 0;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Get the relative importance of a specific head tag. */
|
|
160
|
+
function getImportance(entry: HeadConfig[number]) {
|
|
161
|
+
// 1. Important meta tags.
|
|
162
|
+
if (
|
|
163
|
+
entry.tag === 'meta' &&
|
|
164
|
+
('charset' in entry.attrs || 'http-equiv' in entry.attrs || entry.attrs.name === 'viewport')
|
|
165
|
+
) {
|
|
166
|
+
return 100;
|
|
167
|
+
}
|
|
168
|
+
// 2. Page title
|
|
169
|
+
if (entry.tag === 'title') return 90;
|
|
170
|
+
// 3. Anything that isn’t an SEO meta tag.
|
|
171
|
+
if (entry.tag !== 'meta') {
|
|
172
|
+
// The default favicon should be below any extra icons that the user may have set
|
|
173
|
+
// because if several icons are equally appropriate, the last one is used and we
|
|
174
|
+
// want to use the SVG icon when supported.
|
|
175
|
+
if (entry.tag === 'link' && 'rel' in entry.attrs && entry.attrs.rel === 'shortcut icon') {
|
|
176
|
+
return 70;
|
|
177
|
+
}
|
|
178
|
+
return 80;
|
|
179
|
+
}
|
|
180
|
+
// 4. SEO meta tags.
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
/** Create a fully parsed, merged, and sorted head entry array from multiple sources. */
|
|
184
|
+
export function createHead(defaultHeaders: HeadUserConfig, ...heads: HeadConfig[]) {
|
|
185
|
+
let head = HeadSchema.parse(defaultHeaders);
|
|
186
|
+
for (const next of heads) {
|
|
187
|
+
head = mergeHead(head, next);
|
|
188
|
+
}
|
|
189
|
+
return sortHead(head);
|
|
190
|
+
}
|