aural-ui 3.0.5 → 3.0.7
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/dist/components/clamp-lines/ClampLines.stories.tsx +169 -0
- package/dist/components/clamp-lines/index.tsx +180 -0
- package/dist/components/clamp-lines/meta.ts +6 -0
- package/dist/components/index.ts +1 -0
- package/dist/components/input/index.tsx +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite"
|
|
3
|
+
import { toast } from "sonner"
|
|
4
|
+
|
|
5
|
+
import { ClampLines } from "."
|
|
6
|
+
import { Toaster } from "../toast"
|
|
7
|
+
|
|
8
|
+
const LONG_TEXT =
|
|
9
|
+
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof ClampLines> = {
|
|
12
|
+
title: "Components/UI/ClampLines",
|
|
13
|
+
component: ClampLines,
|
|
14
|
+
parameters: {
|
|
15
|
+
layout: "centered",
|
|
16
|
+
backgrounds: {
|
|
17
|
+
default: "dark",
|
|
18
|
+
values: [
|
|
19
|
+
{ name: "dark", value: "#0a0a0a" },
|
|
20
|
+
{ name: "light", value: "#ffffff" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
tags: ["autodocs"],
|
|
25
|
+
argTypes: {
|
|
26
|
+
wordLimit: {
|
|
27
|
+
control: "number",
|
|
28
|
+
description: "Max number of words to display before truncating",
|
|
29
|
+
},
|
|
30
|
+
responsive: {
|
|
31
|
+
control: "boolean",
|
|
32
|
+
description: "Adjusts word limit based on viewport width",
|
|
33
|
+
},
|
|
34
|
+
minWordLimit: {
|
|
35
|
+
control: "number",
|
|
36
|
+
description:
|
|
37
|
+
"Minimum word limit used on small screens when responsive is true",
|
|
38
|
+
},
|
|
39
|
+
button: {
|
|
40
|
+
control: false,
|
|
41
|
+
description:
|
|
42
|
+
"Render function for a custom toggle button. Receives `open` (boolean) and `toggle` (function) so the button can reflect and control expand/collapse state. Example: `(open, toggle) => <button onClick={toggle}>{open ? 'Less' : 'More'}</button>`",
|
|
43
|
+
},
|
|
44
|
+
classes: {
|
|
45
|
+
control: "object",
|
|
46
|
+
description: "Override classes for root and button elements",
|
|
47
|
+
},
|
|
48
|
+
children: {
|
|
49
|
+
control: "text",
|
|
50
|
+
description: "The text content to render",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default meta
|
|
56
|
+
type Story = StoryObj<typeof ClampLines>
|
|
57
|
+
|
|
58
|
+
export const Default: Story = {
|
|
59
|
+
args: {
|
|
60
|
+
children: LONG_TEXT,
|
|
61
|
+
wordLimit: 20,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const WithCSSClamp: Story = {
|
|
66
|
+
name: "CSS Line Clamp (no wordLimit)",
|
|
67
|
+
args: {
|
|
68
|
+
children: LONG_TEXT,
|
|
69
|
+
},
|
|
70
|
+
decorators: [
|
|
71
|
+
(Story) => (
|
|
72
|
+
<div style={{ width: 400 }}>
|
|
73
|
+
<Story />
|
|
74
|
+
</div>
|
|
75
|
+
),
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Responsive: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
children: LONG_TEXT,
|
|
82
|
+
wordLimit: 40,
|
|
83
|
+
responsive: true,
|
|
84
|
+
minWordLimit: 10,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const WithCustomButton: Story = {
|
|
89
|
+
name: "Custom Button",
|
|
90
|
+
args: {
|
|
91
|
+
children: LONG_TEXT,
|
|
92
|
+
wordLimit: 20,
|
|
93
|
+
button: (open, toggle) => (
|
|
94
|
+
<button
|
|
95
|
+
onClick={toggle}
|
|
96
|
+
style={{
|
|
97
|
+
marginLeft: 4,
|
|
98
|
+
padding: "2px 8px",
|
|
99
|
+
border: "1px solid #7c3aed",
|
|
100
|
+
borderRadius: 4,
|
|
101
|
+
color: "#7c3aed",
|
|
102
|
+
background: "transparent",
|
|
103
|
+
cursor: "pointer",
|
|
104
|
+
fontSize: 12,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{open ? "Show less ▲" : "Show more ▼"}
|
|
108
|
+
</button>
|
|
109
|
+
),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const WithOnToggle: Story = {
|
|
114
|
+
name: "With onToggle (Toast)",
|
|
115
|
+
args: {
|
|
116
|
+
children: LONG_TEXT,
|
|
117
|
+
wordLimit: 20,
|
|
118
|
+
onToggle: (open) => {
|
|
119
|
+
toast(open ? "Expanded" : "Collapsed", {
|
|
120
|
+
description: open
|
|
121
|
+
? "Full text is now visible."
|
|
122
|
+
: "Text has been collapsed.",
|
|
123
|
+
})
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
decorators: [
|
|
127
|
+
(Story) => (
|
|
128
|
+
<>
|
|
129
|
+
<Story />
|
|
130
|
+
<Toaster />
|
|
131
|
+
</>
|
|
132
|
+
),
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const ShortText: Story = {
|
|
137
|
+
name: "Short Text (no truncation)",
|
|
138
|
+
args: {
|
|
139
|
+
children: "This text is short enough that it will never be truncated.",
|
|
140
|
+
wordLimit: 20,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const AllVariants = () => {
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex flex-col gap-6" style={{ width: 400 }}>
|
|
147
|
+
<div>
|
|
148
|
+
<p className="text-fm-tertiary text-fm-xs mb-2 uppercase">
|
|
149
|
+
Word Limit (20 words)
|
|
150
|
+
</p>
|
|
151
|
+
<ClampLines wordLimit={20}>{LONG_TEXT}</ClampLines>
|
|
152
|
+
</div>
|
|
153
|
+
<div>
|
|
154
|
+
<p className="text-fm-tertiary text-fm-xs mb-2 uppercase">
|
|
155
|
+
CSS Line Clamp (3 lines)
|
|
156
|
+
</p>
|
|
157
|
+
<ClampLines>{LONG_TEXT}</ClampLines>
|
|
158
|
+
</div>
|
|
159
|
+
<div>
|
|
160
|
+
<p className="text-fm-tertiary text-fm-xs mb-2 uppercase">
|
|
161
|
+
Responsive (40 words, min 10)
|
|
162
|
+
</p>
|
|
163
|
+
<ClampLines wordLimit={40} responsive minWordLimit={10}>
|
|
164
|
+
{LONG_TEXT}
|
|
165
|
+
</ClampLines>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { cn } from "@lib/utils"
|
|
3
|
+
|
|
4
|
+
import { Button } from "../button"
|
|
5
|
+
|
|
6
|
+
export interface IClampLinesProps {
|
|
7
|
+
button?: ((open: boolean, toggle: () => void) => React.ReactNode) | null
|
|
8
|
+
children: string
|
|
9
|
+
classes?: {
|
|
10
|
+
clampLineBtn?: string
|
|
11
|
+
clampLineBtnInnerClass?: string
|
|
12
|
+
root?: string
|
|
13
|
+
content?: string
|
|
14
|
+
}
|
|
15
|
+
wordLimit?: number
|
|
16
|
+
responsive?: boolean
|
|
17
|
+
minWordLimit?: number
|
|
18
|
+
readMoreText?: string
|
|
19
|
+
readLessText?: string
|
|
20
|
+
onToggle?: (open: boolean) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ClampLines = ({
|
|
24
|
+
children,
|
|
25
|
+
classes = {},
|
|
26
|
+
button = null,
|
|
27
|
+
wordLimit,
|
|
28
|
+
responsive = false,
|
|
29
|
+
minWordLimit,
|
|
30
|
+
readMoreText = "READ MORE",
|
|
31
|
+
readLessText = "READ LESS",
|
|
32
|
+
onToggle,
|
|
33
|
+
}: IClampLinesProps) => {
|
|
34
|
+
const [open, setOpen] = useState(false)
|
|
35
|
+
const ref = useRef<HTMLParagraphElement | null>(null)
|
|
36
|
+
const [showLink, setShowLink] = useState(false)
|
|
37
|
+
const [responsiveWordLimit, setResponsiveWordLimit] = useState(wordLimit || 0)
|
|
38
|
+
|
|
39
|
+
const words = children.split(" ")
|
|
40
|
+
|
|
41
|
+
const calculateResponsiveWordLimit = () => {
|
|
42
|
+
if (!responsive || !wordLimit) {
|
|
43
|
+
setResponsiveWordLimit(wordLimit || 0)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const width = window.innerWidth
|
|
48
|
+
const baseWordLimit = wordLimit
|
|
49
|
+
const minLimit = minWordLimit || Math.ceil(baseWordLimit * 0.5)
|
|
50
|
+
|
|
51
|
+
const mobileBreakpoint = 640
|
|
52
|
+
const tabletBreakpoint = 1024
|
|
53
|
+
|
|
54
|
+
let calculatedLimit: number
|
|
55
|
+
|
|
56
|
+
if (width >= tabletBreakpoint) {
|
|
57
|
+
calculatedLimit = baseWordLimit
|
|
58
|
+
} else if (width >= mobileBreakpoint) {
|
|
59
|
+
const ratio =
|
|
60
|
+
(width - mobileBreakpoint) / (tabletBreakpoint - mobileBreakpoint)
|
|
61
|
+
const tabletWordLimit = minLimit + (baseWordLimit - minLimit) * 0.7
|
|
62
|
+
calculatedLimit = Math.round(
|
|
63
|
+
tabletWordLimit + (baseWordLimit - tabletWordLimit) * ratio
|
|
64
|
+
)
|
|
65
|
+
} else {
|
|
66
|
+
calculatedLimit = minLimit
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setResponsiveWordLimit(Math.max(minLimit, calculatedLimit))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
let rafId = requestAnimationFrame(() => {
|
|
74
|
+
calculateResponsiveWordLimit()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (responsive && wordLimit) {
|
|
78
|
+
const handleResize = () => {
|
|
79
|
+
cancelAnimationFrame(rafId)
|
|
80
|
+
rafId = requestAnimationFrame(() => {
|
|
81
|
+
calculateResponsiveWordLimit()
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
window.addEventListener("resize", handleResize)
|
|
86
|
+
return () => {
|
|
87
|
+
cancelAnimationFrame(rafId)
|
|
88
|
+
window.removeEventListener("resize", handleResize)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return () => cancelAnimationFrame(rafId)
|
|
93
|
+
}, [responsive, wordLimit, minWordLimit])
|
|
94
|
+
|
|
95
|
+
const effectiveWordLimit = responsive ? responsiveWordLimit : wordLimit
|
|
96
|
+
|
|
97
|
+
const shouldTruncate =
|
|
98
|
+
!!effectiveWordLimit && words.length > effectiveWordLimit
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const rafId = requestAnimationFrame(() => {
|
|
102
|
+
if (effectiveWordLimit) {
|
|
103
|
+
setShowLink(shouldTruncate)
|
|
104
|
+
} else {
|
|
105
|
+
if (!ref.current) return
|
|
106
|
+
if (ref.current.clientHeight < ref.current.scrollHeight) {
|
|
107
|
+
setShowLink(true)
|
|
108
|
+
} else if (ref.current.scrollHeight > 0 && !open) {
|
|
109
|
+
setShowLink(false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
return () => cancelAnimationFrame(rafId)
|
|
114
|
+
}, [shouldTruncate, effectiveWordLimit, children, open])
|
|
115
|
+
|
|
116
|
+
const handleClick = () => {
|
|
117
|
+
const next = !open
|
|
118
|
+
setOpen(next)
|
|
119
|
+
onToggle?.(next)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const renderButton = !button ? (
|
|
123
|
+
<Button
|
|
124
|
+
data-nosnippet
|
|
125
|
+
variant="text"
|
|
126
|
+
noise="none"
|
|
127
|
+
innerClassName={cn("px-0! h-fit inline", classes.clampLineBtnInnerClass)}
|
|
128
|
+
className={cn(
|
|
129
|
+
"text-fm-tertiary ml-1 cursor-pointer underline",
|
|
130
|
+
classes.clampLineBtn
|
|
131
|
+
)}
|
|
132
|
+
onClick={handleClick}
|
|
133
|
+
>
|
|
134
|
+
{open ? readLessText : readMoreText}
|
|
135
|
+
</Button>
|
|
136
|
+
) : (
|
|
137
|
+
button(open, handleClick)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const displayText =
|
|
141
|
+
effectiveWordLimit && !open && shouldTruncate
|
|
142
|
+
? words.slice(0, effectiveWordLimit).join(" ")
|
|
143
|
+
: children
|
|
144
|
+
const showEllipsis =
|
|
145
|
+
effectiveWordLimit &&
|
|
146
|
+
!open &&
|
|
147
|
+
shouldTruncate &&
|
|
148
|
+
displayText.length < children.length
|
|
149
|
+
|
|
150
|
+
const renderContent = effectiveWordLimit ? (
|
|
151
|
+
<p className={cn("text-fm-tertiary", classes.content)} data-nosnippet>
|
|
152
|
+
{displayText}
|
|
153
|
+
{showEllipsis && "...."}
|
|
154
|
+
{showLink && renderButton}
|
|
155
|
+
</p>
|
|
156
|
+
) : (
|
|
157
|
+
<>
|
|
158
|
+
<p
|
|
159
|
+
data-nosnippet
|
|
160
|
+
className={cn(
|
|
161
|
+
open ? "line-clamp-none" : "line-clamp-3",
|
|
162
|
+
"text-fm-tertiary",
|
|
163
|
+
classes.content
|
|
164
|
+
)}
|
|
165
|
+
ref={ref}
|
|
166
|
+
>
|
|
167
|
+
{children}
|
|
168
|
+
</p>
|
|
169
|
+
{showLink && renderButton}
|
|
170
|
+
</>
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<section className={classes.root} data-testid="clamp-lines" data-nosnippet>
|
|
175
|
+
{renderContent}
|
|
176
|
+
</section>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export default ClampLines
|
package/dist/components/index.ts
CHANGED