@heliosgraphics/ui 2.0.1-alpha.1 → 2.0.1-alpha.11
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.md +1 -1
- package/components/Dropdown/Dropdown.module.css +6 -25
- package/components/Dropdown/Dropdown.tsx +1 -0
- package/components/Flex/Flex.utils.ts +3 -3
- package/components/Heading/Heading.tsx +8 -11
- package/components/Icon/Icon.module.css +4 -0
- package/components/Icon/Icon.tsx +1 -0
- package/components/Layout/components/LayoutMain/components/LayoutMainContent/LayoutMainContent.utils.ts +4 -3
- package/components/Markdown/Markdown.meta.ts +5 -0
- package/components/Markdown/Markdown.tsx +2 -2
- package/components/Markdown/Markdown.types.ts +1 -0
- package/components/Masonry/Masonry.tsx +14 -34
- package/components/Setup/Setup.spec.tsx +53 -0
- package/components/Setup/Setup.utils.ts +4 -10
- package/components/Tabs/Tabs.tsx +3 -9
- package/components/Tabs/Tabs.utils.ts +9 -0
- package/components/Text/Text.tsx +2 -5
- package/components/Text/Text.utils.ts +54 -4
- package/hooks/useIntersection.tsx +10 -2
- package/hooks/useResizeObserver.tsx +8 -4
- package/hooks/useTheme.tsx +2 -2
- package/package.json +11 -10
- package/utils/markdown.spec.ts +64 -0
- package/utils/markdown.ts +18 -1
- package/components/Markdown/Markdown.utils.spec.ts +0 -11
package/LICENSE.md
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
user-select: none;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
.dropdownOpen [data-ui-
|
|
8
|
+
.dropdownOpen .dropdown__activator [data-ui-icon="caret-down"],
|
|
9
|
+
.dropdownOpen .dropdown__activator [data-ui-icon="arrow-down"],
|
|
10
|
+
.dropdownOpen .dropdown__activator [data-ui-icon="chevron-down"] {
|
|
9
11
|
transition: transform 96ms ease-in-out;
|
|
10
12
|
transform: rotate(180deg);
|
|
11
13
|
}
|
|
@@ -53,40 +55,19 @@
|
|
|
53
55
|
position: absolute;
|
|
54
56
|
z-index: var(--z-index-8);
|
|
55
57
|
|
|
56
|
-
display: none;
|
|
57
58
|
min-width: 240px;
|
|
58
59
|
opacity: 0;
|
|
59
60
|
|
|
60
61
|
transition:
|
|
61
|
-
display 96ms ease-in-out allow-discrete,
|
|
62
62
|
transform 96ms ease-in-out,
|
|
63
63
|
opacity 96ms ease-in-out;
|
|
64
64
|
pointer-events: none;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
.dropdown__navActive {
|
|
68
|
-
|
|
69
|
-
opacity: 1;
|
|
67
|
+
.dropdown__nav.dropdown__navActive {
|
|
68
|
+
opacity: 1 !important;
|
|
70
69
|
|
|
71
|
-
transform: translateY(0);
|
|
70
|
+
transform: translateY(0) !important;
|
|
72
71
|
|
|
73
72
|
pointer-events: all;
|
|
74
|
-
|
|
75
|
-
@starting-style {
|
|
76
|
-
opacity: 0;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.dropdownBottomLeft .dropdown__navActive,
|
|
81
|
-
.dropdownBottomRight .dropdown__navActive {
|
|
82
|
-
@starting-style {
|
|
83
|
-
transform: translateY(-6px);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.dropdownTopLeft .dropdown__navActive,
|
|
88
|
-
.dropdownTopRight .dropdown__navActive {
|
|
89
|
-
@starting-style {
|
|
90
|
-
transform: translateY(6px);
|
|
91
|
-
}
|
|
92
73
|
}
|
|
@@ -73,7 +73,7 @@ export const getFlexUtility = (props?: FlexProps): string => {
|
|
|
73
73
|
export const getResponsiveScale = (value?: ResponsiveScaleType, prefix: string = "p"): string => {
|
|
74
74
|
if (!value) return ""
|
|
75
75
|
|
|
76
|
-
const isArray: boolean =
|
|
76
|
+
const isArray: boolean = Array.isArray(value)
|
|
77
77
|
const classes = new Set<string>()
|
|
78
78
|
|
|
79
79
|
if (!isArray) return `${prefix}-${value}`
|
|
@@ -92,7 +92,7 @@ export const getResponsiveScale = (value?: ResponsiveScaleType, prefix: string =
|
|
|
92
92
|
export const getPadding = (paddingValue?: ResponsiveScaleType): string => {
|
|
93
93
|
if (!paddingValue) return ""
|
|
94
94
|
|
|
95
|
-
const isArray: boolean =
|
|
95
|
+
const isArray: boolean = Array.isArray(paddingValue)
|
|
96
96
|
const paddingClasses = new Set<string>()
|
|
97
97
|
|
|
98
98
|
if (!isArray) return `p-${paddingValue}`
|
|
@@ -111,7 +111,7 @@ export const getPadding = (paddingValue?: ResponsiveScaleType): string => {
|
|
|
111
111
|
export const getRadius = (radiusValue?: ResponsiveRadiusType): string => {
|
|
112
112
|
if (!radiusValue) return ""
|
|
113
113
|
|
|
114
|
-
const isArray: boolean =
|
|
114
|
+
const isArray: boolean = Array.isArray(radiusValue)
|
|
115
115
|
const radiusClasses = new Set<string>()
|
|
116
116
|
|
|
117
117
|
if (!isArray) return `radius-${radiusValue}`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getTypographyUtility } from "../Text/Text.utils"
|
|
1
|
+
import { getTypographyUtility, stripTypographyProps } from "../Text/Text.utils"
|
|
2
2
|
import { getClasses } from "@heliosgraphics/utils"
|
|
3
3
|
import { H0 } from "./components/H0"
|
|
4
4
|
import { H1 } from "./components/H1"
|
|
@@ -9,7 +9,7 @@ import { H5 } from "./components/H5"
|
|
|
9
9
|
import { H6 } from "./components/H6"
|
|
10
10
|
import styles from "./Heading.module.css"
|
|
11
11
|
import type { HeadingProps } from "./Heading.types"
|
|
12
|
-
import type { FC, CSSProperties } from "react"
|
|
12
|
+
import type { FC, CSSProperties, HTMLAttributes } from "react"
|
|
13
13
|
|
|
14
14
|
export const Heading: FC<HeadingProps> = (props) => {
|
|
15
15
|
const { level, lineClamp, lineHeight, style, className, ...rest } = props
|
|
@@ -19,10 +19,7 @@ export const Heading: FC<HeadingProps> = (props) => {
|
|
|
19
19
|
[styles.headingSecondary]: props.emphasis === "secondary",
|
|
20
20
|
[styles.headingTertiary]: props.emphasis === "tertiary",
|
|
21
21
|
})
|
|
22
|
-
const utility: string = getTypographyUtility(
|
|
23
|
-
...props,
|
|
24
|
-
className: headingClasses,
|
|
25
|
-
})
|
|
22
|
+
const utility: string = getTypographyUtility(props, headingClasses)
|
|
26
23
|
const lineClampStyle: CSSProperties | undefined = lineClamp
|
|
27
24
|
? {
|
|
28
25
|
display: "-webkit-box",
|
|
@@ -35,12 +32,12 @@ export const Heading: FC<HeadingProps> = (props) => {
|
|
|
35
32
|
const lineHeightStyle: CSSProperties | undefined = lineHeight !== undefined ? { lineHeight } : undefined
|
|
36
33
|
|
|
37
34
|
const mergedStyle: CSSProperties | undefined =
|
|
38
|
-
style || lineClampStyle || lineHeightStyle
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
style || lineClampStyle || lineHeightStyle ? { ...style, ...lineClampStyle, ...lineHeightStyle } : undefined
|
|
36
|
+
const safeHeadingProps: Omit<HeadingProps, "level" | "lineClamp" | "lineHeight" | "className" | "style"> =
|
|
37
|
+
stripTypographyProps(rest)
|
|
41
38
|
|
|
42
|
-
const allProps:
|
|
43
|
-
...
|
|
39
|
+
const allProps: HTMLAttributes<HTMLHeadingElement> = {
|
|
40
|
+
...safeHeadingProps,
|
|
44
41
|
children: props.children,
|
|
45
42
|
style: mergedStyle,
|
|
46
43
|
className: utility,
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
width: 100%;
|
|
8
8
|
|
|
9
9
|
fill: currentcolor;
|
|
10
|
+
stroke: currentcolor;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
.iconPrimary svg {
|
|
13
14
|
fill: var(--ui-text-primary);
|
|
15
|
+
stroke: var(--ui-text-primary);
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
.iconSecondary svg {
|
|
17
19
|
fill: var(--ui-text-secondary);
|
|
20
|
+
stroke: var(--ui-text-secondary);
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
.iconTertiary svg {
|
|
21
24
|
fill: var(--ui-text-tertiary);
|
|
25
|
+
stroke: var(--ui-text-tertiary);
|
|
22
26
|
}
|
package/components/Icon/Icon.tsx
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
let lastLocation: string | null = null
|
|
4
4
|
let isHistoryPatched: boolean = false
|
|
5
5
|
|
|
6
|
+
const dispatchLocationChange = (): void => {
|
|
7
|
+
globalThis.dispatchEvent(new Event("locationchange"))
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
const getLocationKey = (): string | null => {
|
|
7
11
|
if (!globalThis?.location) return null
|
|
8
12
|
|
|
@@ -15,9 +19,6 @@ const patchHistory = (): void => {
|
|
|
15
19
|
isHistoryPatched = true
|
|
16
20
|
|
|
17
21
|
const { pushState, replaceState } = globalThis.history
|
|
18
|
-
const dispatchLocationChange = (): void => {
|
|
19
|
-
globalThis.dispatchEvent(new Event("locationchange"))
|
|
20
|
-
}
|
|
21
22
|
|
|
22
23
|
const pushStateWrapper = (...args: Parameters<History["pushState"]>): ReturnType<History["pushState"]> => {
|
|
23
24
|
const result = pushState.apply(globalThis.history, args as Parameters<History["pushState"]>)
|
|
@@ -16,6 +16,11 @@ export const meta: HeliosAttributeMeta<MarkdownProps> = {
|
|
|
16
16
|
isOptional: true,
|
|
17
17
|
description: "Children to render inside the markdown wrapper",
|
|
18
18
|
},
|
|
19
|
+
isLinksAllowed: {
|
|
20
|
+
type: "boolean",
|
|
21
|
+
isOptional: true,
|
|
22
|
+
description: "Preserves markdown links and auto-linked URLs in sanitized output",
|
|
23
|
+
},
|
|
19
24
|
isNonSelectable: {
|
|
20
25
|
type: "boolean",
|
|
21
26
|
isOptional: true,
|
|
@@ -4,7 +4,7 @@ import styles from "./Markdown.module.css"
|
|
|
4
4
|
import type { FC } from "react"
|
|
5
5
|
import type { MarkdownProps } from "./Markdown.types"
|
|
6
6
|
|
|
7
|
-
export const Markdown: FC<MarkdownProps> = ({ text, children, isNonSelectable }) => {
|
|
7
|
+
export const Markdown: FC<MarkdownProps> = ({ text, children, isNonSelectable, isLinksAllowed }) => {
|
|
8
8
|
if (!text && !children) return null
|
|
9
9
|
|
|
10
10
|
const markdownClasses: string = getClasses(styles.markdown, {
|
|
@@ -12,7 +12,7 @@ export const Markdown: FC<MarkdownProps> = ({ text, children, isNonSelectable })
|
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
if (text) {
|
|
15
|
-
const innerHTML = { __html: renderMarkdown(text) }
|
|
15
|
+
const innerHTML = { __html: renderMarkdown(text, { allowLinks: !!isLinksAllowed }) }
|
|
16
16
|
|
|
17
17
|
return <div className={markdownClasses} dangerouslySetInnerHTML={innerHTML} data-ui-component="Markdown"></div>
|
|
18
18
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { type FC, useId, useInsertionEffect } from "react"
|
|
1
|
+
import { Children, type FC } from "react"
|
|
2
|
+
import { Masonry as Plock } from "react-plock"
|
|
4
3
|
import type { MasonryProps } from "./Masonry.types"
|
|
5
4
|
|
|
6
5
|
export const Masonry: FC<MasonryProps> = ({
|
|
@@ -9,40 +8,21 @@ export const Masonry: FC<MasonryProps> = ({
|
|
|
9
8
|
gap = [2, 4, 6],
|
|
10
9
|
breakpoints = [0, 640, 960],
|
|
11
10
|
}) => {
|
|
12
|
-
const reactId = useId()
|
|
13
|
-
const id = `masonry${reactId.replace(/:/g, "")}`
|
|
14
|
-
|
|
15
|
-
useInsertionEffect(() => {
|
|
16
|
-
const style = document.createElement("style")
|
|
17
|
-
|
|
18
|
-
style.dataset["masonryId"] = id
|
|
19
|
-
style.textContent = `
|
|
20
|
-
.${id} { column-count: ${columns[0]}; column-gap: ${gap[0]}px; }
|
|
21
|
-
.${id} > * { break-inside: avoid; margin-bottom: ${gap[0]}px; }
|
|
22
|
-
|
|
23
|
-
@media (min-width: ${breakpoints[1]}px) {
|
|
24
|
-
.${id} { column-count: ${columns[1]}; column-gap: ${gap[1]}px; }
|
|
25
|
-
.${id} > * { margin-bottom: ${gap[1]}px; }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
@media (min-width: ${breakpoints[2]}px) {
|
|
29
|
-
.${id} { column-count: ${columns[2]}; column-gap: ${gap[2]}px; }
|
|
30
|
-
.${id} > * { margin-bottom: ${gap[2]}px; }
|
|
31
|
-
}
|
|
32
|
-
`
|
|
33
|
-
document.head.appendChild(style)
|
|
34
|
-
|
|
35
|
-
return (): void => {
|
|
36
|
-
style.remove()
|
|
37
|
-
}
|
|
38
|
-
}, [id, columns, gap, breakpoints])
|
|
39
|
-
|
|
40
11
|
if (!children) return null
|
|
41
12
|
|
|
13
|
+
const items = Children.toArray(children)
|
|
14
|
+
const numericGap = gap.map((g) => (g === "px" ? 1 : g)) as Array<number>
|
|
15
|
+
|
|
42
16
|
return (
|
|
43
|
-
<
|
|
44
|
-
{
|
|
45
|
-
|
|
17
|
+
<Plock
|
|
18
|
+
items={items}
|
|
19
|
+
config={{
|
|
20
|
+
columns,
|
|
21
|
+
gap: numericGap,
|
|
22
|
+
media: breakpoints,
|
|
23
|
+
}}
|
|
24
|
+
render={(item, index) => <div key={index}>{item}</div>}
|
|
25
|
+
/>
|
|
46
26
|
)
|
|
47
27
|
}
|
|
48
28
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import type { HeliosFixedThemeType } from "../../types/themes"
|
|
3
|
+
import { code } from "./Setup.utils"
|
|
4
|
+
|
|
5
|
+
describe("Setup", () => {
|
|
6
|
+
it("should execute when serialized into an inline script", () => {
|
|
7
|
+
const rootDataset: Record<string, string> = {}
|
|
8
|
+
const localStorageValues = new Map<string, string>()
|
|
9
|
+
const listeners: Record<string, (event: { matches: boolean }) => void> = {}
|
|
10
|
+
|
|
11
|
+
globalThis.__theme = "light" as HeliosFixedThemeType
|
|
12
|
+
globalThis.__onThemeChange = undefined
|
|
13
|
+
globalThis.__setPreferredTheme = (): void => {}
|
|
14
|
+
globalThis.document = {
|
|
15
|
+
documentElement: {
|
|
16
|
+
dataset: rootDataset,
|
|
17
|
+
},
|
|
18
|
+
} as Document
|
|
19
|
+
globalThis.localStorage = {
|
|
20
|
+
getItem: (key: string): string | null => localStorageValues.get(key) ?? null,
|
|
21
|
+
setItem: (key: string, value: string): void => {
|
|
22
|
+
localStorageValues.set(key, value)
|
|
23
|
+
},
|
|
24
|
+
} as Storage
|
|
25
|
+
globalThis.matchMedia = ((query: string): MediaQueryList => {
|
|
26
|
+
expect(query).toBe("(prefers-color-scheme: dark)")
|
|
27
|
+
return {
|
|
28
|
+
matches: true,
|
|
29
|
+
addEventListener: (type: string, listener: (event: { matches: boolean }) => void): void => {
|
|
30
|
+
listeners[type] = listener
|
|
31
|
+
},
|
|
32
|
+
} as MediaQueryList
|
|
33
|
+
}) as typeof globalThis.matchMedia
|
|
34
|
+
|
|
35
|
+
new Function(`return (${code.toString()})("system", undefined)`)()
|
|
36
|
+
|
|
37
|
+
expect(globalThis.__theme).toBe("dark")
|
|
38
|
+
expect(rootDataset["theme"]).toBe("dark")
|
|
39
|
+
expect(typeof listeners["change"]).toBe("function")
|
|
40
|
+
|
|
41
|
+
const changeListener = listeners["change"]
|
|
42
|
+
expect(changeListener).toBeDefined()
|
|
43
|
+
if (!changeListener) {
|
|
44
|
+
throw new Error("expected change listener to be registered")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
changeListener({ matches: false })
|
|
48
|
+
|
|
49
|
+
expect(globalThis.__theme).toBe("light")
|
|
50
|
+
expect(rootDataset["theme"]).toBe("light")
|
|
51
|
+
expect(localStorageValues.get("theme")).toBe("light")
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -3,20 +3,18 @@ import type { HeliosFixedThemeType, HeliosThemeType } from "../../types/themes"
|
|
|
3
3
|
export const code = (theme: HeliosThemeType = "system", fixedTheme?: HeliosFixedThemeType): void => {
|
|
4
4
|
globalThis.__onThemeChange = function (_theme: HeliosFixedThemeType): void {}
|
|
5
5
|
|
|
6
|
+
/* eslint-disable-next-line unicorn/consistent-function-scoping */
|
|
6
7
|
const setTheme = (newTheme: HeliosFixedThemeType): void => {
|
|
7
8
|
globalThis.__theme = newTheme
|
|
8
9
|
document.documentElement.dataset["theme"] = newTheme
|
|
9
10
|
globalThis.__onThemeChange?.(newTheme)
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
13
|
+
/* eslint-disable-next-line unicorn/consistent-function-scoping */
|
|
14
|
+
const handleDarkModeChange = (event: MediaQueryListEvent): void => {
|
|
15
|
+
globalThis.__setPreferredTheme(event.matches ? "dark" : "light")
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
document.addEventListener("click", handleDocumentClick)
|
|
19
|
-
|
|
20
18
|
const isLocked: boolean = fixedTheme !== undefined || theme !== "system"
|
|
21
19
|
|
|
22
20
|
if (fixedTheme !== undefined && theme !== "system") {
|
|
@@ -51,10 +49,6 @@ export const code = (theme: HeliosThemeType = "system", fixedTheme?: HeliosFixed
|
|
|
51
49
|
}
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
const handleDarkModeChange = (event: MediaQueryListEvent): void => {
|
|
55
|
-
globalThis.__setPreferredTheme(event.matches ? "dark" : "light")
|
|
56
|
-
}
|
|
57
|
-
|
|
58
52
|
darkQuery.addEventListener("change", handleDarkModeChange)
|
|
59
53
|
|
|
60
54
|
setTheme(preferredTheme ?? (darkQuery.matches ? "dark" : "light"))
|
package/components/Tabs/Tabs.tsx
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
1
|
import { getClasses } from "@heliosgraphics/utils"
|
|
2
2
|
import { Text } from "../Text"
|
|
3
|
+
import { onTabKeyDown } from "./Tabs.utils"
|
|
3
4
|
import styles from "./Tabs.module.css"
|
|
4
|
-
import type { FC
|
|
5
|
+
import type { FC } from "react"
|
|
5
6
|
import type { TabItem, TabsProps } from "./Tabs.types"
|
|
6
7
|
|
|
7
8
|
export const Tabs: FC<TabsProps> = ({ items, children }) => {
|
|
8
9
|
if (!items?.length) return null
|
|
9
10
|
|
|
10
|
-
const onKeyDown = (event: KeyboardEvent<HTMLElement>, item: TabItem): void => {
|
|
11
|
-
if (event.key === " " || event.key === "Enter") {
|
|
12
|
-
event.preventDefault()
|
|
13
|
-
item.onClick?.()
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
11
|
return (
|
|
18
12
|
<div data-ui-component="Tabs">
|
|
19
13
|
<div role="tablist" className={styles.tabs__list}>
|
|
@@ -46,7 +40,7 @@ export const Tabs: FC<TabsProps> = ({ items, children }) => {
|
|
|
46
40
|
<div
|
|
47
41
|
{...commonProps}
|
|
48
42
|
onClick={item.isDisabled ? undefined : item.onClick}
|
|
49
|
-
onKeyDown={(
|
|
43
|
+
onKeyDown={(event) => onTabKeyDown(event, item)}
|
|
50
44
|
>
|
|
51
45
|
<Text type="small" fontWeight="medium">
|
|
52
46
|
{item.name}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react"
|
|
2
|
+
import type { TabItem } from "./Tabs.types"
|
|
3
|
+
|
|
4
|
+
export const onTabKeyDown = (event: KeyboardEvent<HTMLElement>, item: TabItem): void => {
|
|
5
|
+
if (event.key === " " || event.key === "Enter") {
|
|
6
|
+
event.preventDefault()
|
|
7
|
+
item.onClick?.()
|
|
8
|
+
}
|
|
9
|
+
}
|
package/components/Text/Text.tsx
CHANGED
|
@@ -17,10 +17,7 @@ export const Text: FC<TextProps> = (props) => {
|
|
|
17
17
|
[styles.textInherit]: props.emphasis === "inherit",
|
|
18
18
|
})
|
|
19
19
|
|
|
20
|
-
const utility: string = getTypographyUtility(
|
|
21
|
-
...props,
|
|
22
|
-
className: textClasses,
|
|
23
|
-
})
|
|
20
|
+
const utility: string = getTypographyUtility(props, textClasses)
|
|
24
21
|
const lineClampStyle: object | undefined = props.lineClamp
|
|
25
22
|
? {
|
|
26
23
|
display: "-webkit-box",
|
|
@@ -31,7 +28,7 @@ export const Text: FC<TextProps> = (props) => {
|
|
|
31
28
|
: undefined
|
|
32
29
|
|
|
33
30
|
const mergedStyle: object | undefined =
|
|
34
|
-
props.style || lineClampStyle ? { ...
|
|
31
|
+
props.style || lineClampStyle ? { ...props.style, ...lineClampStyle } : undefined
|
|
35
32
|
|
|
36
33
|
const baseTextProps: Omit<TextProps, "type"> = {
|
|
37
34
|
onClick: props.onClick,
|
|
@@ -1,7 +1,54 @@
|
|
|
1
|
-
import type { TextProps } from "./Text.types"
|
|
1
|
+
import type { TextBaseProps, TextProps } from "./Text.types"
|
|
2
2
|
import type { HeadingProps } from "../Heading/Heading.types"
|
|
3
3
|
|
|
4
|
-
export
|
|
4
|
+
export interface TypographyUtilityInput {
|
|
5
|
+
emphasis?: TextBaseProps["emphasis"] | undefined
|
|
6
|
+
fontFamily?: TextBaseProps["fontFamily"] | undefined
|
|
7
|
+
fontStyle?: TextBaseProps["fontStyle"] | undefined
|
|
8
|
+
fontWeight?: TextBaseProps["fontWeight"] | undefined
|
|
9
|
+
isBalanced?: TextBaseProps["isBalanced"] | undefined
|
|
10
|
+
isEllipsis?: TextBaseProps["isEllipsis"] | undefined
|
|
11
|
+
isNonSelectable?: TextBaseProps["isNonSelectable"] | undefined
|
|
12
|
+
textAlign?: TextBaseProps["textAlign"] | undefined
|
|
13
|
+
textDecoration?: TextBaseProps["textDecoration"] | undefined
|
|
14
|
+
whiteSpace?: TextBaseProps["whiteSpace"] | undefined
|
|
15
|
+
wordWrap?: TextBaseProps["wordWrap"] | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const stripTypographyProps = <T extends TypographyUtilityInput>(
|
|
19
|
+
props: T,
|
|
20
|
+
): Omit<T, keyof TypographyUtilityInput> => {
|
|
21
|
+
const {
|
|
22
|
+
emphasis,
|
|
23
|
+
fontFamily,
|
|
24
|
+
fontStyle,
|
|
25
|
+
fontWeight,
|
|
26
|
+
isBalanced,
|
|
27
|
+
isEllipsis,
|
|
28
|
+
isNonSelectable,
|
|
29
|
+
textAlign,
|
|
30
|
+
textDecoration,
|
|
31
|
+
whiteSpace,
|
|
32
|
+
wordWrap,
|
|
33
|
+
...rest
|
|
34
|
+
} = props
|
|
35
|
+
|
|
36
|
+
void emphasis
|
|
37
|
+
void fontFamily
|
|
38
|
+
void fontStyle
|
|
39
|
+
void fontWeight
|
|
40
|
+
void isBalanced
|
|
41
|
+
void isEllipsis
|
|
42
|
+
void isNonSelectable
|
|
43
|
+
void textAlign
|
|
44
|
+
void textDecoration
|
|
45
|
+
void whiteSpace
|
|
46
|
+
void wordWrap
|
|
47
|
+
|
|
48
|
+
return rest
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const _getFontWeight = (fw: TextBaseProps["fontWeight"]): string => {
|
|
5
52
|
switch (fw) {
|
|
6
53
|
case "thin":
|
|
7
54
|
return "fw-thin"
|
|
@@ -27,7 +74,10 @@ export const _getFontWeight = (fw: TextProps["fontWeight"]): string => {
|
|
|
27
74
|
}
|
|
28
75
|
}
|
|
29
76
|
|
|
30
|
-
export const getTypographyUtility = (
|
|
77
|
+
export const getTypographyUtility = (
|
|
78
|
+
props: TextProps | HeadingProps | TypographyUtilityInput,
|
|
79
|
+
className?: string,
|
|
80
|
+
): string => {
|
|
31
81
|
const typoClasses: Array<string> = []
|
|
32
82
|
|
|
33
83
|
const fontFamily = props.fontFamily ? props.fontFamily : "sans"
|
|
@@ -36,7 +86,7 @@ export const getTypographyUtility = (props: TextProps | HeadingProps): string =>
|
|
|
36
86
|
typoClasses.push(fontFamily)
|
|
37
87
|
typoClasses.push(fontWeight)
|
|
38
88
|
|
|
39
|
-
if (
|
|
89
|
+
if (className) typoClasses.push(className)
|
|
40
90
|
if (props.fontStyle) typoClasses.push(props.fontStyle)
|
|
41
91
|
if (props.isBalanced) typoClasses.push("text-balanced")
|
|
42
92
|
if (props.isEllipsis) typoClasses.push("ellipsis")
|
|
@@ -6,9 +6,17 @@ export const useIntersection = (ref: RefObject<HTMLElement>): boolean => {
|
|
|
6
6
|
const [isIntersecting, setIntersecting] = useState<boolean>(false)
|
|
7
7
|
|
|
8
8
|
useEffect(() => {
|
|
9
|
-
if (!ref?.current)
|
|
9
|
+
if (!ref?.current) {
|
|
10
|
+
setIntersecting(false)
|
|
11
|
+
return
|
|
12
|
+
}
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
if (typeof globalThis.IntersectionObserver !== "function") {
|
|
15
|
+
setIntersecting(false)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const observer = new globalThis.IntersectionObserver(([entry]) => setIntersecting(!!entry?.isIntersecting))
|
|
12
20
|
|
|
13
21
|
observer.observe(ref.current)
|
|
14
22
|
|
|
@@ -18,7 +18,14 @@ export const useResizeObserver = (): readonly [(element: HTMLDivElement | null)
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
if (element) {
|
|
21
|
-
|
|
21
|
+
const rect = element.getBoundingClientRect()
|
|
22
|
+
setWidth(rect.width)
|
|
23
|
+
|
|
24
|
+
if (typeof globalThis.ResizeObserver !== "function") {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
resizeObserverRef.current = new globalThis.ResizeObserver((entries) => {
|
|
22
29
|
if (entries[0]) {
|
|
23
30
|
const entryWidth = entries[0].contentRect.width
|
|
24
31
|
setWidth(entryWidth)
|
|
@@ -26,9 +33,6 @@ export const useResizeObserver = (): readonly [(element: HTMLDivElement | null)
|
|
|
26
33
|
})
|
|
27
34
|
|
|
28
35
|
resizeObserverRef.current.observe(element)
|
|
29
|
-
|
|
30
|
-
const rect = element.getBoundingClientRect()
|
|
31
|
-
setWidth(rect.width)
|
|
32
36
|
} else {
|
|
33
37
|
setWidth(0)
|
|
34
38
|
}
|
package/hooks/useTheme.tsx
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { useCallback, useEffect, useState } from "react"
|
|
4
4
|
import type { HeliosFixedThemeType } from "../types/themes"
|
|
5
5
|
|
|
6
|
+
const getTheme = (): HeliosFixedThemeType => (typeof globalThis !== "undefined" ? globalThis.__theme : "light")
|
|
7
|
+
|
|
6
8
|
export const useTheme = (): { theme: HeliosFixedThemeType; isDark: boolean; toggleTheme: () => void } => {
|
|
7
9
|
const [theme, setTheme] = useState<HeliosFixedThemeType | null>(null)
|
|
8
10
|
|
|
@@ -10,8 +12,6 @@ export const useTheme = (): { theme: HeliosFixedThemeType; isDark: boolean; togg
|
|
|
10
12
|
setTheme(getTheme())
|
|
11
13
|
}, [])
|
|
12
14
|
|
|
13
|
-
const getTheme = (): HeliosFixedThemeType => (typeof globalThis !== "undefined" ? globalThis.__theme : "light")
|
|
14
|
-
|
|
15
15
|
const toggleTheme = useCallback(() => {
|
|
16
16
|
const currentIsDark: boolean = getTheme() === "dark"
|
|
17
17
|
globalThis.__setPreferredTheme?.(currentIsDark ? "light" : "dark")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heliosgraphics/ui",
|
|
3
|
-
"version": "2.0.1-alpha.
|
|
3
|
+
"version": "2.0.1-alpha.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": [
|
|
6
6
|
"*.css",
|
|
@@ -40,23 +40,24 @@
|
|
|
40
40
|
"ts:watch": "tsc --noEmit --incremental --watch"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@heliosgraphics/css": "^1.0.0-alpha.
|
|
44
|
-
"@heliosgraphics/icons": "^1.0.0-alpha.
|
|
45
|
-
"@heliosgraphics/utils": "^6.0.0-alpha.
|
|
46
|
-
"marked": "^17.0.
|
|
43
|
+
"@heliosgraphics/css": "^1.0.0-alpha.10",
|
|
44
|
+
"@heliosgraphics/icons": "^1.0.0-alpha.14",
|
|
45
|
+
"@heliosgraphics/utils": "^6.0.0-alpha.16",
|
|
46
|
+
"marked": "^17.0.5",
|
|
47
47
|
"marked-linkify-it": "^3.1.14",
|
|
48
|
-
"marked-xhtml": "^1.0.14"
|
|
48
|
+
"marked-xhtml": "^1.0.14",
|
|
49
|
+
"react-plock": "^3.6.1"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@testing-library/react": "^16.3.2",
|
|
52
53
|
"@types/node": "^25",
|
|
53
|
-
"esbuild": "^0.
|
|
54
|
+
"esbuild": "^0.28.0",
|
|
54
55
|
"esbuild-css-modules-plugin": "^3.1.5",
|
|
55
56
|
"glob": "^13.0.6",
|
|
56
|
-
"jsdom": "^
|
|
57
|
-
"next": "^16.
|
|
57
|
+
"jsdom": "^29.0.1",
|
|
58
|
+
"next": "^16.2.2",
|
|
58
59
|
"prettier": "^3.8.1",
|
|
59
|
-
"typescript": "^
|
|
60
|
+
"typescript": "^6.0.2"
|
|
60
61
|
},
|
|
61
62
|
"peerDependencies": {
|
|
62
63
|
"@types/react": "^19",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { it, describe, expect } from "bun:test"
|
|
2
|
+
import { renderMarkdown } from "./markdown"
|
|
3
|
+
|
|
4
|
+
describe("markdown", () => {
|
|
5
|
+
describe("renderMarkdown", () => {
|
|
6
|
+
const SAMPLE = `Hello\n\nHey`
|
|
7
|
+
const SAMPLE_OUTPUT = `<p>Hello</p>\n<p>Hey</p>\n`
|
|
8
|
+
const LINK_SAMPLE = `[x](https://example.com)`
|
|
9
|
+
const LINK_SAMPLE_OUTPUT = `<p><a href="https://example.com">x</a></p>\n`
|
|
10
|
+
const BARE_URL_SAMPLE = `Visit https://example.com/path for details.`
|
|
11
|
+
const BARE_URL_SAMPLE_OUTPUT = `<p>Visit <a href="https://example.com/path">https://example.com/path</a> for details.</p>\n`
|
|
12
|
+
const MALICIOUS_SAMPLE = `Hello <script>alert(1)</script> **world**`
|
|
13
|
+
const MALICIOUS_SAMPLE_OUTPUT = `<p>Hello <strong>world</strong></p>`
|
|
14
|
+
const MALICIOUS_LINK_SAMPLE = `[x](data:text/html,<script>alert(1)</script>)`
|
|
15
|
+
const MALICIOUS_LINK_SAMPLE_OUTPUT = `<p>x</p>\n`
|
|
16
|
+
const MALICIOUS_EVENT_HANDLER_SAMPLE = `<div onclick="alert(1)">hi</div>`
|
|
17
|
+
const MALICIOUS_EVENT_HANDLER_SAMPLE_OUTPUT = `<div>hi</div>`
|
|
18
|
+
|
|
19
|
+
it("Returns", () => expect(renderMarkdown(SAMPLE)).toEqual(SAMPLE_OUTPUT))
|
|
20
|
+
|
|
21
|
+
it("keeps explicit markdown links when links are allowed", () => {
|
|
22
|
+
expect(renderMarkdown(LINK_SAMPLE, { allowLinks: true })).toEqual(LINK_SAMPLE_OUTPUT)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("autolinks bare urls when links are allowed", () => {
|
|
26
|
+
expect(renderMarkdown(BARE_URL_SAMPLE, { allowLinks: true })).toEqual(BARE_URL_SAMPLE_OUTPUT)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("keeps links disabled by default", () => {
|
|
30
|
+
expect(renderMarkdown(LINK_SAMPLE)).toEqual(`<p>x</p>\n`)
|
|
31
|
+
expect(renderMarkdown(BARE_URL_SAMPLE)).toEqual(`<p>Visit https://example.com/path for details.</p>\n`)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("strips unsafe html from markdown output", () => {
|
|
35
|
+
const result = renderMarkdown(MALICIOUS_SAMPLE)
|
|
36
|
+
|
|
37
|
+
expect(result).toContain(MALICIOUS_SAMPLE_OUTPUT)
|
|
38
|
+
expect(result).not.toContain("<script")
|
|
39
|
+
expect(result).not.toContain("alert(1)")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("strips unsafe link protocols without leaving broken markup", () => {
|
|
43
|
+
const result = renderMarkdown(MALICIOUS_LINK_SAMPLE, { allowLinks: true })
|
|
44
|
+
|
|
45
|
+
expect(result).toEqual(MALICIOUS_LINK_SAMPLE_OUTPUT)
|
|
46
|
+
expect(result).not.toContain("data:")
|
|
47
|
+
expect(result).not.toContain("<script")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("does not autolink urls inside inline code", () => {
|
|
51
|
+
const result = renderMarkdown("`https://example.com`", { allowLinks: true })
|
|
52
|
+
|
|
53
|
+
expect(result).toEqual(`<p><code>https://example.com</code></p>\n`)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("removes inline event handler attributes from raw html", () => {
|
|
57
|
+
const result = renderMarkdown(MALICIOUS_EVENT_HANDLER_SAMPLE)
|
|
58
|
+
|
|
59
|
+
expect(result).toEqual(MALICIOUS_EVENT_HANDLER_SAMPLE_OUTPUT)
|
|
60
|
+
expect(result).not.toContain("onclick")
|
|
61
|
+
expect(result).not.toContain("alert(1)")
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
package/utils/markdown.ts
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { Marked } from "marked"
|
|
2
2
|
import { markedXhtml } from "marked-xhtml"
|
|
3
3
|
import markedLinkifyIt from "marked-linkify-it"
|
|
4
|
+
import { sanitizeText } from "@heliosgraphics/utils"
|
|
4
5
|
|
|
5
6
|
const marked = new Marked({ breaks: true, gfm: true })
|
|
7
|
+
const SAFE_MARKDOWN_LINK_PATTERN: RegExp = /^(?:https?:|mailto:|tel:|\/|#)/i
|
|
8
|
+
|
|
9
|
+
export interface RenderMarkdownOptions {
|
|
10
|
+
allowLinks?: boolean
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
marked.use(markedXhtml())
|
|
8
14
|
marked.use(markedLinkifyIt())
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
const stripUnsafeMarkdownLinks = (html: string): string => {
|
|
17
|
+
return html.replace(/<a\b[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, (match: string, href: string, text: string) => {
|
|
18
|
+
return SAFE_MARKDOWN_LINK_PATTERN.test(href) ? match : text
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const renderMarkdown = (text: string, options: RenderMarkdownOptions = {}): string => {
|
|
23
|
+
const renderedMarkdown: string = marked.parse(text) as string
|
|
24
|
+
const sanitizedMarkdown: string = sanitizeText(stripUnsafeMarkdownLinks(renderedMarkdown), options)
|
|
25
|
+
|
|
26
|
+
return renderedMarkdown.endsWith("\n") ? `${sanitizedMarkdown}\n` : sanitizedMarkdown
|
|
27
|
+
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { it, describe, expect } from "bun:test"
|
|
2
|
-
import { renderMarkdown } from "./Markdown.utils"
|
|
3
|
-
|
|
4
|
-
describe("markdown", () => {
|
|
5
|
-
describe("renderMarkdown", () => {
|
|
6
|
-
const SAMPLE = `Hello\n\nHey`
|
|
7
|
-
const SAMPLE_OUTPUT = `<p>Hello</p>\n<p>Hey</p>\n`
|
|
8
|
-
|
|
9
|
-
it("Returns", () => expect(renderMarkdown(SAMPLE)).toEqual(SAMPLE_OUTPUT))
|
|
10
|
-
})
|
|
11
|
-
})
|