srcdev-nuxt-components 9.6.7 → 9.6.8

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 (58) hide show
  1. package/.claude/rules/consumer/ids-from-useid.md +14 -0
  2. package/.claude/skills/component-compliance-checklist.md +6 -0
  3. package/.claude/skills/component-naming.md +2 -2
  4. package/.claude/skills/components/input-checkbox-radio.md +19 -3
  5. package/.claude/skills/components/input-checkbox.md +14 -3
  6. package/.claude/skills/components/input-description.md +1 -1
  7. package/.claude/skills/components/input-error.md +1 -1
  8. package/.claude/skills/components/input-label.md +2 -2
  9. package/.claude/skills/components/input-number.md +44 -13
  10. package/.claude/skills/components/input-radio.md +15 -1
  11. package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
  12. package/.claude/skills/components/input-textarea-core.md +4 -4
  13. package/.claude/skills/index.md +5 -5
  14. package/.claude/skills/testing-add-unit-test.md +17 -0
  15. package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
  16. package/.vscode/srcdev-component-input-range.code-snippets +38 -18
  17. package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
  18. package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
  19. package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
  20. package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
  21. package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
  22. package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
  23. package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
  24. package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
  25. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
  26. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
  27. package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
  28. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
  29. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
  30. package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
  31. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
  32. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
  33. package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
  34. package/app/components/05.forms/input-description/CONSUMER-STYLING.md +2 -2
  35. package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
  36. package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
  37. package/app/components/05.forms/input-number/InputNumber.vue +37 -25
  38. package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
  39. package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
  40. package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
  41. package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
  42. package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
  43. package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
  44. package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
  45. package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
  46. package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
  47. package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
  48. package/app/components/05.forms/input-range/InputRange.vue +247 -0
  49. package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
  50. package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
  51. package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
  52. package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
  53. package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
  54. package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
  55. package/package.json +1 -1
  56. package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
  57. package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
  58. package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
@@ -1,16 +1,16 @@
1
1
  <template>
2
2
  <div
3
- class="input-range-with-label"
3
+ class="input-range-field"
4
+ :class="elementClasses"
4
5
  :data-theme="theme"
5
- :data-invalid="fieldHasError ? '' : null"
6
- :class="[elementClasses, { error: fieldHasError }]"
6
+ :data-invalid="fieldHasError ? '' : undefined"
7
7
  >
8
8
  <InputLabel
9
9
  :id
10
10
  :required
11
11
  input-variant="normal"
12
12
  :field-has-error
13
- :style-class-passthrough="['input-number-label', 'body-normal-bold']"
13
+ :style-class-passthrough="['input-range-label']"
14
14
  >
15
15
  <template #textLabel>{{ label }}</template>
16
16
  </InputLabel>
@@ -28,7 +28,7 @@
28
28
  </template>
29
29
  </InputDescription>
30
30
 
31
- <InputRangeCore
31
+ <InputRange
32
32
  :id
33
33
  v-model="modelValue"
34
34
  :name
@@ -40,20 +40,22 @@
40
40
  :weight
41
41
  :field-has-error
42
42
  :aria-describedby="ariaDescribedby()"
43
- :style-class-passthrough="styleClassPassthrough"
44
43
  >
45
- <template v-if="slots.datalist" #datalist>
46
- <slot name="datalist"></slot>
44
+ <template v-if="slots.markers" #markers>
45
+ <slot name="markers"></slot>
46
+ </template>
47
+ <template v-if="slots.datalist" #datalist="{ datalistId }">
48
+ <slot name="datalist" :datalist-id="datalistId"></slot>
47
49
  </template>
48
50
  <template v-if="slots.left" #left>
49
51
  <InputButton
50
52
  type="button"
51
- :readonly="Number(modelValue) === min"
53
+ :readonly="modelValue <= min"
52
54
  :is-pending="false"
53
55
  :button-text="stepDownLabel"
54
56
  :theme
55
57
  variant="secondary"
56
- @click.stop.prevent="updateRange(-step, Number(modelValue) > min)"
58
+ @click.stop.prevent="stepBy(-1)"
57
59
  >
58
60
  <template #iconOnly>
59
61
  <slot name="left"></slot>
@@ -63,19 +65,19 @@
63
65
  <template v-if="slots.right" #right>
64
66
  <InputButton
65
67
  type="button"
66
- :readonly="Number(modelValue) === max"
68
+ :readonly="modelValue >= max"
67
69
  :is-pending="false"
68
70
  :button-text="stepUpLabel"
69
71
  :theme
70
72
  variant="secondary"
71
- @click.stop.prevent="updateRange(step, Number(modelValue) < max)"
73
+ @click.stop.prevent="stepBy(1)"
72
74
  >
73
75
  <template #iconOnly>
74
76
  <slot name="right"></slot>
75
77
  </template>
76
78
  </InputButton>
77
79
  </template>
78
- </InputRangeCore>
80
+ </InputRange>
79
81
  <InputError
80
82
  :id="errorId"
81
83
  :error-message
@@ -119,7 +121,14 @@ const props = withDefaults(defineProps<Props>(), {
119
121
  });
120
122
 
121
123
  const slots = useSlots();
122
- const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
124
+ const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
125
+
126
+ watch(
127
+ () => props.styleClassPassthrough,
128
+ () => {
129
+ resetElementClasses(props.styleClassPassthrough);
130
+ }
131
+ );
123
132
 
124
133
  const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
125
134
  props.name,
@@ -131,9 +140,23 @@ const modelValue = defineModel<number>({
131
140
  required: true,
132
141
  });
133
142
 
134
- const updateRange = (step: number, withinRangeLimit: boolean) => {
135
- if (withinRangeLimit) {
136
- modelValue.value = (Number(modelValue.value) + step) as number;
137
- }
143
+ const decimalPlaces = (value: number) => (String(value).split(".")[1] ?? "").length;
144
+ const clamp = (value: number) => Math.min(Math.max(value, props.min), props.max);
145
+
146
+ const stepBy = (direction: 1 | -1) => {
147
+ const current = Number(modelValue.value);
148
+ const stepSize = Math.abs(props.step) || 1;
149
+ const precision = Math.max(decimalPlaces(stepSize), decimalPlaces(props.min));
150
+ const next = clamp(Number(((Number.isFinite(current) ? current : props.min) + direction * stepSize).toFixed(precision)));
151
+ if (next !== current) modelValue.value = next;
138
152
  };
139
153
  </script>
154
+
155
+ <style lang="css">
156
+ @layer components {
157
+ .input-range-field {
158
+ min-inline-size: 0;
159
+ overflow-wrap: anywhere;
160
+ }
161
+ }
162
+ </style>
@@ -0,0 +1,187 @@
1
+ import { ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputRange from "../InputRange.vue";
4
+ import InputButton from "../../input-button/InputButton.vue";
5
+ import type { FormUiTheme } from "~/types/forms/types.forms.d";
6
+
7
+ interface StoryArgs {
8
+ id: string;
9
+ name: string;
10
+ min: number;
11
+ max: number;
12
+ step: number;
13
+ required: boolean;
14
+ fieldHasError: boolean;
15
+ theme: FormUiTheme;
16
+ weight: string;
17
+ }
18
+
19
+ // Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
20
+ const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
21
+ const parentNote = `
22
+ <p style="${storyNoteStyle}">
23
+ With a label, description, error message and clamped step buttons wired up, this is the
24
+ <a href="/?path=/story/components-forms-input-range-inputrangefield--with-step-buttons" target="_top">InputRangeField</a>.
25
+ </p>
26
+ `;
27
+
28
+ const meta: Meta<StoryArgs> = {
29
+ title: "Components/Forms/Input Range/InputRange",
30
+ component: InputRange,
31
+ argTypes: {
32
+ id: { control: "text", table: { category: "Basic" } },
33
+ name: { control: "text", table: { category: "Basic" } },
34
+ min: { control: "number", table: { category: "Basic" } },
35
+ max: { control: "number", table: { category: "Basic" } },
36
+ step: { control: "number", table: { category: "Basic" } },
37
+ required: { control: "boolean", table: { category: "Basic" } },
38
+ fieldHasError: { control: "boolean", description: "Sets data-invalid and aria-invalid", table: { category: "States" } },
39
+ theme: {
40
+ control: { type: "select" },
41
+ options: ["default", "success", "error", "warning"],
42
+ table: { category: "Styling" },
43
+ },
44
+ weight: { control: "text", description: "Adds an `input-range--{weight}` class; nothing styles it yet", table: { category: "Styling" } },
45
+ },
46
+ args: {
47
+ id: "range-default",
48
+ name: "rangeDefault",
49
+ min: 0,
50
+ max: 100,
51
+ step: 1,
52
+ required: false,
53
+ fieldHasError: false,
54
+ theme: "default",
55
+ weight: "normal",
56
+ },
57
+ parameters: {
58
+ docs: {
59
+ description: {
60
+ component:
61
+ "The bare native `<input type=\"range\">`. Slots: `left`/`right` (step buttons), `markers` (custom tick marks overlaid on the track; the native thumb is hidden), and `datalist`, which receives a generated `datalistId` slot prop to put on your `<datalist>`. No label, description or error: that's `InputRangeField`. Hooks: `data-theme`, `data-invalid`, `data-has-markers`.",
62
+ },
63
+ },
64
+ },
65
+ };
66
+
67
+ export default meta;
68
+ type Story = StoryObj<StoryArgs>;
69
+
70
+ const render = (args: StoryArgs) => ({
71
+ components: { InputRange },
72
+ setup() {
73
+ const modelValue = ref(50);
74
+ return { args, modelValue };
75
+ },
76
+ template: `
77
+ <div style="margin: 36px; max-width: 320px;">
78
+ ${parentNote}
79
+ <InputRange v-bind="args" v-model="modelValue" />
80
+ </div>
81
+ `,
82
+ });
83
+
84
+ export const Default: Story = { render };
85
+
86
+ export const ErrorState: Story = {
87
+ args: { fieldHasError: true },
88
+ render,
89
+ parameters: { docs: { description: { story: "`fieldHasError` sets `data-invalid` on the wrapper and `aria-invalid` on the input." } } },
90
+ };
91
+
92
+ export const WithStepButtons: Story = {
93
+ render: (args: StoryArgs) => ({
94
+ components: { InputRange, InputButton },
95
+ setup() {
96
+ const modelValue = ref(50);
97
+ return { args, modelValue };
98
+ },
99
+ template: `
100
+ <div style="margin: 36px; max-width: 320px;">
101
+ ${parentNote}
102
+ <InputRange v-bind="args" v-model="modelValue">
103
+ <template #left>
104
+ <InputButton type="button" button-text="Step down" variant="secondary" @click="modelValue = Math.max(args.min, modelValue - args.step)">
105
+ <template #iconOnly><Icon name="mdi:minus" /></template>
106
+ </InputButton>
107
+ </template>
108
+ <template #right>
109
+ <InputButton type="button" button-text="Step up" variant="secondary" @click="modelValue = Math.min(args.max, modelValue + args.step)">
110
+ <template #iconOnly><Icon name="mdi:plus" /></template>
111
+ </InputButton>
112
+ </template>
113
+ </InputRange>
114
+ </div>
115
+ `,
116
+ }),
117
+ parameters: {
118
+ docs: {
119
+ description: {
120
+ story: "`InputButton`s in the `left`/`right` slots, sized by `--input-range-button-size`. The wiring here is minimal; `InputRangeField` clamps and rounds properly.",
121
+ },
122
+ },
123
+ },
124
+ };
125
+
126
+ export const WithMarkers: Story = {
127
+ args: { min: 0, max: 2, step: 1 },
128
+ render: (args: StoryArgs) => ({
129
+ components: { InputRange },
130
+ setup() {
131
+ const modelValue = ref(1);
132
+ return { args, modelValue };
133
+ },
134
+ template: `
135
+ <div style="margin: 36px; max-width: 320px;">
136
+ <InputRange v-bind="args" v-model="modelValue">
137
+ <template #markers>
138
+ <div class="input-range-markers">
139
+ <div class="input-range-marker"><span class="input-range-marker-icon">😀</span></div>
140
+ <div class="input-range-marker"><span class="input-range-marker-icon">🙂</span></div>
141
+ <div class="input-range-marker"><span class="input-range-marker-icon">😐</span></div>
142
+ </div>
143
+ </template>
144
+ </InputRange>
145
+ </div>
146
+ `,
147
+ }),
148
+ parameters: {
149
+ docs: {
150
+ description: {
151
+ story:
152
+ "The `markers` slot with the pre-styled `.input-range-markers` / `.input-range-marker` / `.input-range-marker-icon` classes. The line joining them sits behind the markers, the whole row is the slider (drag, click or use the arrow keys), and a ring circles the selected marker. Use one marker per step.",
153
+ },
154
+ },
155
+ },
156
+ };
157
+
158
+ export const WithDatalist: Story = {
159
+ render: (args: StoryArgs) => ({
160
+ components: { InputRange },
161
+ setup() {
162
+ const modelValue = ref(50);
163
+ return { args, modelValue };
164
+ },
165
+ template: `
166
+ <div style="margin: 36px; max-width: 320px;">
167
+ <InputRange v-bind="args" v-model="modelValue">
168
+ <template #datalist="{ datalistId }">
169
+ <datalist :id="datalistId" class="input-range-datalist">
170
+ <option value="0" label="Low"></option>
171
+ <option value="50" label="Medium"></option>
172
+ <option value="100" label="High"></option>
173
+ </datalist>
174
+ </template>
175
+ </InputRange>
176
+ </div>
177
+ `,
178
+ }),
179
+ parameters: {
180
+ docs: {
181
+ description: {
182
+ story:
183
+ "The `datalist` slot receives a generated `datalistId`; put it on your `<datalist>` and the input's `list` points at it. The thumb snaps to the options in browsers that support it.",
184
+ },
185
+ },
186
+ },
187
+ };
@@ -0,0 +1,196 @@
1
+ import { computed, ref } from "vue";
2
+ import type { Meta, StoryObj } from "@nuxtjs/storybook";
3
+ import InputRangeField from "../InputRangeField.vue";
4
+ import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
5
+ import type { FormUiTheme } from "~/types/forms/types.forms.d";
6
+ import type { MediaCanvas } from "~/types/components";
7
+
8
+ interface StoryArgs {
9
+ modelValue: number;
10
+ name: string;
11
+ label: string;
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ errorMessage: string;
16
+ fieldHasError: boolean;
17
+ required: boolean;
18
+ theme: FormUiTheme;
19
+ stepDownLabel: string;
20
+ stepUpLabel: string;
21
+ useStepButtons: boolean;
22
+ descriptionText: string;
23
+ datalistOptions: string[];
24
+ }
25
+
26
+ const meta: Meta<StoryArgs> = {
27
+ title: "Components/Forms/Input Range/InputRangeField",
28
+ component: InputRangeField,
29
+ argTypes: {
30
+ modelValue: { control: "number", table: { category: "Model" } },
31
+ name: { control: "text", table: { category: "Basic" } },
32
+ label: { control: "text", table: { category: "Basic" } },
33
+ min: { control: "number", table: { category: "Basic" } },
34
+ max: { control: "number", table: { category: "Basic" } },
35
+ step: { control: "number", table: { category: "Basic" } },
36
+ errorMessage: { control: "text", table: { category: "Validation" } },
37
+ fieldHasError: { control: "boolean", table: { category: "Validation" } },
38
+ required: { control: "boolean", table: { category: "Validation" } },
39
+ theme: {
40
+ control: { type: "select" },
41
+ options: ["default", "success", "error", "warning"],
42
+ table: { category: "Styling" },
43
+ },
44
+ stepDownLabel: { control: "text", description: "Localisation override for the step-down button", table: { category: "Accessibility" } },
45
+ stepUpLabel: { control: "text", description: "Localisation override for the step-up button", table: { category: "Accessibility" } },
46
+ useStepButtons: {
47
+ control: "boolean",
48
+ description: "Story-only: render the +/- step buttons by filling the left/right slots",
49
+ table: { category: "Slots" },
50
+ },
51
+ descriptionText: { control: "text", description: "Story-only: fills the descriptionText slot", table: { category: "Slots" } },
52
+ datalistOptions: {
53
+ control: "object",
54
+ description: "Story-only: option labels for a datalist spread evenly from min to max (empty for none)",
55
+ table: { category: "Slots" },
56
+ },
57
+ },
58
+ args: {
59
+ modelValue: 50,
60
+ name: "priceRange",
61
+ label: "Price range",
62
+ min: 0,
63
+ max: 100,
64
+ step: 1,
65
+ errorMessage: "",
66
+ fieldHasError: false,
67
+ required: false,
68
+ theme: "default",
69
+ stepDownLabel: "Step down",
70
+ stepUpLabel: "Step up",
71
+ useStepButtons: false,
72
+ descriptionText: "",
73
+ datalistOptions: [],
74
+ },
75
+ decorators: [
76
+ (story, context) => ({
77
+ components: { story, CanvasSwitcher },
78
+ setup() {
79
+ const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
80
+ return { canvasName };
81
+ },
82
+ template: `
83
+ <div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
84
+ <CanvasSwitcher v-model:canvas-name="canvasName" />
85
+ </div>
86
+ <div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
87
+ <story />
88
+ </div>
89
+ `,
90
+ }),
91
+ ],
92
+ parameters: {
93
+ layout: "fullscreen",
94
+ docs: {
95
+ description: {
96
+ component:
97
+ "Labelled range slider: `InputLabel`, an optional description, `InputRange` and an error strip. Filling the `left`/`right` slots with an icon adds step buttons: they step by `step`, round away floating-point drift and clamp to `min`/`max`. `markers` and `datalist` are forwarded to `InputRange` (the datalist slot passes its generated `datalistId` through). `stepDownLabel`/`stepUpLabel` localise the buttons' accessible names.",
98
+ },
99
+ },
100
+ },
101
+ };
102
+
103
+ export default meta;
104
+ type Story = StoryObj<StoryArgs>;
105
+
106
+ const render = (args: StoryArgs) => ({
107
+ components: { InputRangeField },
108
+ setup() {
109
+ const componentArgs = computed(() => {
110
+ const {
111
+ modelValue: _modelValue,
112
+ useStepButtons: _useStepButtons,
113
+ descriptionText: _descriptionText,
114
+ datalistOptions: _datalistOptions,
115
+ ...rest
116
+ } = args;
117
+ return rest;
118
+ });
119
+ const datalist = computed(() => {
120
+ const count = args.datalistOptions.length;
121
+ return args.datalistOptions.map((label, index) => ({
122
+ label,
123
+ value: count > 1 ? args.min + ((args.max - args.min) * index) / (count - 1) : args.min,
124
+ }));
125
+ });
126
+ return { args, componentArgs, datalist };
127
+ },
128
+ template: `
129
+ <InputRangeField v-model="args.modelValue" v-bind="componentArgs">
130
+ <template v-if="args.descriptionText" #descriptionText>{{ args.descriptionText }}</template>
131
+ <template v-if="args.useStepButtons" #left><Icon name="mdi:minus" /></template>
132
+ <template v-if="args.useStepButtons" #right><Icon name="mdi:plus" /></template>
133
+ <template v-if="datalist.length" #datalist="{ datalistId }">
134
+ <datalist :id="datalistId" class="input-range-datalist">
135
+ <option v-for="option in datalist" :key="option.value" :value="option.value" :label="option.label"></option>
136
+ </datalist>
137
+ </template>
138
+ </InputRangeField>
139
+ <p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
140
+ `,
141
+ });
142
+
143
+ export const Default: Story = { render };
144
+
145
+ export const WithStepButtons: Story = {
146
+ args: { useStepButtons: true },
147
+ render,
148
+ parameters: { docs: { description: { story: "Step buttons from the `left`/`right` slots. Step-down goes read-only at `min`, step-up at `max`." } } },
149
+ };
150
+
151
+ export const DecimalSteps: Story = {
152
+ args: { useStepButtons: true, label: "Opacity", modelValue: 0.2, min: 0, max: 1, step: 0.1 },
153
+ render,
154
+ parameters: { docs: { description: { story: "A fractional `step`: stepping up from 0.2 gives 0.3, not 0.30000000000000004." } } },
155
+ };
156
+
157
+ export const WithError: Story = {
158
+ args: { fieldHasError: true, errorMessage: "Please choose a value within the allowed range", theme: "error" },
159
+ render,
160
+ };
161
+
162
+ export const Required: Story = { args: { required: true }, render };
163
+
164
+ export const LocalisedLabels: Story = {
165
+ args: { useStepButtons: true, stepDownLabel: "Diminuer", stepUpLabel: "Augmenter" },
166
+ render,
167
+ };
168
+
169
+ export const StressTest: Story = {
170
+ name: "Stress Test (Worst-Case Data)",
171
+ args: {
172
+ useStepButtons: true,
173
+ modelValue: 123456789,
174
+ min: -999999999999,
175
+ max: 999999999999,
176
+ step: 0.0001,
177
+ label:
178
+ "🎚️ Maximalerpreisbereichfürdiegewünschtebehandlunginklusivezusatzleistungen https://example.com/a/very/long/unbroken/url <b>not bold</b> نطاق",
179
+ descriptionText: "يرجى اختيار نطاق Ziehen Sie den Regler, um den gewünschten Preisbereich festzulegen.",
180
+ errorMessage: "DieserWertliegtleideraußerhalbdeszulässigenBereichsbitteversuchenSieeserneut",
181
+ fieldHasError: true,
182
+ stepDownLabel: "Verringern 🔽",
183
+ stepUpLabel: "Erhöhen 🔼",
184
+ datalistOptions: ["Minimum", "Sehrniedrigerpreisbereich", "😀", "<b>Mitte</b>", "متوسط", "Hoch", "Maximum"],
185
+ },
186
+ render,
187
+ parameters: {
188
+ initialCanvas: "mobileCanvas",
189
+ docs: {
190
+ description: {
191
+ story:
192
+ "A range of minus to plus a trillion with a 0.0001 step, long unbroken German/URL/emoji/RTL/HTML-like copy, and a 7-option datalist with long and odd labels. Check at every canvas width: the label, description and error wrap inside the dashed outline, the slider shrinks to fit between step buttons that keep their size, and markup renders as text. Set `step` to 0 or a negative number and press the step buttons.",
193
+ },
194
+ },
195
+ },
196
+ };
@@ -1,7 +1,7 @@
1
1
  // https://nuxt.com/docs/getting-started/testing#unit-testing
2
2
  import type { VueWrapper } from "@vue/test-utils";
3
3
  import { mountSuspended } from "@nuxt/test-utils/runtime";
4
- import ComponentUnderTest from "../InputRangeCore.vue";
4
+ import ComponentUnderTest from "../InputRange.vue";
5
5
 
6
6
  const initialPropsData = {
7
7
  id: "test-range",
@@ -12,6 +12,11 @@ const initialPropsData = {
12
12
  };
13
13
 
14
14
  let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
15
+
16
+ afterEach(() => {
17
+ wrapper?.unmount();
18
+ vi.restoreAllMocks();
19
+ });
15
20
  const wrapperFactory = (propsData = {}, slots = {}) => {
16
21
  const mockPropsData = { ...initialPropsData, ...propsData };
17
22
 
@@ -22,7 +27,7 @@ const wrapperFactory = (propsData = {}, slots = {}) => {
22
27
  });
23
28
  };
24
29
 
25
- describe("InputRangeCore", () => {
30
+ describe("InputRange", () => {
26
31
  it("mounts without error", async () => {
27
32
  wrapper = await wrapperFactory();
28
33
  expect(wrapper.vm).toBeTruthy();
@@ -60,8 +65,8 @@ describe("InputRangeCore", () => {
60
65
 
61
66
  it("does not render the left/right slot wrappers when the slots are not used", async () => {
62
67
  wrapper = await wrapperFactory();
63
- expect(wrapper.find(".slot.left").exists()).toBe(false);
64
- expect(wrapper.find(".slot.right").exists()).toBe(false);
68
+ expect(wrapper.find(".input-range-slot-left").exists()).toBe(false);
69
+ expect(wrapper.find(".input-range-slot-right").exists()).toBe(false);
65
70
  });
66
71
 
67
72
  it("renders the left/right slots when provided", async () => {
@@ -73,11 +78,11 @@ describe("InputRangeCore", () => {
73
78
  }
74
79
  );
75
80
 
76
- expect(wrapper.find(".slot.left [data-testid='left-slot']").exists()).toBe(true);
77
- expect(wrapper.find(".slot.right [data-testid='right-slot']").exists()).toBe(true);
81
+ expect(wrapper.find(".input-range-slot-left [data-testid='left-slot']").exists()).toBe(true);
82
+ expect(wrapper.find(".input-range-slot-right [data-testid='right-slot']").exists()).toBe(true);
78
83
  });
79
84
 
80
- it("renders the markers slot and adds has-markers class to the input", async () => {
85
+ it("renders the markers slot and sets data-has-markers on the wrapper", async () => {
81
86
  wrapper = await wrapperFactory(
82
87
  {},
83
88
  {
@@ -86,25 +91,44 @@ describe("InputRangeCore", () => {
86
91
  );
87
92
 
88
93
  expect(wrapper.find('[data-testid="markers"]').exists()).toBe(true);
89
- expect(wrapper.find("input").classes()).toContain("has-markers");
94
+ expect(wrapper.find(".input-range-wrapper").attributes("data-has-markers")).toBe("");
90
95
  });
91
96
 
92
- it("does not add has-markers class when the markers slot is not used", async () => {
97
+ it("does not set data-has-markers when the markers slot is not used", async () => {
93
98
  wrapper = await wrapperFactory();
94
- expect(wrapper.find("input").classes()).not.toContain("has-markers");
99
+ expect(wrapper.find(".input-range-wrapper").attributes("data-has-markers")).toBeUndefined();
95
100
  });
96
101
 
97
- it("sets the list attribute to `{name}-datalist` only when the datalist slot is used", async () => {
102
+ it("links a generated datalist id through the datalistId slot prop", async () => {
98
103
  wrapper = await wrapperFactory(
99
104
  { name: "price" },
100
- { datalist: '<datalist id="price-datalist"></datalist>' }
105
+ { datalist: `<template #datalist="{ datalistId }"><datalist :id="datalistId"></datalist></template>` }
101
106
  );
102
- expect(wrapper.find("input").attributes("list")).toBe("price-datalist");
107
+ const list = wrapper.find("input").attributes("list");
108
+
109
+ expect(list).toBeTruthy();
110
+ expect(list).not.toBe("price-datalist");
111
+ expect(wrapper.find("datalist").attributes("id")).toBe(list);
103
112
  });
104
113
 
105
- it("leaves the list attribute empty when the datalist slot is not used", async () => {
114
+ it("omits the list attribute when the datalist slot is not used", async () => {
106
115
  wrapper = await wrapperFactory();
107
- expect(wrapper.find("input").attributes("list")).toBe("");
116
+ expect(wrapper.find("input").attributes("list")).toBeUndefined();
117
+ });
118
+
119
+ it("marks the wrapper and input invalid when fieldHasError is true", async () => {
120
+ wrapper = await wrapperFactory({ fieldHasError: true });
121
+ expect(wrapper.find(".input-range-wrapper").attributes("data-invalid")).toBe("");
122
+ expect(wrapper.find("input").attributes("aria-invalid")).toBe("true");
123
+ });
124
+
125
+ it("updates passthrough classes when the prop changes", async () => {
126
+ wrapper = await wrapperFactory({ styleClassPassthrough: ["first"] });
127
+ await wrapper.setProps({ styleClassPassthrough: ["second"] });
128
+ const classes = wrapper.find("input").classes();
129
+
130
+ expect(classes).toContain("second");
131
+ expect(classes).not.toContain("first");
108
132
  });
109
133
 
110
134
  it("applies the weight class from the weight prop", async () => {