@frontfactory/readmore 1.3.1 → 1.4.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/README.md +129 -26
- package/dist/readmore.css +1 -1
- package/dist/readmore.d.ts +68 -22
- package/dist/readmore.iife.js +1 -0
- package/dist/readmore.js +78 -43
- package/package.json +26 -20
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixel height) with an ellipsis and a
|
|
8
8
|
`Read more` / `Read less` toggle button.
|
|
9
9
|
|
|
10
|
-
- Zero dependencies, ~1.
|
|
10
|
+
- Zero dependencies, ~1.7 KB gzipped
|
|
11
11
|
- TypeScript types included
|
|
12
12
|
- CSS-driven clamping via `-webkit-line-clamp` or `max-height`
|
|
13
13
|
- The toggle button is only created when the text actually overflows
|
|
@@ -19,8 +19,23 @@ Lightweight, framework-agnostic plugin to clamp text to N lines (or a fixed pixe
|
|
|
19
19
|
npm install @frontfactory/readmore
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Requires Node.js
|
|
23
|
-
`ResizeObserver
|
|
22
|
+
Requires Node.js `^22.22.2 || >=24.15` to build from source. The published package is shipped as untranspiled ES2022
|
|
23
|
+
(private class members, logical assignment, `ResizeObserver`) and runs in Chrome/Edge 85+, Firefox 90+ and Safari 15+.
|
|
24
|
+
|
|
25
|
+
### From a CDN
|
|
26
|
+
|
|
27
|
+
Without a bundler, load the stylesheet and the IIFE build, which exposes `FrontFactory.ReadMore` (an existing
|
|
28
|
+
`FrontFactory` global is extended, not replaced). Pin the major version in production:
|
|
29
|
+
|
|
30
|
+
```html
|
|
31
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@frontfactory/readmore@1/dist/readmore.css">
|
|
32
|
+
<script src="https://cdn.jsdelivr.net/npm/@frontfactory/readmore@1/dist/readmore.iife.js"></script>
|
|
33
|
+
<script>
|
|
34
|
+
FrontFactory.ReadMore.init('.excerpt', {
|
|
35
|
+
lines: 3
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
```
|
|
24
39
|
|
|
25
40
|
## Playground
|
|
26
41
|
|
|
@@ -51,14 +66,23 @@ ReadMore.init('.excerpt', {
|
|
|
51
66
|
});
|
|
52
67
|
```
|
|
53
68
|
|
|
54
|
-
`ReadMore.init()` also accepts
|
|
69
|
+
`ReadMore.init()` also accepts an element, or any iterable or array-like of elements (`NodeList`, `HTMLCollection`,
|
|
70
|
+
array, `Set`…). A third argument restricts a selector to a container:
|
|
55
71
|
|
|
56
72
|
```ts
|
|
57
73
|
ReadMore.init(document.querySelectorAll<HTMLElement>('.excerpt'), {
|
|
58
74
|
lines: 3
|
|
59
75
|
});
|
|
76
|
+
|
|
77
|
+
// only the excerpts inside #news
|
|
78
|
+
ReadMore.init('.excerpt', {
|
|
79
|
+
lines: 3
|
|
80
|
+
}, document.querySelector('#news')!);
|
|
60
81
|
```
|
|
61
82
|
|
|
83
|
+
Calling `ReadMore.init()` again is safe: elements that are already initialized keep their instance (and its options) and
|
|
84
|
+
are returned as is, so you can re-run it after injecting new content.
|
|
85
|
+
|
|
62
86
|
Or instantiate against a single element:
|
|
63
87
|
|
|
64
88
|
```ts
|
|
@@ -70,19 +94,61 @@ if (el) new ReadMore(el, {
|
|
|
70
94
|
});
|
|
71
95
|
```
|
|
72
96
|
|
|
97
|
+
Each toggle also dispatches a bubbling `readmore:toggle` event on the element, handy for event delegation:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
document.addEventListener('readmore:toggle', (event) => {
|
|
101
|
+
const { expanded, instance } = event.detail;
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Pass `true` or `false` to `toggle()` to force a state, e.g. for an "Expand all" button. Instances already in that
|
|
106
|
+
state are left untouched and fire neither `onToggle` nor the event:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const instances = ReadMore.init('.excerpt');
|
|
110
|
+
|
|
111
|
+
expandAllButton.addEventListener('click', () => instances.forEach((instance) => instance.toggle(true)));
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If you change the content without changing the element's size (resizes are already tracked), call `refresh()` so the
|
|
115
|
+
button is mounted or removed as needed:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
instance.el.textContent = newText;
|
|
119
|
+
instance.refresh();
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### With a framework
|
|
123
|
+
|
|
124
|
+
The instance holds a button outside the element and is watched by a `ResizeObserver`, so call `destroy()` when the
|
|
125
|
+
component unmounts. For example with React:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const instance = new ReadMore(ref.current!, {
|
|
130
|
+
lines: 3
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return () => instance.destroy();
|
|
134
|
+
}, []);
|
|
135
|
+
```
|
|
136
|
+
|
|
73
137
|
## Options
|
|
74
138
|
|
|
75
|
-
| Option | Type
|
|
76
|
-
|
|
77
|
-
| `lines` | `number`
|
|
78
|
-
| `height` | `number`
|
|
79
|
-
| `moreText` | `string`
|
|
80
|
-
| `lessText` | `string`
|
|
81
|
-
| `
|
|
82
|
-
| `
|
|
83
|
-
| `
|
|
84
|
-
| `
|
|
85
|
-
| `
|
|
139
|
+
| Option | Type | Default | Description |
|
|
140
|
+
|-----------------|---------------------------------------------------|------------------|-----------------------------------------------------------------------------|
|
|
141
|
+
| `lines` | `number` | `3` | Number of lines to clamp to (min `1`). Ignored if `height` is set. |
|
|
142
|
+
| `height` | `number` | — | Max height in pixels (min `1`). Takes precedence over `lines`. |
|
|
143
|
+
| `moreText` | `string` | `'Read more'` | Label of the button when the text is collapsed. |
|
|
144
|
+
| `lessText` | `string` | `'Read less'` | Label of the button when the text is expanded. |
|
|
145
|
+
| `moreLabel` | `string` | — | `aria-label` of the button when collapsed. Should contain `moreText`. |
|
|
146
|
+
| `lessLabel` | `string` | — | `aria-label` of the button when expanded. Should contain `lessText`. |
|
|
147
|
+
| `buttonClass` | `string` | `'readmore-btn'` | Class applied to the toggle button. |
|
|
148
|
+
| `expandedClass` | `string` | `'is-expanded'` | Class applied to the target element while expanded. |
|
|
149
|
+
| `openingClass` | `string` | `'is-opening'` | Transient class applied while expanding, removed after transition. |
|
|
150
|
+
| `closingClass` | `string` | `'is-closing'` | Transient class applied while collapsing, removed after transition. |
|
|
151
|
+
| `onToggle` | `(expanded: boolean, instance: ReadMore) => void` | — | Callback fired after each toggle, before the `readmore:toggle` event. |
|
|
86
152
|
|
|
87
153
|
## API
|
|
88
154
|
|
|
@@ -90,13 +156,17 @@ if (el) new ReadMore(el, {
|
|
|
90
156
|
const instance = new ReadMore(element, options);
|
|
91
157
|
|
|
92
158
|
instance.el; // the target element
|
|
93
|
-
instance.options; // resolved options
|
|
159
|
+
instance.options; // resolved options (read-only)
|
|
94
160
|
instance.toggle(); // expand / collapse programmatically
|
|
161
|
+
instance.toggle(true); // force expand (false: force collapse); no-op if already in that state
|
|
95
162
|
instance.expanded; // boolean getter
|
|
96
|
-
instance.
|
|
163
|
+
instance.refresh(); // re-check the overflow after a content change
|
|
164
|
+
instance.destroy(); // remove button, classes, listeners and generated id; toggle() is a no-op afterwards
|
|
165
|
+
|
|
166
|
+
ReadMore.init(target, options, root); // target: selector | element | iterable of elements — returns ReadMore[], reusing existing instances
|
|
167
|
+
ReadMore.getInstance(element); // returns the ReadMore bound to an element, or undefined
|
|
97
168
|
|
|
98
|
-
|
|
99
|
-
ReadMore.getInstance(element); // returns the ReadMore bound to an element, or undefined
|
|
169
|
+
// event: 'readmore:toggle' (bubbles) — event.detail: { expanded, instance }
|
|
100
170
|
```
|
|
101
171
|
|
|
102
172
|
### Errors
|
|
@@ -107,13 +177,23 @@ The constructor throws instead of failing silently:
|
|
|
107
177
|
|--------------|-------------------------------------------------------------------------------|
|
|
108
178
|
| `TypeError` | `element` is not an `HTMLElement`. |
|
|
109
179
|
| `Error` | The element is already initialized — call `destroy()` before re-initializing. |
|
|
110
|
-
| `RangeError` | `lines` is
|
|
180
|
+
| `RangeError` | `lines` is not an integer `>= 1`, or `height` is not a finite number `> 0`. |
|
|
111
181
|
|
|
112
182
|
## Accessibility
|
|
113
183
|
|
|
114
184
|
When the button is mounted, it gets `aria-expanded` (kept in sync on every toggle) and `aria-controls`
|
|
115
|
-
pointing to the target element. If the element has no `id`, a unique one (`readmore-1`, `readmore-2`,
|
|
116
|
-
is generated and removed again on `destroy()`; an existing `id` is always preserved.
|
|
185
|
+
pointing to the target element. If the element has no `id`, a unique one (`readmore-1`, `readmore-2`, …, skipping ids
|
|
186
|
+
already used in the page) is generated and removed again on `destroy()`; an existing `id` is always preserved.
|
|
187
|
+
|
|
188
|
+
When several buttons share the same visible text, give each one a distinct accessible name with `moreLabel` and
|
|
189
|
+
`lessLabel`. Keep the visible text inside the label so voice control users can still activate the button by its text:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
new ReadMore(el, {
|
|
193
|
+
moreLabel: 'Read more about Product A',
|
|
194
|
+
lessLabel: 'Read less about Product A'
|
|
195
|
+
});
|
|
196
|
+
```
|
|
117
197
|
|
|
118
198
|
## How it works
|
|
119
199
|
|
|
@@ -121,15 +201,18 @@ The plugin sets a CSS custom property on the target element and toggles `.readmo
|
|
|
121
201
|
it sets `--readmore-lines` and the bundled stylesheet applies `-webkit-line-clamp`, producing an ellipsis at the end of
|
|
122
202
|
the last visible line. In **height mode** (`height` option), it sets `--readmore-height` and applies `max-height` via
|
|
123
203
|
the `.readmore-clamp--height` modifier class instead. The toggle button is mounted right after the element only when the
|
|
124
|
-
content actually overflows; a `ResizeObserver
|
|
125
|
-
fitting (or mounts it later if it starts overflowing).
|
|
204
|
+
content actually overflows; a single `ResizeObserver`, shared by every instance, watches the elements and unmounts the
|
|
205
|
+
button again if the text ends up fitting (or mounts it later if it starts overflowing). All resized elements are
|
|
206
|
+
measured before any button is added or removed, to avoid a layout per element.
|
|
126
207
|
|
|
127
208
|
### Transient classes for animations
|
|
128
209
|
|
|
129
210
|
In addition to `expandedClass` (which stays applied for the entire expanded state), the plugin applies short-lived
|
|
130
211
|
`openingClass` and `closingClass` markers on the target element at the moment of expand and collapse respectively. Each
|
|
131
|
-
is added when the toggle starts and removed automatically once the CSS transition on the element
|
|
132
|
-
tick if no transition is declared.
|
|
212
|
+
is added when the toggle starts and removed automatically once the longest CSS transition declared on the element
|
|
213
|
+
(duration + delay) has elapsed — or on the next tick if no transition is declared. The overflow check that mounts or
|
|
214
|
+
unmounts the button waits for that moment too, so the button stays in place during a collapse animation. Use them as
|
|
215
|
+
hooks to drive enter/leave animations:
|
|
133
216
|
|
|
134
217
|
```css
|
|
135
218
|
.is-opening {
|
|
@@ -140,3 +223,23 @@ tick if no transition is declared. Use them as hooks to drive enter/leave animat
|
|
|
140
223
|
/* collapse animation */
|
|
141
224
|
}
|
|
142
225
|
```
|
|
226
|
+
|
|
227
|
+
### Fade in height mode
|
|
228
|
+
|
|
229
|
+
Add the `readmore-fade` class to the element to fade out the bottom of the clamped text in height mode (lines mode
|
|
230
|
+
already ends with an ellipsis). The fade is removed while expanded; its size defaults to `2em` and can be changed with
|
|
231
|
+
the `--readmore-fade-size` custom property:
|
|
232
|
+
|
|
233
|
+
```html
|
|
234
|
+
<p class="excerpt readmore-fade" style="--readmore-fade-size: 3em">…</p>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Limitations
|
|
238
|
+
|
|
239
|
+
- While collapsed, `.readmore-clamp` sets `display: -webkit-box` (lines mode) or `display: block` (height mode), which
|
|
240
|
+
overrides the element's own `display`. To clamp a flex or grid container, wrap its content in an inner element and
|
|
241
|
+
initialize the plugin on that element instead.
|
|
242
|
+
- In lines mode, how `-webkit-line-clamp` handles several block children (e.g. multiple `<p>`) varies between browsers.
|
|
243
|
+
For rich content, prefer height mode.
|
|
244
|
+
- Lines mode cannot be animated: neither `display` nor `line-clamp` can transition. For an animated expand/collapse, use
|
|
245
|
+
height mode with a `max-height` transition.
|
package/dist/readmore.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.readmore-clamp{-webkit-line-clamp:var(--readmore-lines,3);line-clamp:var(--readmore-lines,3);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.readmore-clamp--height{max-height:var(--readmore-height);-webkit-line-clamp:none;line-clamp:none;display:block}
|
|
1
|
+
.readmore-clamp{-webkit-line-clamp:var(--readmore-lines,3);line-clamp:var(--readmore-lines,3);-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.readmore-clamp--height{max-height:var(--readmore-height);-webkit-line-clamp:none;line-clamp:none;display:block}.readmore-fade.readmore-clamp--height{-webkit-mask-image:linear-gradient(to bottom, #000 calc(100% - var(--readmore-fade-size,2em)), transparent);mask-image:linear-gradient(to bottom, #000 calc(100% - var(--readmore-fade-size,2em)), transparent)}
|
package/dist/readmore.d.ts
CHANGED
|
@@ -6,14 +6,14 @@ export interface ReadMoreOptions {
|
|
|
6
6
|
* Number of lines to clamp to. Ignored when {@link ReadMoreOptions.height} is set.
|
|
7
7
|
*
|
|
8
8
|
* @defaultValue 3
|
|
9
|
-
* @throws RangeError - if
|
|
9
|
+
* @throws RangeError - if not an integer greater than or equal to 1.
|
|
10
10
|
*/
|
|
11
11
|
lines?: number;
|
|
12
12
|
/**
|
|
13
13
|
* Max height in pixels. Takes precedence over {@link ReadMoreOptions.lines}
|
|
14
14
|
* and clamps with `max-height` instead of `-webkit-line-clamp`.
|
|
15
15
|
*
|
|
16
|
-
* @throws RangeError - if
|
|
16
|
+
* @throws RangeError - if not a finite number greater than 0.
|
|
17
17
|
*/
|
|
18
18
|
height?: number;
|
|
19
19
|
/**
|
|
@@ -28,6 +28,17 @@ export interface ReadMoreOptions {
|
|
|
28
28
|
* @defaultValue 'Read less'
|
|
29
29
|
*/
|
|
30
30
|
lessText?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Accessible name (`aria-label`) of the toggle button while the text is
|
|
33
|
+
* collapsed, e.g. `'Read more about Product A'` when several buttons share
|
|
34
|
+
* the same visible text. It should contain {@link ReadMoreOptions.moreText}.
|
|
35
|
+
*/
|
|
36
|
+
moreLabel?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Accessible name (`aria-label`) of the toggle button while the text is
|
|
39
|
+
* expanded. It should contain {@link ReadMoreOptions.lessText}.
|
|
40
|
+
*/
|
|
41
|
+
lessLabel?: string;
|
|
31
42
|
/**
|
|
32
43
|
* Class applied to the toggle button.
|
|
33
44
|
*
|
|
@@ -41,34 +52,51 @@ export interface ReadMoreOptions {
|
|
|
41
52
|
*/
|
|
42
53
|
expandedClass?: string;
|
|
43
54
|
/**
|
|
44
|
-
* Transient class applied when expanding, removed once the CSS
|
|
45
|
-
* on the element
|
|
55
|
+
* Transient class applied when expanding, removed once the longest CSS
|
|
56
|
+
* transition on the element (duration + delay) has elapsed — or on the next
|
|
57
|
+
* tick if no transition is declared.
|
|
46
58
|
*
|
|
47
59
|
* @defaultValue 'is-opening'
|
|
48
60
|
*/
|
|
49
61
|
openingClass?: string;
|
|
50
62
|
/**
|
|
51
|
-
* Transient class applied when collapsing, removed once the CSS
|
|
52
|
-
* on the element
|
|
63
|
+
* Transient class applied when collapsing, removed once the longest CSS
|
|
64
|
+
* transition on the element (duration + delay) has elapsed — or on the next
|
|
65
|
+
* tick if no transition is declared.
|
|
53
66
|
*
|
|
54
67
|
* @defaultValue 'is-closing'
|
|
55
68
|
*/
|
|
56
69
|
closingClass?: string;
|
|
57
70
|
/**
|
|
58
|
-
* Called after each toggle with the resulting state
|
|
71
|
+
* Called after each toggle with the resulting state, right before the
|
|
72
|
+
* `readmore:toggle` event is dispatched.
|
|
59
73
|
*
|
|
60
74
|
* @param expanded - `true` once expanded, `false` once collapsed.
|
|
75
|
+
* @param instance - The instance that was toggled.
|
|
61
76
|
*/
|
|
62
|
-
onToggle?: (expanded: boolean) => void;
|
|
77
|
+
onToggle?: (expanded: boolean, instance: ReadMore) => void;
|
|
63
78
|
}
|
|
79
|
+
type OptionalKeys = 'height' | 'moreLabel' | 'lessLabel' | 'onToggle';
|
|
64
80
|
/**
|
|
65
81
|
* {@link ReadMoreOptions} merged with the defaults, as exposed on
|
|
66
82
|
* {@link ReadMore.options}.
|
|
67
83
|
*/
|
|
68
|
-
export type ResolvedOptions = Required<Omit<ReadMoreOptions,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
export type ResolvedOptions = Required<Omit<ReadMoreOptions, OptionalKeys>> & Pick<ReadMoreOptions, OptionalKeys>;
|
|
85
|
+
/** `detail` of the `readmore:toggle` event. */
|
|
86
|
+
export interface ReadMoreToggleDetail {
|
|
87
|
+
/** `true` once expanded, `false` once collapsed. */
|
|
88
|
+
expanded: boolean;
|
|
89
|
+
/** The instance that was toggled. */
|
|
90
|
+
instance: ReadMore;
|
|
91
|
+
}
|
|
92
|
+
declare global {
|
|
93
|
+
interface HTMLElementEventMap {
|
|
94
|
+
'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
|
|
95
|
+
}
|
|
96
|
+
interface DocumentEventMap {
|
|
97
|
+
'readmore:toggle': CustomEvent<ReadMoreToggleDetail>;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
72
100
|
/**
|
|
73
101
|
* Clamps an element to a number of lines (or a fixed height) and mounts a
|
|
74
102
|
* `Read more` / `Read less` button right after it, but only while the content
|
|
@@ -87,33 +115,50 @@ export declare class ReadMore {
|
|
|
87
115
|
/** The clamped element. */
|
|
88
116
|
readonly el: HTMLElement;
|
|
89
117
|
/** The options this instance was built with, merged with the defaults. */
|
|
90
|
-
readonly options: ResolvedOptions
|
|
118
|
+
readonly options: Readonly<ResolvedOptions>;
|
|
91
119
|
/**
|
|
92
120
|
* @param element - The element to clamp.
|
|
93
121
|
* @param options - See {@link ReadMoreOptions}.
|
|
94
122
|
* @throws TypeError - if `element` is not an `HTMLElement`.
|
|
95
123
|
* @throws Error - if `element` is already initialized; call
|
|
96
124
|
* {@link ReadMore.destroy} first.
|
|
97
|
-
* @throws RangeError - if `lines` is
|
|
98
|
-
* or
|
|
125
|
+
* @throws RangeError - if `lines` is not an integer greater than or equal
|
|
126
|
+
* to 1, or `height` is not a finite number greater than 0.
|
|
99
127
|
*/
|
|
100
128
|
constructor(element: HTMLElement, options?: ReadMoreOptions);
|
|
101
129
|
/**
|
|
102
|
-
* Instantiates {@link ReadMore} on every matching element.
|
|
130
|
+
* Instantiates {@link ReadMore} on every matching element. Elements that are
|
|
131
|
+
* already initialized are left untouched and their existing instance is
|
|
132
|
+
* returned, so it is safe to call again after new content is added.
|
|
103
133
|
*
|
|
104
|
-
* @param target - A CSS selector,
|
|
105
|
-
*
|
|
134
|
+
* @param target - A CSS selector, an element, or any iterable or array-like
|
|
135
|
+
* of elements (`NodeList`, `HTMLCollection`, array, `Set`…).
|
|
136
|
+
* @param options - See {@link ReadMoreOptions}. Not applied to elements
|
|
137
|
+
* that are already initialized.
|
|
138
|
+
* @param root - Where to look for `target` when it is a selector.
|
|
106
139
|
* @returns One instance per element, in document order.
|
|
107
140
|
*/
|
|
108
|
-
static init(target: string |
|
|
141
|
+
static init(target: string | HTMLElement | Iterable<HTMLElement> | ArrayLike<HTMLElement>, options?: ReadMoreOptions, root?: ParentNode): ReadMore[];
|
|
109
142
|
/** Whether the text is currently expanded. */
|
|
110
143
|
get expanded(): boolean;
|
|
111
144
|
/**
|
|
112
145
|
* Expands or collapses the text, updating the button label, the
|
|
113
146
|
* `aria-expanded` attribute and the state classes, then calls
|
|
114
|
-
* {@link ReadMoreOptions.onToggle}
|
|
147
|
+
* {@link ReadMoreOptions.onToggle} and dispatches a bubbling
|
|
148
|
+
* `readmore:toggle` event on the element. Does nothing once destroyed.
|
|
149
|
+
*
|
|
150
|
+
* @param force - `true` to expand, `false` to collapse; the state is
|
|
151
|
+
* inverted when omitted. Does nothing if the text is already in the
|
|
152
|
+
* requested state.
|
|
153
|
+
*/
|
|
154
|
+
toggle(force?: boolean): void;
|
|
155
|
+
/**
|
|
156
|
+
* Checks again whether the content overflows, mounting or unmounting the
|
|
157
|
+
* button. Resizes of the element are already tracked; call it after
|
|
158
|
+
* changing the content in a way that keeps the element's size. Does nothing
|
|
159
|
+
* while expanded, during a toggle transition, or once destroyed.
|
|
115
160
|
*/
|
|
116
|
-
|
|
161
|
+
refresh(): void;
|
|
117
162
|
/**
|
|
118
163
|
* @param element - An element possibly initialized by this plugin.
|
|
119
164
|
* @returns The instance bound to it, or `undefined`.
|
|
@@ -122,7 +167,8 @@ export declare class ReadMore {
|
|
|
122
167
|
/**
|
|
123
168
|
* Restores the element: removes the button, the classes, the CSS custom
|
|
124
169
|
* properties, the listeners and the generated `id`. The element can then be
|
|
125
|
-
* initialized again.
|
|
170
|
+
* initialized again. Calling it again does nothing.
|
|
126
171
|
*/
|
|
127
172
|
destroy(): void;
|
|
128
173
|
}
|
|
174
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=`readmore-clamp`,n=`readmore-clamp--height`,r=new WeakMap,i=0,a={lines:3,moreText:`Read more`,lessText:`Read less`,buttonClass:`readmore-btn`,expandedClass:`is-expanded`,openingClass:`is-opening`,closingClass:`is-closing`};function o(e){return e.split(`,`).map(e=>parseFloat(e)*1e3||0)}e.ReadMore=class e{static#e;el;options;#t=null;#n=!1;#r=!1;#i=null;#a=()=>this.toggle();get#o(){return r.get(this.el)!==this}constructor(e,t={}){if(!(e instanceof HTMLElement))throw TypeError(`ReadMore: an HTMLElement is required.`);if(r.has(e))throw Error(`ReadMore: element is already initialized. Call destroy() first.`);this.el=e,this.options={...a,...Object.fromEntries(Object.entries(t).filter(e=>e[1]!==void 0))};let{height:n,lines:i}=this.options;if(n!=null&&!(Number.isFinite(n)&&n>0))throw RangeError(`ReadMore: height must be a finite number greater than 0.`);if(!(Number.isInteger(i)&&i>=1))throw RangeError(`ReadMore: lines must be an integer of at least 1.`);r.set(e,this),this.#c()}static init(t,n,i=document){let a=typeof t==`string`?i.querySelectorAll(t):t instanceof HTMLElement?[t]:t;return Array.from(a).map(t=>r.get(t)??new e(t,n))}get expanded(){return this.#n}toggle(e){let r=typeof e==`boolean`?e:!this.#n;if(this.#o||r===this.#n)return;this.#n=r,this.el.classList.toggle(t,!this.#n),this.options.height!=null&&this.el.classList.toggle(n,!this.#n),this.el.classList.toggle(this.options.expandedClass,this.#n),this.#m();let i=this.#n?this.options.openingClass:this.options.closingClass,a=this.#n?this.options.closingClass:this.options.openingClass;this.el.classList.remove(a),this.#d(i),this.options.onToggle?.(this.#n,this),this.el.dispatchEvent(new CustomEvent(`readmore:toggle`,{bubbles:!0,detail:{expanded:this.#n,instance:this}}))}refresh(){this.#u(this.#l())}static getInstance(e){return r.get(e)}destroy(){this.#o||(r.delete(this.el),e.#e?.unobserve(this.el),this.#i!==null&&(clearTimeout(this.#i),this.#i=null),this.#h(),this.el.classList.remove(t,n,this.options.expandedClass,this.options.openingClass,this.options.closingClass),this.el.style.removeProperty(`--readmore-lines`),this.el.style.removeProperty(`--readmore-height`),this.#r&&=(this.el.removeAttribute(`id`),!1))}static#s(e){let t=new Map;for(let n of e){let e=r.get(n.target);e&&t.set(e,e.#l())}t.forEach((e,t)=>t.#u(e))}#c(){this.options.height==null?(this.el.style.setProperty(`--readmore-lines`,String(this.options.lines)),this.el.classList.add(t)):(this.el.style.setProperty(`--readmore-height`,`${this.options.height}px`),this.el.classList.add(t,n)),e.#e??=new ResizeObserver(e.#s),e.#e.observe(this.el),this.refresh()}#l(){return this.el.scrollHeight-this.el.clientHeight>1}#u(e){this.#n||this.#i!==null||this.#o||(e&&!this.#t?this.#p():!e&&this.#t&&this.#h())}#d(e){this.#i!==null&&clearTimeout(this.#i),this.el.classList.add(e),this.#i=setTimeout(()=>{this.#i=null,this.el.classList.remove(e),this.refresh()},this.#f())}#f(){let e=getComputedStyle(this.el),t=o(e.transitionDuration),n=o(e.transitionDelay),r=0;for(let e=0;e<Math.max(t.length,n.length);e++)r=Math.max(r,t[e%t.length]+n[e%n.length]);return r}#p(){if(!this.el.id){let e;do e=`readmore-${++i}`;while(document.getElementById(e));this.el.id=e,this.#r=!0}let e=document.createElement(`button`);e.type=`button`,e.className=this.options.buttonClass,e.setAttribute(`aria-controls`,this.el.id),e.addEventListener(`click`,this.#a),this.#t=e,this.#m(),this.el.insertAdjacentElement(`afterend`,e)}#m(){if(!this.#t)return;let{moreText:e,lessText:t,moreLabel:n,lessLabel:r}=this.options,i=this.#n?r:n;this.#t.textContent=this.#n?t:e,this.#t.setAttribute(`aria-expanded`,String(this.#n)),i?this.#t.setAttribute(`aria-label`,i):this.#t.removeAttribute(`aria-label`)}#h(){this.#t&&=(this.#t.removeEventListener(`click`,this.#a),this.#t.remove(),null)}}})(this.FrontFactory=this.FrontFactory||{});
|
package/dist/readmore.js
CHANGED
|
@@ -7,74 +7,109 @@ var e = "readmore-clamp", t = "readmore-clamp--height", n = /* @__PURE__ */ new
|
|
|
7
7
|
expandedClass: "is-expanded",
|
|
8
8
|
openingClass: "is-opening",
|
|
9
9
|
closingClass: "is-closing"
|
|
10
|
-
}
|
|
10
|
+
};
|
|
11
|
+
function a(e) {
|
|
12
|
+
return e.split(",").map((e) => parseFloat(e) * 1e3 || 0);
|
|
13
|
+
}
|
|
14
|
+
var o = class o {
|
|
15
|
+
static #e;
|
|
11
16
|
el;
|
|
12
17
|
options;
|
|
13
|
-
#
|
|
14
|
-
#t = !1;
|
|
18
|
+
#t = null;
|
|
15
19
|
#n = !1;
|
|
16
|
-
#r;
|
|
20
|
+
#r = !1;
|
|
17
21
|
#i = null;
|
|
18
22
|
#a = () => this.toggle();
|
|
23
|
+
get #o() {
|
|
24
|
+
return n.get(this.el) !== this;
|
|
25
|
+
}
|
|
19
26
|
constructor(e, t = {}) {
|
|
20
27
|
if (!(e instanceof HTMLElement)) throw TypeError("ReadMore: an HTMLElement is required.");
|
|
21
28
|
if (n.has(e)) throw Error("ReadMore: element is already initialized. Call destroy() first.");
|
|
22
|
-
|
|
29
|
+
this.el = e, this.options = {
|
|
23
30
|
...i,
|
|
24
|
-
...t
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
...Object.fromEntries(Object.entries(t).filter((e) => e[1] !== void 0))
|
|
32
|
+
};
|
|
33
|
+
let { height: r, lines: a } = this.options;
|
|
34
|
+
if (r != null && !(Number.isFinite(r) && r > 0)) throw RangeError("ReadMore: height must be a finite number greater than 0.");
|
|
35
|
+
if (!(Number.isInteger(a) && a >= 1)) throw RangeError("ReadMore: lines must be an integer of at least 1.");
|
|
36
|
+
n.set(e, this), this.#c();
|
|
28
37
|
}
|
|
29
|
-
static init(e, t) {
|
|
30
|
-
let
|
|
31
|
-
return Array.from(
|
|
38
|
+
static init(e, t, r = document) {
|
|
39
|
+
let i = typeof e == "string" ? r.querySelectorAll(e) : e instanceof HTMLElement ? [e] : e;
|
|
40
|
+
return Array.from(i).map((e) => n.get(e) ?? new o(e, t));
|
|
32
41
|
}
|
|
33
42
|
get expanded() {
|
|
34
|
-
return this.#
|
|
43
|
+
return this.#n;
|
|
44
|
+
}
|
|
45
|
+
toggle(n) {
|
|
46
|
+
let r = typeof n == "boolean" ? n : !this.#n;
|
|
47
|
+
if (this.#o || r === this.#n) return;
|
|
48
|
+
this.#n = r, this.el.classList.toggle(e, !this.#n), this.options.height != null && this.el.classList.toggle(t, !this.#n), this.el.classList.toggle(this.options.expandedClass, this.#n), this.#m();
|
|
49
|
+
let i = this.#n ? this.options.openingClass : this.options.closingClass, a = this.#n ? this.options.closingClass : this.options.openingClass;
|
|
50
|
+
this.el.classList.remove(a), this.#d(i), this.options.onToggle?.(this.#n, this), this.el.dispatchEvent(new CustomEvent("readmore:toggle", {
|
|
51
|
+
bubbles: !0,
|
|
52
|
+
detail: {
|
|
53
|
+
expanded: this.#n,
|
|
54
|
+
instance: this
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
35
57
|
}
|
|
36
|
-
|
|
37
|
-
this.#
|
|
38
|
-
let n = this.#t ? this.options.openingClass : this.options.closingClass, r = this.#t ? this.options.closingClass : this.options.openingClass;
|
|
39
|
-
this.el.classList.remove(r), this.#l(n), this.#t || this.#c(), this.options.onToggle?.(this.#t);
|
|
58
|
+
refresh() {
|
|
59
|
+
this.#u(this.#l());
|
|
40
60
|
}
|
|
41
61
|
static getInstance(e) {
|
|
42
62
|
return n.get(e);
|
|
43
63
|
}
|
|
44
64
|
destroy() {
|
|
45
|
-
n.delete(this.el), this
|
|
65
|
+
this.#o || (n.delete(this.el), o.#e?.unobserve(this.el), this.#i !== null && (clearTimeout(this.#i), this.#i = null), this.#h(), this.el.classList.remove(e, t, this.options.expandedClass, this.options.openingClass, this.options.closingClass), this.el.style.removeProperty("--readmore-lines"), this.el.style.removeProperty("--readmore-height"), this.#r &&= (this.el.removeAttribute("id"), !1));
|
|
46
66
|
}
|
|
47
|
-
#
|
|
48
|
-
|
|
67
|
+
static #s(e) {
|
|
68
|
+
let t = /* @__PURE__ */ new Map();
|
|
69
|
+
for (let r of e) {
|
|
70
|
+
let e = n.get(r.target);
|
|
71
|
+
e && t.set(e, e.#l());
|
|
72
|
+
}
|
|
73
|
+
t.forEach((e, t) => t.#u(e));
|
|
74
|
+
}
|
|
75
|
+
#c() {
|
|
76
|
+
this.options.height == null ? (this.el.style.setProperty("--readmore-lines", String(this.options.lines)), this.el.classList.add(e)) : (this.el.style.setProperty("--readmore-height", `${this.options.height}px`), this.el.classList.add(e, t)), o.#e ??= new ResizeObserver(o.#s), o.#e.observe(this.el), this.refresh();
|
|
49
77
|
}
|
|
50
|
-
#
|
|
78
|
+
#l() {
|
|
51
79
|
return this.el.scrollHeight - this.el.clientHeight > 1;
|
|
52
80
|
}
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
let t = () => this.el.classList.remove(e);
|
|
61
|
-
if (!getComputedStyle(this.el).transitionDuration.split(",").some((e) => parseFloat(e) > 0)) {
|
|
62
|
-
setTimeout(t, 0);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
let n = (e) => {
|
|
66
|
-
e.target === this.el && (this.el.removeEventListener("transitionend", n), this.#i = null, t());
|
|
67
|
-
};
|
|
68
|
-
this.#i = n, this.el.addEventListener("transitionend", n);
|
|
81
|
+
#u(e) {
|
|
82
|
+
this.#n || this.#i !== null || this.#o || (e && !this.#t ? this.#p() : !e && this.#t && this.#h());
|
|
83
|
+
}
|
|
84
|
+
#d(e) {
|
|
85
|
+
this.#i !== null && clearTimeout(this.#i), this.el.classList.add(e), this.#i = setTimeout(() => {
|
|
86
|
+
this.#i = null, this.el.classList.remove(e), this.refresh();
|
|
87
|
+
}, this.#f());
|
|
69
88
|
}
|
|
70
|
-
#
|
|
71
|
-
|
|
89
|
+
#f() {
|
|
90
|
+
let e = getComputedStyle(this.el), t = a(e.transitionDuration), n = a(e.transitionDelay), r = 0;
|
|
91
|
+
for (let e = 0; e < Math.max(t.length, n.length); e++) r = Math.max(r, t[e % t.length] + n[e % n.length]);
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
#p() {
|
|
95
|
+
if (!this.el.id) {
|
|
96
|
+
let e;
|
|
97
|
+
do
|
|
98
|
+
e = `readmore-${++r}`;
|
|
99
|
+
while (document.getElementById(e));
|
|
100
|
+
this.el.id = e, this.#r = !0;
|
|
101
|
+
}
|
|
72
102
|
let e = document.createElement("button");
|
|
73
|
-
e.type = "button", e.className = this.options.buttonClass, e.
|
|
103
|
+
e.type = "button", e.className = this.options.buttonClass, e.setAttribute("aria-controls", this.el.id), e.addEventListener("click", this.#a), this.#t = e, this.#m(), this.el.insertAdjacentElement("afterend", e);
|
|
104
|
+
}
|
|
105
|
+
#m() {
|
|
106
|
+
if (!this.#t) return;
|
|
107
|
+
let { moreText: e, lessText: t, moreLabel: n, lessLabel: r } = this.options, i = this.#n ? r : n;
|
|
108
|
+
this.#t.textContent = this.#n ? t : e, this.#t.setAttribute("aria-expanded", String(this.#n)), i ? this.#t.setAttribute("aria-label", i) : this.#t.removeAttribute("aria-label");
|
|
74
109
|
}
|
|
75
|
-
#
|
|
76
|
-
this.#
|
|
110
|
+
#h() {
|
|
111
|
+
this.#t &&= (this.#t.removeEventListener("click", this.#a), this.#t.remove(), null);
|
|
77
112
|
}
|
|
78
113
|
};
|
|
79
114
|
//#endregion
|
|
80
|
-
export {
|
|
115
|
+
export { o as ReadMore };
|
package/package.json
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontfactory/readmore",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Zero-dependency plugin to truncate text to N lines or a fixed height, with an ellipsis and an accessible read more / read less toggle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
|
+
"lint": "eslint .",
|
|
8
9
|
"lint:js": "eslint --fix .",
|
|
9
10
|
"build": "vite build",
|
|
10
11
|
"preview": "vite preview",
|
|
11
12
|
"typecheck": "tsc --noEmit",
|
|
12
|
-
"test": "vitest run",
|
|
13
|
+
"test": "vitest run --project unit",
|
|
14
|
+
"test:browser": "vitest run --project browser --outputFile.junit=junit-browser.xml",
|
|
13
15
|
"test:watch": "vitest",
|
|
14
|
-
"test:coverage": "vitest run --coverage",
|
|
16
|
+
"test:coverage": "vitest run --project unit --coverage",
|
|
15
17
|
"prepare": "husky",
|
|
16
18
|
"prepack": "npm run build",
|
|
17
19
|
"release": "semantic-release"
|
|
18
20
|
},
|
|
19
21
|
"engines": {
|
|
20
|
-
"node": ">=
|
|
22
|
+
"node": "^22.22.2 || >=24.15"
|
|
21
23
|
},
|
|
22
24
|
"main": "./dist/readmore.js",
|
|
23
25
|
"types": "./dist/readmore.d.ts",
|
|
26
|
+
"unpkg": "./dist/readmore.iife.js",
|
|
27
|
+
"jsdelivr": "./dist/readmore.iife.js",
|
|
24
28
|
"exports": {
|
|
25
29
|
".": {
|
|
26
30
|
"types": "./dist/readmore.d.ts",
|
|
@@ -31,25 +35,27 @@
|
|
|
31
35
|
"./package.json": "./package.json"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
34
|
-
"@commitlint/cli": "^
|
|
35
|
-
"@commitlint/config-conventional": "^
|
|
38
|
+
"@commitlint/cli": "^21.2.2",
|
|
39
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
36
40
|
"@front-factory/eslint-config": "^2.4.2",
|
|
37
|
-
"@semantic-release/changelog": "^
|
|
38
|
-
"@semantic-release/git": "^
|
|
39
|
-
"@semantic-release/github": "^12.0.
|
|
40
|
-
"@vitest/
|
|
41
|
+
"@semantic-release/changelog": "^7.0.0",
|
|
42
|
+
"@semantic-release/git": "^11.0.1",
|
|
43
|
+
"@semantic-release/github": "^12.0.9",
|
|
44
|
+
"@vitest/browser-playwright": "^5.0.0",
|
|
45
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
41
46
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
42
|
-
"eslint": "^10.
|
|
47
|
+
"eslint": "^10.10.0",
|
|
43
48
|
"husky": "^9.1.7",
|
|
44
|
-
"jsdom": "^
|
|
45
|
-
"lightningcss": "^1.
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"typescript
|
|
49
|
-
"
|
|
50
|
-
"vite
|
|
51
|
-
"vite-plugin-
|
|
52
|
-
"
|
|
49
|
+
"jsdom": "^30.0.1",
|
|
50
|
+
"lightningcss": "^1.33.0",
|
|
51
|
+
"playwright": "^1.63.0",
|
|
52
|
+
"semantic-release": "^25.0.9",
|
|
53
|
+
"typescript": "^6.0.3",
|
|
54
|
+
"typescript-eslint": "^8.70.0",
|
|
55
|
+
"vite": "^8.3.0",
|
|
56
|
+
"vite-plugin-dts": "^5.1.0",
|
|
57
|
+
"vite-plugin-static-copy": "^4.1.1",
|
|
58
|
+
"vitest": "^5.0.0"
|
|
53
59
|
},
|
|
54
60
|
"keywords": [
|
|
55
61
|
"readmore",
|