aural-ui 3.0.4 → 3.0.6

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.
@@ -0,0 +1,144 @@
1
+ import React from "react"
2
+ import type { Meta, StoryObj } from "@storybook/react-vite"
3
+
4
+ import { ClampLines } from "."
5
+
6
+ const LONG_TEXT =
7
+ "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."
8
+
9
+ const meta: Meta<typeof ClampLines> = {
10
+ title: "Components/UI/ClampLines",
11
+ component: ClampLines,
12
+ parameters: {
13
+ layout: "centered",
14
+ backgrounds: {
15
+ default: "dark",
16
+ values: [
17
+ { name: "dark", value: "#0a0a0a" },
18
+ { name: "light", value: "#ffffff" },
19
+ ],
20
+ },
21
+ },
22
+ tags: ["autodocs"],
23
+ argTypes: {
24
+ wordLimit: {
25
+ control: "number",
26
+ description: "Max number of words to display before truncating",
27
+ },
28
+ responsive: {
29
+ control: "boolean",
30
+ description: "Adjusts word limit based on viewport width",
31
+ },
32
+ minWordLimit: {
33
+ control: "number",
34
+ description:
35
+ "Minimum word limit used on small screens when responsive is true",
36
+ },
37
+ button: {
38
+ control: false,
39
+ description:
40
+ "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>`",
41
+ },
42
+ classes: {
43
+ control: "object",
44
+ description: "Override classes for root and button elements",
45
+ },
46
+ children: {
47
+ control: "text",
48
+ description: "The text content to render",
49
+ },
50
+ },
51
+ }
52
+
53
+ export default meta
54
+ type Story = StoryObj<typeof ClampLines>
55
+
56
+ export const Default: Story = {
57
+ args: {
58
+ children: LONG_TEXT,
59
+ wordLimit: 20,
60
+ },
61
+ }
62
+
63
+ export const WithCSSClamp: Story = {
64
+ name: "CSS Line Clamp (no wordLimit)",
65
+ args: {
66
+ children: LONG_TEXT,
67
+ },
68
+ decorators: [
69
+ (Story) => (
70
+ <div style={{ width: 400 }}>
71
+ <Story />
72
+ </div>
73
+ ),
74
+ ],
75
+ }
76
+
77
+ export const Responsive: Story = {
78
+ args: {
79
+ children: LONG_TEXT,
80
+ wordLimit: 40,
81
+ responsive: true,
82
+ minWordLimit: 10,
83
+ },
84
+ }
85
+
86
+ export const WithCustomButton: Story = {
87
+ name: "Custom Button",
88
+ args: {
89
+ children: LONG_TEXT,
90
+ wordLimit: 20,
91
+ button: (open, toggle) => (
92
+ <button
93
+ onClick={toggle}
94
+ style={{
95
+ marginLeft: 4,
96
+ padding: "2px 8px",
97
+ border: "1px solid #7c3aed",
98
+ borderRadius: 4,
99
+ color: "#7c3aed",
100
+ background: "transparent",
101
+ cursor: "pointer",
102
+ fontSize: 12,
103
+ }}
104
+ >
105
+ {open ? "Show less ▲" : "Show more ▼"}
106
+ </button>
107
+ ),
108
+ },
109
+ }
110
+
111
+ export const ShortText: Story = {
112
+ name: "Short Text (no truncation)",
113
+ args: {
114
+ children: "This text is short enough that it will never be truncated.",
115
+ wordLimit: 20,
116
+ },
117
+ }
118
+
119
+ export const AllVariants = () => {
120
+ return (
121
+ <div className="flex flex-col gap-6" style={{ width: 400 }}>
122
+ <div>
123
+ <p className="text-fm-tertiary text-fm-xs mb-2 uppercase">
124
+ Word Limit (20 words)
125
+ </p>
126
+ <ClampLines wordLimit={20}>{LONG_TEXT}</ClampLines>
127
+ </div>
128
+ <div>
129
+ <p className="text-fm-tertiary text-fm-xs mb-2 uppercase">
130
+ CSS Line Clamp (3 lines)
131
+ </p>
132
+ <ClampLines>{LONG_TEXT}</ClampLines>
133
+ </div>
134
+ <div>
135
+ <p className="text-fm-tertiary text-fm-xs mb-2 uppercase">
136
+ Responsive (40 words, min 10)
137
+ </p>
138
+ <ClampLines wordLimit={40} responsive minWordLimit={10}>
139
+ {LONG_TEXT}
140
+ </ClampLines>
141
+ </div>
142
+ </div>
143
+ )
144
+ }
@@ -0,0 +1,175 @@
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
+ }
21
+
22
+ export const ClampLines = ({
23
+ children,
24
+ classes = {},
25
+ button = null,
26
+ wordLimit,
27
+ responsive = false,
28
+ minWordLimit,
29
+ readMoreText = "READ MORE",
30
+ readLessText = "READ LESS",
31
+ }: IClampLinesProps) => {
32
+ const [open, setOpen] = useState(false)
33
+ const ref = useRef<HTMLParagraphElement | null>(null)
34
+ const [showLink, setShowLink] = useState(false)
35
+ const [responsiveWordLimit, setResponsiveWordLimit] = useState(wordLimit || 0)
36
+
37
+ const words = children.split(" ")
38
+
39
+ const calculateResponsiveWordLimit = () => {
40
+ if (!responsive || !wordLimit) {
41
+ setResponsiveWordLimit(wordLimit || 0)
42
+ return
43
+ }
44
+
45
+ const width = window.innerWidth
46
+ const baseWordLimit = wordLimit
47
+ const minLimit = minWordLimit || Math.ceil(baseWordLimit * 0.5)
48
+
49
+ const mobileBreakpoint = 640
50
+ const tabletBreakpoint = 1024
51
+
52
+ let calculatedLimit: number
53
+
54
+ if (width >= tabletBreakpoint) {
55
+ calculatedLimit = baseWordLimit
56
+ } else if (width >= mobileBreakpoint) {
57
+ const ratio =
58
+ (width - mobileBreakpoint) / (tabletBreakpoint - mobileBreakpoint)
59
+ const tabletWordLimit = minLimit + (baseWordLimit - minLimit) * 0.7
60
+ calculatedLimit = Math.round(
61
+ tabletWordLimit + (baseWordLimit - tabletWordLimit) * ratio
62
+ )
63
+ } else {
64
+ calculatedLimit = minLimit
65
+ }
66
+
67
+ setResponsiveWordLimit(Math.max(minLimit, calculatedLimit))
68
+ }
69
+
70
+ useEffect(() => {
71
+ let rafId = requestAnimationFrame(() => {
72
+ calculateResponsiveWordLimit()
73
+ })
74
+
75
+ if (responsive && wordLimit) {
76
+ const handleResize = () => {
77
+ cancelAnimationFrame(rafId)
78
+ rafId = requestAnimationFrame(() => {
79
+ calculateResponsiveWordLimit()
80
+ })
81
+ }
82
+
83
+ window.addEventListener("resize", handleResize)
84
+ return () => {
85
+ cancelAnimationFrame(rafId)
86
+ window.removeEventListener("resize", handleResize)
87
+ }
88
+ }
89
+
90
+ return () => cancelAnimationFrame(rafId)
91
+ }, [responsive, wordLimit, minWordLimit])
92
+
93
+ const effectiveWordLimit = responsive ? responsiveWordLimit : wordLimit
94
+
95
+ const shouldTruncate =
96
+ !!effectiveWordLimit && words.length > effectiveWordLimit
97
+
98
+ useEffect(() => {
99
+ const rafId = requestAnimationFrame(() => {
100
+ if (effectiveWordLimit) {
101
+ setShowLink(shouldTruncate)
102
+ } else {
103
+ if (!ref.current) return
104
+ if (ref.current.clientHeight < ref.current.scrollHeight) {
105
+ setShowLink(true)
106
+ } else if (ref.current.scrollHeight > 0 && !open) {
107
+ setShowLink(false)
108
+ }
109
+ }
110
+ })
111
+ return () => cancelAnimationFrame(rafId)
112
+ }, [shouldTruncate, effectiveWordLimit, children, open])
113
+
114
+ const handleClick = () => {
115
+ setOpen((prev) => !prev)
116
+ }
117
+
118
+ const renderButton = !button ? (
119
+ <Button
120
+ variant="text"
121
+ noise="none"
122
+ innerClassName={cn("px-0! h-fit inline", classes.clampLineBtnInnerClass)}
123
+ className={cn(
124
+ "text-fm-tertiary ml-1 cursor-pointer underline",
125
+ classes.clampLineBtn
126
+ )}
127
+ onClick={handleClick}
128
+ >
129
+ {open ? readLessText : readMoreText}
130
+ </Button>
131
+ ) : (
132
+ button(open, handleClick)
133
+ )
134
+
135
+ const displayText =
136
+ effectiveWordLimit && !open && shouldTruncate
137
+ ? words.slice(0, effectiveWordLimit).join(" ")
138
+ : children
139
+ const showEllipsis =
140
+ effectiveWordLimit &&
141
+ !open &&
142
+ shouldTruncate &&
143
+ displayText.length < children.length
144
+
145
+ const renderContent = effectiveWordLimit ? (
146
+ <p className={cn("text-fm-tertiary", classes.content)} data-nosnippet>
147
+ {displayText}
148
+ {showEllipsis && "...."}
149
+ {showLink && renderButton}
150
+ </p>
151
+ ) : (
152
+ <>
153
+ <p
154
+ data-nosnippet
155
+ className={cn(
156
+ open ? "line-clamp-none" : "line-clamp-3",
157
+ "text-fm-tertiary",
158
+ classes.content
159
+ )}
160
+ ref={ref}
161
+ >
162
+ {children}
163
+ </p>
164
+ {showLink && renderButton}
165
+ </>
166
+ )
167
+
168
+ return (
169
+ <section className={classes.root} data-testid="clamp-lines" data-nosnippet>
170
+ {renderContent}
171
+ </section>
172
+ )
173
+ }
174
+
175
+ export default ClampLines
@@ -0,0 +1,6 @@
1
+ export const meta = {
2
+ dependencies: {},
3
+ devDependencies: {},
4
+ internalDependencies: ["button"],
5
+ tokens: [],
6
+ }
@@ -9,6 +9,7 @@ export * from "./card"
9
9
  export * from "./char-count"
10
10
  export * from "./checkbox"
11
11
  export * from "./chip"
12
+ export * from "./clamp-lines"
12
13
  export * from "./collapsible"
13
14
  export * from "./command"
14
15
  export * from "./dialog"