frontacles 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -6,9 +6,23 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
6
6
 
7
7
  Nothing for now.
8
8
 
9
+ <!-- Compare with [last published version](https://github.com/frontacles/frontacles/compare/0.5.0...main). -->
10
+
9
11
  <!-- ⚠️ Before a new release, make sure the documentation doesn't contain any **unreleased** mention. -->
10
12
 
11
- <!-- Compare with [last published version](https://github.com/frontacles/frontacles/compare/0.4.0...main). -->
13
+ ## v0.5.0 (2026-01-24)
14
+
15
+ Compare with [last published version](https://github.com/frontacles/frontacles/compare/0.4.0...0.5.0).
16
+
17
+ ### New
18
+
19
+ - Add [`setAttributes`](./README.md#setattributes), a function to update HTML attributes on any number of elements.
20
+
21
+ ### Under the hood
22
+
23
+ - Setup tests for DOM utilities using Playwright via Vitest browser mode.
24
+ - Migrate types testing from `tsd` to TSTyche.
25
+ - Bump Node version from 20 to 22.
12
26
 
13
27
  ## v0.4.0 (2025-03-08)
14
28
 
package/README.md CHANGED
@@ -7,14 +7,131 @@ Cool utilities for front-end development (and potentially for Node).
7
7
 
8
8
  We love tiny bits (using brotli compression):
9
9
 
10
- | categories | util | size |
11
- | --- | --- | --- |
12
- | math | [`clamp`](#clamp) | 35 B |
13
- | math | [`round`](#round) | 38 B |
14
- | string | [`capitalize`](#capitalize) | 40 B |
15
- | url | [`isEmail`](#isemail) | 86 B |
16
- | url | [`Email`](#email) | 173 B |
17
- | | **everything** | 328 B |
10
+ | category | util | size | description |
11
+ | --- | --- | --- | --- |
12
+ | DOM | [`setAttributes`](#setattributes) | 338 B | Updates attributes of HTML or SVG element(s). |
13
+ | math | [`clamp`](#clamp) | 35 B | Make sure a number stays in a given range. |
14
+ | math | [`round`](#round) | 38 B | Round a number to a given precision. |
15
+ | string | [`capitalize`](#capitalize) | 40 B | Capitalize the first letter of a string. |
16
+ | URL | [`isEmail`](#isemail) | 86 B | Wheither a variable is a valid email address. |
17
+ | URL | [`Email`](#email) | 173 B | An `Email` object with validation and separate access to an email username and domain. |
18
+ | | **everything** | 645 B | |
19
+
20
+ ## DOM utils
21
+
22
+ ### `setAttributes`
23
+
24
+ Updates attributes of HTML element(s).
25
+
26
+ ```js
27
+ import { setAttributes } from 'frontacles/dom'
28
+
29
+ const widget = getElementById('animal-widget')
30
+
31
+ // `<div name="Animal widget" loading="true">`
32
+ setAttributes(widget, {
33
+ name: 'Animal widget'
34
+ loading: true,
35
+ })
36
+ ```
37
+
38
+ To remove an attribute, set its value to `false`, `null` or `undefined`.
39
+
40
+ ```js
41
+ // `<div name="Animal widget">`
42
+ setAttributes(widget, { loading: false })
43
+
44
+ // `<div name="Animal widget" loading="false">`
45
+ setAttributes(widget, { loading: 'false' })
46
+ ```
47
+
48
+ `setAttributes` also accepts multiple elements (array or [HTML collection](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCollection)):
49
+
50
+ ```js
51
+ const animals = widget.getElementsbyClassName('.list-item')
52
+
53
+ // `<li class="list-item" data-cat="animals">cat</li>`
54
+ setAttributes(animals, { 'data-cat': 'animals' })
55
+ ```
56
+
57
+ When an attribute is an object, its properties are converted to `attrName-propName` attributes, making it helpful for any bulk update of `aria-*` or `data-*` attributes:
58
+
59
+ ```js
60
+ setAttributes(el, {
61
+ loading: true,
62
+ aria: {
63
+ busy: true,
64
+ live: 'polite',
65
+ },
66
+ data: {
67
+ category: 'boats',
68
+ 'max-items': 12,
69
+ },
70
+ user: {
71
+ id: 4,
72
+ name: 'Liz',
73
+ },
74
+ })
75
+ ```
76
+
77
+ The previous example gives:
78
+
79
+ ```html
80
+ <div
81
+ loading="true"
82
+ aria-busy="true" aria-live="polite"
83
+ data-category="boats" data-max-items="12"
84
+ user-id="4" user-name="Liz"
85
+ >
86
+ ```
87
+
88
+ Like for non-object attributes, using `false`, `null` or `undefined` as property value will remove the matching attribute from the HTML:
89
+
90
+ ```js
91
+ setAttributes(el, { data: { 'max-items': null }}) // no more `data-max-items`
92
+ ```
93
+
94
+ > [!NOTE]
95
+ > `data` is using [`dataset`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset) under the hood, but differs from it: `setAttributes` removes any attribute with a `null` value while `dataset` turn `null` into a string. In the previous example, `el.dataset.maxItems = null` would have given `data-max-items="null"` instead of removing the attribute.
96
+
97
+ `class` updates… CSS classes. It can be an array of CSS classes, a string containing one or more (space-separated) classes, or an object in the form of `{ className: state }`, defining which classes should be added or removed from the element.
98
+
99
+ ```js
100
+ // <div class="btn btn--xl">
101
+ setAttributes(el, { class: { btn: true, 'btn--xl': true }})
102
+
103
+ // <div class="btn btn--xl card__btn">
104
+ setAttributes(el, { class: ['card__btn'] })
105
+
106
+ // <div class="btn btn--xl card__btn card__btn--special">
107
+ setAttributes(el, { class: 'card__btn--special' })
108
+
109
+ // <div class="btn btn--xl card__btn card__btn--special card__btn--1 card__btn--2">
110
+ setAttributes(el, { class: 'card__btn--1 card__btn--2' })
111
+
112
+ // <div class="btn card__btn card__btn--special card__btn--1 card__btn--2">
113
+ setAttributes(el, { class: { 'btn--xl': false }})
114
+ ```
115
+
116
+ When `style` is an object, it behaves like [`HTMLElement.style`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/style). When it’s a string, it completely replaces the attribute.
117
+
118
+ ```js
119
+ // <div style="color: red; opacity: 0.9">
120
+ setAttributes(el, {
121
+ style: {
122
+ color: 'red',
123
+ opacity: .9
124
+ }
125
+ })
126
+
127
+ // <div style="color: red;">
128
+ setAttributes(el, { style: { opacity: null }})
129
+
130
+ // <div style="gap: 2px; opacity: .9;">
131
+ setAttributes(el, { style: 'gap: 2px; opacity: .9;')
132
+ ```
133
+
134
+ `setAttributes` also works on any SVG or descendant elements.
18
135
 
19
136
  ## Math utils
20
137
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontacles",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Front-end utilities for artisans",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -12,21 +12,22 @@
12
12
  },
13
13
  "types": "./types/index.d.ts",
14
14
  "engines": {
15
- "node": ">=20.18.1"
15
+ "node": ">=22.12.0"
16
16
  },
17
17
  "scripts": {
18
18
  "types": "tsc && dts-bundle-generator --silent --no-banner=true -o types/index.d.ts types-transitive/index.d.ts",
19
19
  "posttypes": "node scripts/inject-jsdoc.mjs",
20
20
  "test": "vitest run",
21
- "test:types": "npm exec tsd",
21
+ "test:types": "tstyche",
22
22
  "test:ui": "vitest --ui --coverage.enabled --coverage.exclude=types",
23
+ "test:watch": "vitest",
23
24
  "coverage": "vitest run --coverage --coverage.exclude=types",
24
- "watch": "vitest watch",
25
+ "watch": "npm run test:watch",
25
26
  "build": "echo \"Nothing to build, this command is only here to please size-limit GitHub action\" && exit 0",
26
27
  "size": "size-limit",
27
28
  "lint": "eslint",
28
29
  "lint:fix": "eslint --fix",
29
- "lint:inspect": "eslint --inspect-config "
30
+ "lint:inspect": "eslint --inspect-config"
30
31
  },
31
32
  "files": [
32
33
  "CHANGELOG.md",
@@ -56,16 +57,18 @@
56
57
  },
57
58
  "devDependencies": {
58
59
  "@eslint/js": "^9.9.0",
59
- "@size-limit/preset-small-lib": "^11.1.4",
60
- "@vitest/coverage-v8": "^3",
60
+ "@size-limit/preset-small-lib": "^12",
61
+ "@vitest/browser-playwright": "^4",
62
+ "@vitest/coverage-v8": "^4",
61
63
  "@vitest/eslint-plugin": "^1.0.1",
62
- "@vitest/ui": "^3",
64
+ "@vitest/ui": "^4",
63
65
  "dts-bundle-generator": "^9.5.1",
64
66
  "eslint": "^9.9.0",
65
- "size-limit": "^11.1.4",
66
- "tsd": "^0.31.1",
67
+ "globals": "^17",
68
+ "size-limit": "^12",
69
+ "tstyche": "^6.0.2",
67
70
  "typescript": "^5.5.4",
68
- "typescript-eslint": "^8.0.1",
69
- "vitest": "^3"
71
+ "typescript-eslint": "^8",
72
+ "vitest": "^4"
70
73
  }
71
74
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Bulk update attributes of HTML element(s).
3
+ *
4
+ * Specific behaviour for the following types of values:
5
+ *
6
+ * - boolean, `null` and `undefined`:
7
+ * - `true` adds the attribute to the HTML, with no value (`<p hidden="">`)
8
+ * - `false`, `null` and `undefined` removes the attribute from the HTML
9
+ * - objects:
10
+ * - `class` will add/remove CSS classes from `{ className: state }` entries
11
+ * - `data` is pushed to `Element.dataset`, but a `data` property that is
12
+ * `null` or `undefined` removes its `data-*` attribute from the HTML:
13
+ * `{ data: { id: 1, enabled: false }}` gives `<p data-id="1">`
14
+ * - `style` is pushed to `Element.style` (inline CSS):
15
+ * `{ style: { color: 'red', gap: '2px' }}` gives
16
+ * `<p style="color: red; gap: 2px;">`
17
+ * - all other objects are converted the same way:
18
+ * `aria: { label: 'Hello!' }` gives `<p aria-label="Hello!">`
19
+ * - `class` can also be a string with one or more classes (space-separated),
20
+ * or an array of classes.
21
+ *
22
+ * @template {Element | Element[] | HTMLCollection} T
23
+ * @param {T} elements
24
+ * @param {Attributes} attributes
25
+ * @returns {T} The received element(s). Use it for method chaining.
26
+ */
27
+ export const setAttributes = (elements, attributes) => {
28
+ const items = elements instanceof Element
29
+ ? [elements]
30
+ : [...elements]
31
+
32
+ attributes = normalizeAttributes(attributes)
33
+
34
+ items.forEach(element =>
35
+ attributes.forEach(([name, value]) => {
36
+ if (value == null) {
37
+ return element.removeAttribute(name)
38
+ }
39
+
40
+ // CSS `class`, as array or object.
41
+ if (name == 'class') {
42
+ return Array.isArray(value)
43
+ ? element.classList.add(...value)
44
+ : Object.entries(value).forEach(([className, classState]) =>
45
+ element.classList.toggle(className, classState)
46
+ )
47
+ }
48
+
49
+ // Only `data` and `style` can go in this `if`.
50
+ if (typeof value == 'object') {
51
+
52
+ // `style` attribute (inline styles)
53
+ if (name == 'style') {
54
+ return Object.assign(element.style, value)
55
+ }
56
+
57
+ // `data-*` attributes (using `dataset`)
58
+ Object.assign(element.dataset, value)
59
+
60
+ return Object.entries(value).forEach(([dataKey, dataValue]) => {
61
+ /**
62
+ * Remove `data-*` prop if their value is `null` or `undefined`,
63
+ * because `dataset` stringify them but `setAttributes` apply
64
+ * the logic of regular HTML attributes to all attributes.
65
+ */
66
+ if (dataValue == null) {
67
+ delete element.dataset[dataKey]
68
+ }
69
+ })
70
+ }
71
+
72
+ element.setAttribute(name, value)
73
+ })
74
+ )
75
+
76
+ return elements
77
+ }
78
+
79
+ /** @param {Record<string, any>} fnAttributes */
80
+ const normalizeAttributes = fnAttributes => {
81
+ const attributes = { ...fnAttributes }
82
+
83
+ /**
84
+ * Normalize object attributes.
85
+ * It turns `{ key: { name: value }}` into `'key-name="value"'`.
86
+ */
87
+ Object.entries(attributes)
88
+ .filter(([name, value]) =>
89
+ value
90
+ && typeof value == 'object'
91
+ && !['class', 'data', 'style'].includes(name)
92
+ )
93
+ .forEach(([name, value]) => {
94
+ delete attributes[name]
95
+
96
+ Object.entries(value).forEach(([attrName, attrValue]) => {
97
+ attributes[`${name}-${attrName}`] = attrValue
98
+ })
99
+ })
100
+
101
+ // Normalize `class` attribute from string to array.
102
+ if (typeof attributes.class == 'string') {
103
+ attributes.class = attributes.class.split(' ')
104
+ }
105
+
106
+ // Normalize boolean attributes: `true` becomes `''`, `false` becomes `null`.
107
+ return Object.entries(attributes)
108
+ .map(([name, value]) => [
109
+ name,
110
+ typeof value == 'boolean'
111
+ ? (value ? '' : null)
112
+ : value // not boolean
113
+ ])
114
+ }
115
+
116
+ /** @typedef {boolean|string|number|null|undefined} AttributePrimitive */
117
+ /** @typedef {AttributePrimitive | Record<string, AttributePrimitive>} AttributeValue */
118
+ /** @typedef {Record<string, AttributeValue>} BaseAttributes */
119
+ /** @typedef {{ class?: string | string[] | Record<string, boolean> }} ClassAttribute */
120
+ /** @typedef {{ style?: AttributePrimitive | CSSStyleDeclaration }} StyleAttribute */
121
+ /** @typedef {BaseAttributes | ClassAttribute | StyleAttribute} Attributes */
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './dom/index.js'
1
2
  export * from './math/index.js'
2
3
  export * from './string/index.js'
3
4
  export * from './url/email.js'
package/types/index.d.ts CHANGED
@@ -1,3 +1,14 @@
1
+ export function setAttributes<T extends Element | Element[] | HTMLCollection>(elements: T, attributes: Attributes): T;
2
+ export type AttributePrimitive = boolean | string | number | null | undefined;
3
+ export type AttributeValue = AttributePrimitive | Record<string, AttributePrimitive>;
4
+ export type BaseAttributes = Record<string, AttributeValue>;
5
+ export type ClassAttribute = {
6
+ class?: string | string[] | Record<string, boolean>;
7
+ };
8
+ export type StyleAttribute = {
9
+ style?: AttributePrimitive | CSSStyleDeclaration;
10
+ };
11
+ export type Attributes = BaseAttributes | ClassAttribute | StyleAttribute;
1
12
  export function clamp(val: number, min: number, max: number): number;
2
13
  export function round(number: number, precision?: number): number;
3
14
  export function capitalize(str: string): string;