@statistikzh/leu 0.24.0 → 0.24.1

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.
Files changed (84) hide show
  1. package/.github/workflows/publish.yml +7 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/dist/Accordion.js +1 -1
  5. package/dist/Button.js +1 -1
  6. package/dist/ButtonGroup.js +1 -1
  7. package/dist/ChartWrapper.js +1 -1
  8. package/dist/Checkbox.js +1 -1
  9. package/dist/CheckboxGroup.js +1 -1
  10. package/dist/Chip.js +1 -1
  11. package/dist/ChipGroup.js +1 -1
  12. package/dist/ChipLink.js +1 -1
  13. package/dist/ChipRemovable.js +1 -1
  14. package/dist/ChipSelectable.js +1 -1
  15. package/dist/Dialog.js +1 -1
  16. package/dist/Dropdown.js +1 -1
  17. package/dist/FileInput.js +1 -1
  18. package/dist/Icon.js +1 -1
  19. package/dist/Input.js +1 -1
  20. package/dist/{LeuElement-BfbOWTGZ.js → LeuElement-jrR2M5pZ.js} +1 -1
  21. package/dist/Menu.js +1 -1
  22. package/dist/MenuItem.js +1 -1
  23. package/dist/Message.js +1 -1
  24. package/dist/Pagination.js +1 -1
  25. package/dist/Placeholder.js +1 -1
  26. package/dist/Popup.js +1 -1
  27. package/dist/ProgressBar.js +1 -1
  28. package/dist/Radio.js +1 -1
  29. package/dist/RadioGroup.js +1 -1
  30. package/dist/Range.d.ts +32 -20
  31. package/dist/Range.js +137 -72
  32. package/dist/ScrollTop.js +2 -25
  33. package/dist/Select.js +1 -1
  34. package/dist/Spinner.js +1 -1
  35. package/dist/Table.js +1 -1
  36. package/dist/Tag.js +1 -1
  37. package/dist/VisuallyHidden.js +1 -1
  38. package/dist/components/range/Range.d.ts +33 -20
  39. package/dist/components/range/Range.d.ts.map +1 -1
  40. package/dist/components/range/stories/range.stories.d.ts +1 -0
  41. package/dist/components/range/stories/range.stories.d.ts.map +1 -1
  42. package/dist/index.js +2 -1
  43. package/dist/leu-accordion.js +1 -1
  44. package/dist/leu-button-group.js +1 -1
  45. package/dist/leu-button.js +1 -1
  46. package/dist/leu-chart-wrapper.js +1 -1
  47. package/dist/leu-checkbox-group.js +1 -1
  48. package/dist/leu-checkbox.js +1 -1
  49. package/dist/leu-chip-group.js +1 -1
  50. package/dist/leu-chip-link.js +1 -1
  51. package/dist/leu-chip-removable.js +1 -1
  52. package/dist/leu-chip-selectable.js +1 -1
  53. package/dist/leu-dialog.js +1 -1
  54. package/dist/leu-dropdown.js +1 -1
  55. package/dist/leu-file-input.js +1 -1
  56. package/dist/leu-icon.js +1 -1
  57. package/dist/leu-input.js +1 -1
  58. package/dist/leu-menu-item.js +1 -1
  59. package/dist/leu-menu.js +1 -1
  60. package/dist/leu-message.js +1 -1
  61. package/dist/leu-pagination.js +1 -1
  62. package/dist/leu-placeholder.js +1 -1
  63. package/dist/leu-popup.js +1 -1
  64. package/dist/leu-progress-bar.js +1 -1
  65. package/dist/leu-radio-group.js +1 -1
  66. package/dist/leu-radio.js +1 -1
  67. package/dist/leu-range.js +3 -1
  68. package/dist/leu-scroll-top.js +2 -1
  69. package/dist/leu-select.js +1 -1
  70. package/dist/leu-spinner.js +1 -1
  71. package/dist/leu-table.js +1 -1
  72. package/dist/leu-tag.js +1 -1
  73. package/dist/leu-visually-hidden.js +1 -1
  74. package/dist/lib/utils.d.ts +10 -3
  75. package/dist/lib/utils.d.ts.map +1 -1
  76. package/dist/utils-DBGsNSJW.js +33 -0
  77. package/dist/vscode.html-custom-data.json +66 -62
  78. package/dist/vue/index.d.ts +80 -73
  79. package/dist/web-types.json +143 -137
  80. package/package.json +1 -1
  81. package/src/components/range/Range.ts +160 -87
  82. package/src/components/range/stories/range.stories.ts +3 -0
  83. package/src/components/range/test/range.test.ts +59 -0
  84. package/src/lib/utils.ts +13 -3
@@ -1,14 +1,18 @@
1
- import { html, nothing } from "lit"
1
+ import { html, nothing, PropertyValues } from "lit"
2
+ import { property, query } from "lit/decorators.js"
3
+ import { ifDefined } from "lit/directives/if-defined.js"
2
4
 
3
- import { property } from "lit/decorators.js"
4
5
  import styles from "./range.css"
5
6
  import { LeuElement } from "../../lib/LeuElement.js"
7
+ import { clamp, isNumber } from "../../lib/utils.js"
8
+
9
+ type InternalRangeValue = [number, number] | [number]
6
10
 
7
11
  const defaultValueConverter = {
8
- fromAttribute(value) {
12
+ fromAttribute(value: string) {
9
13
  return value.split(",").map((v) => Number(v.trim()))
10
14
  },
11
- toAttribute(value) {
15
+ toAttribute(value: number[]) {
12
16
  return value.join(",")
13
17
  },
14
18
  }
@@ -26,7 +30,15 @@ export class LeuRange extends LeuElement {
26
30
  delegatesFocus: true,
27
31
  }
28
32
 
29
- @property({ converter: defaultValueConverter, attribute: "value" })
33
+ /**
34
+ * The default value of the range slider.
35
+ * String input is parsed as a comma-separated list of numbers.
36
+ */
37
+ @property({
38
+ converter: defaultValueConverter,
39
+ attribute: "value",
40
+ reflect: true,
41
+ })
30
42
  defaultValue = [50]
31
43
 
32
44
  /**
@@ -110,116 +122,175 @@ export class LeuRange extends LeuElement {
110
122
  @property({ attribute: false })
111
123
  valueFormatter?: (value: number) => string
112
124
 
113
- updated() {
114
- this._updateStyles()
115
- }
125
+ protected _value: InternalRangeValue = this.defaultValue.map((v) =>
126
+ this.clampAndRoundValue(v),
127
+ ) as InternalRangeValue
116
128
 
117
- protected get _inputs() {
118
- return Array.from(
119
- this.shadowRoot.querySelectorAll<HTMLInputElement>("input"),
120
- )
121
- }
129
+ /**
130
+ * The value of the range slider.
131
+ * String input is parsed as a comma-separated list of numbers.
132
+ * In multiple mode, if only a single value is provided, the second handle will be set to the minimum value.
133
+ * In single mode, only the first value will be used.
134
+ */
135
+ @property({ attribute: false })
136
+ set value(value: string | number | Array<string | number>) {
137
+ let nextValue: Array<number> = []
138
+
139
+ if (typeof value === "string") {
140
+ nextValue = value
141
+ .split(",")
142
+ .map((v) => Number(v.trim()))
143
+ .filter(isNumber)
144
+ } else if (isNumber(value)) {
145
+ nextValue = [value]
146
+ } else if (Array.isArray(value)) {
147
+ nextValue = value.map((v: unknown) => Number(v)).filter(isNumber)
148
+ }
122
149
 
123
- protected _updateStyles() {
124
- const normalizedRange = this._getNormalizedRange()
125
- this.style.setProperty("--low", normalizedRange[0].toString())
126
- this.style.setProperty("--high", normalizedRange[1].toString())
150
+ if (nextValue.length === 0) {
151
+ return
152
+ }
127
153
 
128
- const inputs = this.multiple
129
- ? [this._getBaseInput(), this._getGhostInput()]
130
- : [this._getBaseInput()]
154
+ // In multiple mode, we need to ensure that we always have two values.
155
+ // `min` is a fallback for the second value.
156
+ if (this.multiple && nextValue.length === 1) {
157
+ nextValue.unshift(this.min)
158
+ }
131
159
 
132
- inputs.forEach((input) => {
133
- const output = this.shadowRoot.querySelector<HTMLOutputElement>(
134
- `.output[for=${input.id}]`,
135
- )
136
- const normalizedValue = this._getNormalizedValue(input.valueAsNumber)
137
- output.style.setProperty("--value", normalizedValue.toString())
138
- output.value = this.formatValue(input.valueAsNumber)
139
- })
160
+ this._value = nextValue
161
+ .slice(0, this.multiple ? 2 : 1)
162
+ .map((v) => this.clampAndRoundValue(v)) as InternalRangeValue
140
163
  }
141
164
 
142
- get value() {
143
- return this._inputs.map((input) => input.value).join(",")
165
+ get value(): string {
166
+ return this._value.join(",")
144
167
  }
145
168
 
146
- /**
147
- * Sets the value of the underlying input element(s).
148
- * The value has to be an array if "multiple" range is used.
149
- * Otherwise it has to be a string.
150
- */
151
- set value(value: string | Array<string>) {
152
- if (this.multiple && Array.isArray(value)) {
153
- const inputs = this._inputs
154
-
155
- value.forEach((v, i) => {
156
- inputs[i].value = v
157
- })
158
- this._updateStyles()
159
- } else if (!this.multiple) {
160
- this._getBaseInput().value = value
161
- this._updateStyles()
162
- }
169
+ get valueAsArray(): InternalRangeValue {
170
+ return this._value.slice() as InternalRangeValue
163
171
  }
164
172
 
165
- get valueAsArray() {
166
- return this._inputs.map((input) => input.valueAsNumber)
173
+ get valueLow(): number {
174
+ return Math.min(...this._value)
167
175
  }
168
176
 
169
- get valueLow() {
170
- const inputs = this._inputs
177
+ get valueHigh(): number {
178
+ return Math.max(...this._value)
179
+ }
171
180
 
172
- if (this.multiple) {
173
- return inputs.map((input) => input.valueAsNumber).sort((a, b) => a - b)[0]
174
- }
181
+ @query("#container")
182
+ protected container: HTMLDivElement
175
183
 
176
- return inputs[0].value
184
+ @query("#input-base")
185
+ protected inputBase: HTMLInputElement
186
+
187
+ @query("#input-ghost")
188
+ protected inputGhost: HTMLInputElement | null
189
+
190
+ @query("output[for=input-base]")
191
+ protected outputBase: HTMLOutputElement
192
+
193
+ @query("output[for=input-ghost]")
194
+ protected outputGhost: HTMLOutputElement | null
195
+
196
+ updated() {
197
+ this.updateStyles()
177
198
  }
178
199
 
179
- get valueHigh() {
180
- const inputs = this._inputs
200
+ protected willUpdate(changedProperties: PropertyValues<this>): void {
201
+ // Reflect defaultValue changes to the value property
202
+ // to ensure backwards compatibility with previous versions
203
+ if (changedProperties.has("defaultValue")) {
204
+ this.value = this.defaultValue.map((v) =>
205
+ this.clampAndRoundValue(v),
206
+ ) as InternalRangeValue
207
+ }
181
208
 
182
- if (this.multiple) {
183
- return inputs.map((input) => input.valueAsNumber).sort((a, b) => a - b)[1]
209
+ if (
210
+ changedProperties.has("min") ||
211
+ changedProperties.has("max") ||
212
+ changedProperties.has("step")
213
+ ) {
214
+ this._value = this._value.map((v) =>
215
+ this.clampAndRoundValue(v),
216
+ ) as InternalRangeValue
184
217
  }
185
218
 
186
- return inputs[0].value
219
+ if (changedProperties.has("multiple") && this.multiple) {
220
+ // When switching to multiple mode, ensure that we have two values
221
+ if (this._value.length === 1) {
222
+ this._value = [this.min, this._value[0]]
223
+ }
224
+ } else if (changedProperties.has("multiple") && !this.multiple) {
225
+ // When switching to single mode, keep only the lower value
226
+ this._value = [this.valueLow]
227
+ }
187
228
  }
188
229
 
189
- protected _getBaseInput() {
190
- return this.shadowRoot.querySelector<HTMLInputElement>(".range--base")
230
+ protected updateStyles() {
231
+ const normalizedRange = this.getNormalizedRange()
232
+ this.container?.style.setProperty("--low", normalizedRange[0].toString())
233
+ this.container?.style.setProperty("--high", normalizedRange[1].toString())
234
+
235
+ const inputs = this.multiple
236
+ ? [this.inputBase, this.inputGhost]
237
+ : [this.inputBase]
238
+
239
+ inputs.forEach((input) => {
240
+ const output =
241
+ input.id === "input-base" ? this.outputBase : this.outputGhost
242
+ const normalizedValue = this.getNormalizedValue(input.valueAsNumber)
243
+ output.style.setProperty("--value", normalizedValue.toString())
244
+ output.value = this.formatValue(input.valueAsNumber)
245
+ })
191
246
  }
192
247
 
193
- protected _getGhostInput() {
194
- return this.shadowRoot.querySelector<HTMLInputElement>(".range--ghost")
248
+ protected clampAndRoundValue(value: number) {
249
+ const clampedValue = clamp(value, this.min, this.max)
250
+ const roundedValue =
251
+ Math.round((clampedValue - this.min) / this.step) * this.step + this.min
252
+
253
+ return roundedValue
195
254
  }
196
255
 
197
- protected _handleInput(
198
- _index: number,
199
- _e: InputEvent & { target: HTMLInputElement },
200
- ) {
201
- this._updateStyles()
256
+ protected handleInput(e: Event & { target: HTMLInputElement }) {
257
+ e.stopPropagation()
258
+
259
+ if (this.multiple) {
260
+ this.value = [this.inputBase.valueAsNumber, this.inputGhost.valueAsNumber]
261
+ } else {
262
+ this.value = [this.inputBase.valueAsNumber]
263
+ }
264
+
265
+ this.dispatchEvent(
266
+ new CustomEvent("input", {
267
+ composed: true,
268
+ bubbles: true,
269
+ detail: { value: this.value, valueAsArray: this.valueAsArray },
270
+ }),
271
+ )
202
272
  }
203
273
 
204
- protected _getNormalizedValue(value: number) {
274
+ protected getNormalizedValue(value: number) {
205
275
  return (value - this.min) / (this.max - this.min)
206
276
  }
207
277
 
208
- protected _getNormalizedRange() {
278
+ protected getNormalizedRange() {
209
279
  if (this.multiple) {
210
280
  return this.valueAsArray
211
- .map((value) => this._getNormalizedValue(value))
281
+ .map((value) => this.getNormalizedValue(value))
212
282
  .sort((a, b) => a - b)
213
283
  }
214
284
 
215
- return [0, this._getNormalizedValue(this.valueAsArray[0])]
285
+ return [0, this.getNormalizedValue(this.valueAsArray[0])]
216
286
  }
217
287
 
218
288
  /**
219
- * Determine if the "click" (pointer event) is closer the
220
- * the value of the other input element. Swap the values if this is the case.
289
+ * This event handler is only applied to the "base" input element and only when in "multiple" mode.
290
+ * It handles pointer events on the *track* and the thumb.
291
+ * This method determines if the interaction was closer to the base or the ghost input.
221
292
  */
222
- protected _handlePointerDown(e: PointerEvent & { target: HTMLInputElement }) {
293
+ protected handlePointerDown(e: PointerEvent & { target: HTMLInputElement }) {
223
294
  const clickValue =
224
295
  this.min + ((this.max - this.min) * e.offsetX) / this.offsetWidth
225
296
  const middleValue = (this.valueAsArray[0] + this.valueAsArray[1]) / 2
@@ -232,8 +303,7 @@ export class LeuRange extends LeuElement {
232
303
  * As the pointerdown event is fired before the input event, we first overwrite the value
233
304
  * of the input element that was not clicked on. The active input element will update itself.
234
305
  */
235
- // this._value = [e.target.valueAsNumber, e.target.valueAsNumber]
236
- this._getGhostInput().value = e.target.value
306
+ this.inputGhost.value = e.target.value
237
307
  }
238
308
  }
239
309
 
@@ -258,7 +328,7 @@ export class LeuRange extends LeuElement {
258
328
  (tick) =>
259
329
  html`<span
260
330
  class="tick"
261
- style="left: ${this._getNormalizedValue(tick) * 100}%"
331
+ style="left: ${this.getNormalizedValue(tick) * 100}%"
262
332
  ></span>`,
263
333
  )}
264
334
  </div>`
@@ -267,13 +337,14 @@ export class LeuRange extends LeuElement {
267
337
  render() {
268
338
  const inputs = this.multiple ? ["base", "ghost"] : ["base"]
269
339
 
270
- const { multiple, disabled, label, defaultValue } = this
340
+ const { multiple, disabled, label, valueAsArray } = this
271
341
 
272
342
  return html`
273
343
  <div
344
+ id="container"
274
345
  class="container"
275
- role=${multiple ? "group" : undefined}
276
- aria-labelledby=${multiple ? "group-label" : undefined}
346
+ role=${ifDefined(multiple ? "group" : undefined)}
347
+ aria-labelledby=${ifDefined(multiple ? "group-label" : undefined)}
277
348
  >
278
349
  ${multiple
279
350
  ? html`<span id="group-label" class="label">${label}</span>`
@@ -284,7 +355,7 @@ export class LeuRange extends LeuElement {
284
355
  html`<output
285
356
  class="output"
286
357
  for="input-${type}"
287
- value=${this.formatValue(defaultValue[index])}
358
+ value=${this.formatValue(valueAsArray[index])}
288
359
  ></output>`,
289
360
  )}
290
361
  </div>
@@ -292,9 +363,9 @@ export class LeuRange extends LeuElement {
292
363
  ${inputs.map(
293
364
  (type, index) => html`
294
365
  <input
295
- @input=${(e) => this._handleInput(index, e)}
366
+ @input=${this.handleInput}
296
367
  @pointerdown=${multiple && !disabled && index === 0
297
- ? this._handlePointerDown
368
+ ? this.handlePointerDown
298
369
  : undefined}
299
370
  type="range"
300
371
  class="range range--${type}"
@@ -303,9 +374,11 @@ export class LeuRange extends LeuElement {
303
374
  min=${this.min}
304
375
  max=${this.max}
305
376
  step=${this.step}
306
- aria-label=${multiple ? RANGE_LABELS[index] : undefined}
377
+ aria-label=${ifDefined(
378
+ multiple ? RANGE_LABELS[index] : undefined,
379
+ )}
307
380
  ?disabled=${disabled}
308
- .value=${defaultValue[index].toString()}
381
+ .value=${valueAsArray[index].toString()}
309
382
  />
310
383
  `,
311
384
  )}
@@ -1,4 +1,5 @@
1
1
  import { Meta, StoryObj } from "@storybook/web-components"
2
+ import { action } from "@storybook/addon-actions"
2
3
  import { html } from "lit"
3
4
  import { ifDefined } from "lit/directives/if-defined.js"
4
5
 
@@ -20,6 +21,7 @@ export default {
20
21
  },
21
22
  args: {
22
23
  label: "Bereich",
24
+ oninput: action("input"),
23
25
  },
24
26
  } satisfies Meta<StoryArgs>
25
27
 
@@ -39,6 +41,7 @@ const Template: Story = {
39
41
  ?hide-label=${args["hide-label"]}
40
42
  ?show-ticks=${args["show-ticks"]}
41
43
  ?show-range-labels=${args["show-range-labels"]}
44
+ @input=${args.oninput}
42
45
  >
43
46
  </leu-range>`,
44
47
  }
@@ -166,4 +166,63 @@ describe("LeuRange", () => {
166
166
  const input = el.shadowRoot?.querySelector("input")
167
167
  expect(input).to.have.attribute("disabled")
168
168
  })
169
+
170
+ it("clamps and rounds when value is set", async () => {
171
+ const el = await defaultFixture({ min: 0, max: 10, step: 3 })
172
+
173
+ el.value = "8"
174
+ await el.updateComplete
175
+
176
+ expect(el.value).to.equal("9")
177
+ })
178
+
179
+ it("re-normalizes when min/max/step changes", async () => {
180
+ const el = await defaultFixture({ min: 0, max: 10, step: 2 })
181
+
182
+ el.value = "9"
183
+ await el.updateComplete
184
+
185
+ expect(el.value).to.equal("10")
186
+
187
+ el.max = 6
188
+ await el.updateComplete
189
+
190
+ expect(el.value).to.equal("6")
191
+ })
192
+
193
+ it("sets the second handle to min when multiple and a single value is provided", async () => {
194
+ const el = await defaultFixture({
195
+ multiple: true,
196
+ min: 10,
197
+ max: 100,
198
+ value: 20,
199
+ })
200
+
201
+ expect(el.value).to.equal("10,20")
202
+
203
+ el.value = "30"
204
+ await el.updateComplete
205
+ expect(el.value).to.equal("10,30")
206
+
207
+ el.value = "30, 40"
208
+ await el.updateComplete
209
+ expect(el.value).to.equal("30,40")
210
+ })
211
+
212
+ it("re-normalizes both values when multiple and min/max/step changes", async () => {
213
+ const el = await defaultFixture({
214
+ multiple: true,
215
+ min: 0,
216
+ max: 10,
217
+ step: 2,
218
+ value: "3,7",
219
+ })
220
+
221
+ expect(el.value).to.equal("4,8")
222
+
223
+ el.max = 6
224
+ await el.updateComplete
225
+
226
+ expect(el.value).to.equal("4,6")
227
+ })
169
228
  })
package/src/lib/utils.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * @param {Number} timeout - Default is 500 ms
5
5
  * @returns {Function} - Your function wrapped in a timeout function
6
6
  */
7
- const debounce = function debounce(func, timeout = 500) {
7
+ export const debounce = function debounce(func, timeout = 500) {
8
8
  let timer = null
9
9
  return (...args) => {
10
10
  clearTimeout(timer)
@@ -20,7 +20,7 @@ const debounce = function debounce(func, timeout = 500) {
20
20
  * @param {Number} timeout - Default is 500 ms
21
21
  * @returns {Function} - Your function wrapped in a timeout function
22
22
  */
23
- const throttle = function throttle(func, timeout = 500) {
23
+ export const throttle = function throttle(func, timeout = 500) {
24
24
  let timer = null
25
25
  return (...args) => {
26
26
  if (timer === null) {
@@ -32,4 +32,14 @@ const throttle = function throttle(func, timeout = 500) {
32
32
  }
33
33
  }
34
34
 
35
- export { debounce, throttle }
35
+ /**
36
+ * Clamp a number between a minimum and maximum value.
37
+ */
38
+ export const clamp = (value: number, min: number, max: number) =>
39
+ Math.min(Math.max(value, min), max)
40
+
41
+ /**
42
+ * Check if a value is a finite number.
43
+ */
44
+ export const isNumber = (value: unknown): value is number =>
45
+ typeof value === "number" && Number.isFinite(value)