srcdev-nuxt-components 9.6.6 → 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.
- package/.claude/rules/consumer/ids-from-useid.md +14 -0
- package/.claude/skills/component-compliance-checklist.md +6 -0
- package/.claude/skills/component-naming.md +2 -2
- package/.claude/skills/components/input-checkbox-radio.md +19 -3
- package/.claude/skills/components/input-checkbox.md +14 -3
- package/.claude/skills/components/input-description.md +1 -1
- package/.claude/skills/components/input-error.md +1 -1
- package/.claude/skills/components/input-label.md +2 -2
- package/.claude/skills/components/input-number.md +44 -13
- package/.claude/skills/components/input-radio.md +15 -1
- package/.claude/skills/components/{input-range-core.md → input-range.md} +53 -18
- package/.claude/skills/components/input-textarea-core.md +4 -4
- package/.claude/skills/components/page-hero-highlights.md +22 -18
- package/.claude/skills/index.md +6 -6
- package/.claude/skills/testing-add-unit-test.md +17 -0
- package/.vscode/srcdev-component-input-checkbox-radio.code-snippets +0 -1
- package/.vscode/srcdev-component-input-range.code-snippets +38 -18
- package/.vscode/srcdev-component-page-hero-highlights.code-snippets +2 -1
- package/app/components/04.templates/page-hero-highlights/CONSUMER-STYLING.md +32 -12
- package/app/components/04.templates/page-hero-highlights/PageHeroHighlights.vue +56 -47
- package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlights.stories.ts +158 -307
- package/app/components/04.templates/page-hero-highlights/stories/PageHeroHighlightsHeader.stories.ts +10 -0
- package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlights.spec.ts +49 -27
- package/app/components/04.templates/page-hero-highlights/tests/PageHeroHighlightsHeader.spec.ts +42 -7
- package/app/components/04.templates/page-hero-highlights/tests/__snapshots__/PageHeroHighlights.spec.ts.snap +12 -10
- package/app/components/05.forms/input-checkbox/CONSUMER-STYLING.md +10 -0
- package/app/components/05.forms/input-checkbox/MultipleCheckboxes.vue +18 -4
- package/app/components/05.forms/input-checkbox/SingleCheckbox.vue +14 -2
- package/app/components/05.forms/input-checkbox/stories/MultipleCheckboxes.stories.ts +146 -37
- package/app/components/05.forms/input-checkbox/stories/SingleCheckbox.stories.ts +107 -30
- package/app/components/05.forms/input-checkbox/tests/MultipleCheckboxes.spec.ts +38 -4
- package/app/components/05.forms/input-checkbox/tests/SingleCheckbox.spec.ts +5 -0
- package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +10 -3
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadio.vue +1 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +11 -10
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioField.vue +3 -0
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadio.stories.ts +44 -17
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +164 -58
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioField.stories.ts +94 -21
- package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadio.spec.ts +14 -3
- package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioButton.spec.ts +38 -3
- package/app/components/05.forms/input-checkbox-radio/tests/InputCheckboxRadioField.spec.ts +14 -3
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +42 -2
- package/app/components/05.forms/input-number/InputNumber.vue +37 -25
- package/app/components/05.forms/input-number/InputNumberField.vue +29 -16
- package/app/components/05.forms/input-number/stories/InputNumber.stories.ts +76 -25
- package/app/components/05.forms/input-number/stories/InputNumberField.stories.ts +116 -50
- package/app/components/05.forms/input-number/tests/InputNumber.spec.ts +49 -13
- package/app/components/05.forms/input-number/tests/InputNumberField.spec.ts +59 -5
- package/app/components/05.forms/input-radio/CONSUMER-STYLING.md +11 -0
- package/app/components/05.forms/input-radio/MultipleRadiobuttons.vue +14 -3
- package/app/components/05.forms/input-radio/stories/MultipleRadiobuttons.stories.ts +138 -38
- package/app/components/05.forms/input-radio/tests/MultipleRadioButtons.spec.ts +50 -3
- package/app/components/05.forms/input-range/CONSUMER-STYLING.md +68 -26
- package/app/components/05.forms/input-range/InputRange.vue +247 -0
- package/app/components/05.forms/input-range/{InputRangeDefault.vue → InputRangeField.vue} +41 -18
- package/app/components/05.forms/input-range/stories/InputRange.stories.ts +187 -0
- package/app/components/05.forms/input-range/stories/InputRangeField.stories.ts +196 -0
- package/app/components/05.forms/input-range/tests/{InputRangeCore.spec.ts → InputRange.spec.ts} +39 -15
- package/app/components/05.forms/input-range/tests/{InputRangeDefault.spec.ts → InputRangeField.spec.ts} +71 -7
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +5 -5
- package/package.json +1 -1
- package/app/components/05.forms/input-range/InputRangeCore.vue +0 -169
- package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +0 -126
- package/app/components/05.forms/input-range/stories/InputRangeDefault.stories.ts +0 -108
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
2
3
|
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
4
|
import InputCheckboxRadioField from "../InputCheckboxRadioField.vue";
|
|
4
5
|
|
|
@@ -9,12 +10,22 @@ const defaultProps = {
|
|
|
9
10
|
modelValue: false,
|
|
10
11
|
};
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
-
|
|
13
|
+
const mounted: VueWrapper[] = [];
|
|
14
|
+
|
|
15
|
+
const createWrapper = async (props = {}, slots = {}) => {
|
|
16
|
+
const wrapper = await mountSuspended(InputCheckboxRadioField, {
|
|
14
17
|
attachTo: document.body,
|
|
15
18
|
props: { ...defaultProps, ...props },
|
|
16
19
|
slots,
|
|
17
20
|
});
|
|
21
|
+
mounted.push(wrapper);
|
|
22
|
+
return wrapper;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
mounted.splice(0).forEach((wrapper) => wrapper.unmount());
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
18
29
|
|
|
19
30
|
describe("InputCheckboxRadioField", () => {
|
|
20
31
|
it("renders a label linked to the inner control", async () => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`InputDescription` is the help text rendered between the label and the control by every `*Field`
|
|
4
4
|
wrapper (`InputTextWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
|
|
5
|
-
`InputTextareaWithLabel`, `
|
|
5
|
+
`InputTextareaWithLabel`, `InputRangeField`, `InputTextAsNumberWithLabel`, `SingleCheckbox`,
|
|
6
6
|
`MultipleCheckboxes`, `MultipleRadiobuttons`) when you pass it a `descriptionText` or
|
|
7
7
|
`descriptionHtml` slot. With neither slot it renders nothing.
|
|
8
8
|
|
|
@@ -53,7 +53,7 @@ No private tokens.
|
|
|
53
53
|
|
|
54
54
|
| Attribute on `.input-description` | When |
|
|
55
55
|
|---|---|
|
|
56
|
-
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (not passed by `
|
|
56
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (not passed by `InputRangeField`, which has no variants, so it stays `normal` there) |
|
|
57
57
|
| `data-invalid` | The field has an error |
|
|
58
58
|
|
|
59
59
|
Inner elements: `.input-description-html` (div), `.input-description-text` (p).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`InputLabel` is the `<label>` rendered by every labelled field wrapper (`InputTextWithLabel`,
|
|
4
4
|
`InputTextAsNumberWithLabel`, `InputNumberField`, `InputSelectWithLabel`,
|
|
5
|
-
`InputTextareaWithLabel`, `
|
|
5
|
+
`InputTextareaWithLabel`, `InputRangeField`, `ToggleSwitchWithLabel`,
|
|
6
6
|
`ToggleSwitchWithLabelInline`).
|
|
7
7
|
|
|
8
8
|
## Public token API
|
|
@@ -50,7 +50,7 @@ shared fallback so the per-state tokens don't repeat it.
|
|
|
50
50
|
|
|
51
51
|
| Attribute on `.input-label` | When |
|
|
52
52
|
|---|---|
|
|
53
|
-
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (`
|
|
53
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | Always, mirrors the field's `input-variant` (`InputRangeField` and the toggle switches always pass `normal`) |
|
|
54
54
|
| `data-invalid` | The field has an error (not passed by `ToggleSwitchWithLabelInline`) |
|
|
55
55
|
|
|
56
56
|
| Attribute on `.input-label-indicator` | When |
|
|
@@ -29,12 +29,41 @@ component and untouched by this — see `theming-form-geometry-tokens.md`.
|
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
+
## State hooks
|
|
33
|
+
|
|
34
|
+
| Attribute | On | When |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-number-wrapper`, `.input-number-field` | Always, mirrors `input-variant` |
|
|
37
|
+
| `data-theme` | `.input-number-wrapper`, `.input-number-field` | Always, mirrors `theme` |
|
|
38
|
+
| `data-invalid` | `.input-number-wrapper`, `.input-number-field` | `field-has-error` |
|
|
39
|
+
| `data-has-left-slot` / `data-has-right-slot` | `.input-number-wrapper` | The `left` / `right` slot is filled |
|
|
40
|
+
|
|
41
|
+
Inner elements: `.input-number-slot` (both slot holders), `.input-number-slot-left`,
|
|
42
|
+
`.input-number-slot-right`, `.input-number-container`, `.input-number` (the native input),
|
|
43
|
+
`.input-number-label` (the Field's `InputLabel`).
|
|
44
|
+
|
|
45
|
+
> **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
|
|
46
|
+
> (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
|
|
47
|
+
> above instead:
|
|
48
|
+
>
|
|
49
|
+
> - `.input-number-wrapper.normal` / `.underlined` → `[data-input-variant="…"]`
|
|
50
|
+
> - `.input-number-wrapper.has-left-slot` / `.has-right-slot` → `[data-has-left-slot]` /
|
|
51
|
+
> `[data-has-right-slot]`
|
|
52
|
+
> - `.slot.left-slot` / `.slot.right-slot` → `.input-number-slot-left` / `.input-number-slot-right`
|
|
53
|
+
> - `.input-number-field.error` → `[data-invalid]`; its `.normal`/`.underlined` and `theme-*`
|
|
54
|
+
> classes → `[data-input-variant]` / `[data-theme]`
|
|
55
|
+
>
|
|
56
|
+
> Also: long label, description and error copy now wraps (`overflow-wrap: anywhere` on
|
|
57
|
+
> `.input-number-field`), and the control's minimum width is capped at `12em` so a long `min`/`max`
|
|
58
|
+
> can't push it past a phone screen.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
32
62
|
## Input variant
|
|
33
63
|
|
|
34
64
|
`:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
|
|
35
65
|
`InputTextCore`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
|
|
36
|
-
(bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either
|
|
37
|
-
`&.outlined` rule exists there), so passing it renders with no border/outline at all — a
|
|
66
|
+
(bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either, so passing it renders with no border/outline at all — a
|
|
38
67
|
pre-existing gap in the type, not something specific to `InputNumber`. Declared for prop-shape
|
|
39
68
|
consistency with the rest of `05.forms`.
|
|
40
69
|
|
|
@@ -68,3 +97,14 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
68
97
|
<InputNumber id="quantity" name="quantity" :min="1" :max="10" style="--input-number-border: var(--gold-04);" />
|
|
69
98
|
```
|
|
70
99
|
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Class passthrough
|
|
104
|
+
|
|
105
|
+
`InputNumber`'s `:style-class-passthrough` lands on the native `<input class="input-number">`.
|
|
106
|
+
`InputNumberField`'s lands on its root `.input-number-field` only.
|
|
107
|
+
|
|
108
|
+
> **Changed 2026-10-16**: `InputNumberField` also forwarded its passthrough classes to the inner
|
|
109
|
+
> input, so a class meant for the field matched two elements. If you styled the input through the
|
|
110
|
+
> Field's passthrough, select `.your-class .input-number` instead.
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
class="input-number-wrapper"
|
|
4
4
|
:data-theme="theme"
|
|
5
5
|
:data-invalid="fieldHasError ? '' : null"
|
|
6
|
-
:
|
|
6
|
+
:data-input-variant="inputVariant"
|
|
7
|
+
:data-has-left-slot="slots.left ? '' : undefined"
|
|
8
|
+
:data-has-right-slot="slots.right ? '' : undefined"
|
|
7
9
|
>
|
|
8
|
-
<div v-if="slots.left" class="slot
|
|
10
|
+
<div v-if="slots.left" class="input-number-slot input-number-slot-left">
|
|
9
11
|
<slot name="left"></slot>
|
|
10
12
|
</div>
|
|
11
13
|
|
|
@@ -22,12 +24,11 @@
|
|
|
22
24
|
:step
|
|
23
25
|
:placeholder
|
|
24
26
|
:class="['input-number', `input-number--${weight}`, elementClasses]"
|
|
25
|
-
inputmode
|
|
26
|
-
pattern="[0-9]+"
|
|
27
|
+
:inputmode
|
|
27
28
|
:aria-describedby
|
|
28
29
|
/>
|
|
29
30
|
</div>
|
|
30
|
-
<div v-if="slots.right" class="slot
|
|
31
|
+
<div v-if="slots.right" class="input-number-slot input-number-slot-right">
|
|
31
32
|
<slot name="right"></slot>
|
|
32
33
|
</div>
|
|
33
34
|
</div>
|
|
@@ -70,8 +71,26 @@ const modelValue = defineModel<number | readonly number[]>({
|
|
|
70
71
|
required: true,
|
|
71
72
|
});
|
|
72
73
|
|
|
73
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
74
|
-
|
|
74
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
75
|
+
|
|
76
|
+
watch(
|
|
77
|
+
() => props.styleClassPassthrough,
|
|
78
|
+
() => {
|
|
79
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const decimalPlaces = (value: number) => (String(value).split(".")[1] ?? "").length;
|
|
84
|
+
|
|
85
|
+
const minLength = computed(() => {
|
|
86
|
+
const longest = Math.max(String(props.min).length, String(props.max).length) + decimalPlaces(props.step);
|
|
87
|
+
return `${Math.min(longest + 1, 12)}em`;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const inputmode = computed(() => {
|
|
91
|
+
if (props.min < 0) return undefined;
|
|
92
|
+
return Number.isInteger(props.step) ? "numeric" : "decimal";
|
|
93
|
+
});
|
|
75
94
|
</script>
|
|
76
95
|
|
|
77
96
|
<style lang="css">
|
|
@@ -92,6 +111,7 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
92
111
|
align-items: center;
|
|
93
112
|
|
|
94
113
|
width: fit-content;
|
|
114
|
+
max-inline-size: 100%;
|
|
95
115
|
|
|
96
116
|
background-color: var(--_surface);
|
|
97
117
|
overflow: hidden;
|
|
@@ -99,7 +119,7 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
99
119
|
position: relative;
|
|
100
120
|
z-index: 2;
|
|
101
121
|
|
|
102
|
-
|
|
122
|
+
&[data-input-variant="normal"] {
|
|
103
123
|
border: var(--form-element-border-width) solid var(--_border);
|
|
104
124
|
border-radius: var(--form-input-border-radius);
|
|
105
125
|
outline: var(--form-element-outline-width) solid transparent;
|
|
@@ -116,12 +136,14 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
116
136
|
}
|
|
117
137
|
}
|
|
118
138
|
|
|
119
|
-
|
|
139
|
+
&[data-input-variant="underlined"] {
|
|
120
140
|
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
|
|
121
141
|
}
|
|
122
142
|
|
|
123
|
-
.slot {
|
|
124
|
-
display:
|
|
143
|
+
.input-number-slot {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
flex-shrink: 0;
|
|
125
147
|
|
|
126
148
|
.icon {
|
|
127
149
|
font-weight: 900;
|
|
@@ -153,25 +175,15 @@ const minLength = computed(() => `${props.max.toString().length + 1}em`);
|
|
|
153
175
|
}
|
|
154
176
|
}
|
|
155
177
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
display: flex;
|
|
159
|
-
align-items: center;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
&.has-right-slot {
|
|
164
|
-
.right-slot {
|
|
165
|
-
display: flex;
|
|
166
|
-
align-items: center;
|
|
167
|
-
}
|
|
178
|
+
.input-number-container {
|
|
179
|
+
min-inline-size: 0;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
|
-
|
|
182
|
+
.input-number-slot-left .input-button {
|
|
171
183
|
border-right: var(--form-element-border-width) solid var(--_border);
|
|
172
184
|
}
|
|
173
185
|
|
|
174
|
-
|
|
186
|
+
.input-number-slot-right .input-button {
|
|
175
187
|
border-left: var(--form-element-border-width) solid var(--_border);
|
|
176
188
|
}
|
|
177
189
|
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
class="input-number-field"
|
|
4
4
|
:data-theme="theme"
|
|
5
5
|
:data-invalid="fieldHasError ? '' : null"
|
|
6
|
-
:
|
|
6
|
+
:data-input-variant="inputVariant"
|
|
7
|
+
:class="elementClasses"
|
|
7
8
|
>
|
|
8
9
|
<InputLabel
|
|
9
10
|
:id
|
|
10
11
|
:required
|
|
11
12
|
:input-variant
|
|
12
13
|
:field-has-error
|
|
13
|
-
:style-class-passthrough="['input-number-label'
|
|
14
|
+
:style-class-passthrough="['input-number-label']"
|
|
14
15
|
>
|
|
15
16
|
<template #textLabel>{{ label }}</template>
|
|
16
17
|
</InputLabel>
|
|
@@ -42,7 +43,6 @@
|
|
|
42
43
|
:weight
|
|
43
44
|
:input-variant
|
|
44
45
|
:field-has-error
|
|
45
|
-
:style-class-passthrough
|
|
46
46
|
:aria-describedby="ariaDescribedby()"
|
|
47
47
|
>
|
|
48
48
|
<template v-if="slots.left" #left>
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
:button-text="stepDownLabel"
|
|
54
54
|
:theme="theme"
|
|
55
55
|
variant="inline"
|
|
56
|
-
@click.stop.prevent="
|
|
56
|
+
@click.stop.prevent="stepBy(-1)"
|
|
57
57
|
>
|
|
58
58
|
<template #iconOnly>
|
|
59
59
|
<slot name="left"></slot>
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
:button-text="stepUpLabel"
|
|
69
69
|
:theme="theme"
|
|
70
70
|
variant="inline"
|
|
71
|
-
@click.stop.prevent="
|
|
71
|
+
@click.stop.prevent="stepBy(1)"
|
|
72
72
|
>
|
|
73
73
|
<template #iconOnly>
|
|
74
74
|
<slot name="right"></slot>
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
</InputButton>
|
|
77
77
|
</template>
|
|
78
78
|
</InputNumber>
|
|
79
|
-
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" />
|
|
79
|
+
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="true" :input-variant />
|
|
80
80
|
</div>
|
|
81
81
|
</template>
|
|
82
82
|
|
|
@@ -115,7 +115,14 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
const slots = useSlots();
|
|
118
|
-
const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
118
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
119
|
+
|
|
120
|
+
watch(
|
|
121
|
+
() => props.styleClassPassthrough,
|
|
122
|
+
() => {
|
|
123
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
124
|
+
}
|
|
125
|
+
);
|
|
119
126
|
|
|
120
127
|
const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
121
128
|
props.name,
|
|
@@ -126,16 +133,28 @@ const modelValue = defineModel<number | readonly number[]>({
|
|
|
126
133
|
required: true,
|
|
127
134
|
});
|
|
128
135
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
const decimalPlaces = (value: number) => (String(value).split(".")[1] ?? "").length;
|
|
137
|
+
const clamp = (value: number) => Math.min(Math.max(value, props.min), props.max);
|
|
138
|
+
|
|
139
|
+
const stepBy = (direction: 1 | -1) => {
|
|
140
|
+
const current = modelValue.value;
|
|
141
|
+
if (typeof current !== "number" || !Number.isFinite(current)) {
|
|
142
|
+
modelValue.value = clamp(0);
|
|
143
|
+
return;
|
|
132
144
|
}
|
|
145
|
+
const stepSize = Math.abs(props.step) || 1;
|
|
146
|
+
const precision = Math.max(decimalPlaces(stepSize), decimalPlaces(current), decimalPlaces(props.min));
|
|
147
|
+
const next = clamp(Number((current + direction * stepSize).toFixed(precision)));
|
|
148
|
+
if (next !== current) modelValue.value = next;
|
|
133
149
|
};
|
|
134
150
|
</script>
|
|
135
151
|
|
|
136
152
|
<style lang="css">
|
|
137
153
|
@layer components {
|
|
138
154
|
.input-number-field {
|
|
155
|
+
min-inline-size: 0;
|
|
156
|
+
overflow-wrap: anywhere;
|
|
157
|
+
|
|
139
158
|
.input-number-label {
|
|
140
159
|
display: block;
|
|
141
160
|
margin-block: 0.8rem;
|
|
@@ -144,12 +163,6 @@ const updateValue = (step: number, withinRangeLimit: boolean) => {
|
|
|
144
163
|
cursor: pointer;
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
|
-
|
|
148
|
-
.label-description {
|
|
149
|
-
font-family: var(--font-family);
|
|
150
|
-
font-size: 1.6rem;
|
|
151
|
-
margin-top: 1.2rem;
|
|
152
|
-
}
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
</style>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
1
|
import { ref } from "vue";
|
|
3
|
-
import
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import InputNumber from "../InputNumber.vue";
|
|
4
|
+
import InputButton from "../../input-button/InputButton.vue";
|
|
5
|
+
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
4
6
|
|
|
5
|
-
interface
|
|
7
|
+
interface StoryArgs {
|
|
6
8
|
id: string;
|
|
7
9
|
name: string;
|
|
8
10
|
min: number;
|
|
@@ -11,13 +13,23 @@ interface InputNumberStoryArgs {
|
|
|
11
13
|
placeholder: string;
|
|
12
14
|
required: boolean;
|
|
13
15
|
fieldHasError: boolean;
|
|
16
|
+
theme: FormUiTheme;
|
|
14
17
|
weight: string;
|
|
15
|
-
inputVariant:
|
|
18
|
+
inputVariant: InputUiVariant;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
// Links in the canvas open in the Storybook manager (target="_top"), not inside the preview iframe.
|
|
22
|
+
const storyNoteStyle = "margin: 0 0 1.6rem; font-size: 1.4rem;";
|
|
23
|
+
const parentNote = `
|
|
24
|
+
<p style="${storyNoteStyle}">
|
|
25
|
+
With a label, description, error message and clamped step buttons wired up, this is the
|
|
26
|
+
<a href="/?path=/story/components-forms-input-number-inputnumberfield--with-step-buttons" target="_top">InputNumberField</a>.
|
|
27
|
+
</p>
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const meta: Meta<StoryArgs> = {
|
|
19
31
|
title: "Components/Forms/Input Number/InputNumber",
|
|
20
|
-
component:
|
|
32
|
+
component: InputNumber,
|
|
21
33
|
argTypes: {
|
|
22
34
|
id: { control: "text", table: { category: "Basic" } },
|
|
23
35
|
name: { control: "text", table: { category: "Basic" } },
|
|
@@ -27,7 +39,12 @@ export default {
|
|
|
27
39
|
placeholder: { control: "text", table: { category: "Basic" } },
|
|
28
40
|
required: { control: "boolean", table: { category: "Basic" } },
|
|
29
41
|
fieldHasError: { control: "boolean", table: { category: "States" } },
|
|
30
|
-
|
|
42
|
+
theme: {
|
|
43
|
+
control: { type: "select" },
|
|
44
|
+
options: ["default", "success", "error", "warning"],
|
|
45
|
+
table: { category: "Styling" },
|
|
46
|
+
},
|
|
47
|
+
weight: { control: "text", description: "Adds an `input-number--{weight}` class; nothing styles it yet", table: { category: "Styling" } },
|
|
31
48
|
inputVariant: {
|
|
32
49
|
control: { type: "select" },
|
|
33
50
|
options: ["normal", "outlined", "underlined"],
|
|
@@ -43,50 +60,84 @@ export default {
|
|
|
43
60
|
placeholder: "",
|
|
44
61
|
required: false,
|
|
45
62
|
fieldHasError: false,
|
|
63
|
+
theme: "default",
|
|
46
64
|
weight: "normal",
|
|
47
65
|
inputVariant: "normal",
|
|
48
66
|
},
|
|
49
|
-
|
|
67
|
+
parameters: {
|
|
68
|
+
docs: {
|
|
69
|
+
description: {
|
|
70
|
+
component:
|
|
71
|
+
"The bare native `<input type=\"number\">` with `left`/`right` slots for embedded step buttons. No label, description or error: that's `InputNumberField`. `inputmode` follows `min` and `step` (`numeric`, `decimal`, or none when negatives are allowed). Hooks: `data-input-variant`, `data-theme`, `data-invalid`, `data-has-left-slot`, `data-has-right-slot`.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export default meta;
|
|
78
|
+
type Story = StoryObj<StoryArgs>;
|
|
50
79
|
|
|
51
|
-
const
|
|
52
|
-
components: {
|
|
80
|
+
const render = (args: StoryArgs) => ({
|
|
81
|
+
components: { InputNumber },
|
|
53
82
|
setup() {
|
|
54
83
|
const modelValue = ref(50);
|
|
55
84
|
return { args, modelValue };
|
|
56
85
|
},
|
|
57
86
|
template: `
|
|
58
87
|
<div style="margin: 36px; max-width: 320px;">
|
|
59
|
-
|
|
88
|
+
${parentNote}
|
|
89
|
+
<InputNumber v-bind="args" v-model="modelValue" />
|
|
60
90
|
</div>
|
|
61
91
|
`,
|
|
62
92
|
});
|
|
63
93
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
export const ErrorState = Template.bind({});
|
|
67
|
-
ErrorState.args = { fieldHasError: true };
|
|
68
|
-
|
|
69
|
-
const WithStepButtonsTemplate: StoryFn<InputNumberStoryArgs> = (args) => ({
|
|
70
|
-
components: { StorybookComponent },
|
|
94
|
+
const renderWithSlots = (args: StoryArgs) => ({
|
|
95
|
+
components: { InputNumber, InputButton },
|
|
71
96
|
setup() {
|
|
72
97
|
const modelValue = ref(50);
|
|
73
98
|
return { args, modelValue };
|
|
74
99
|
},
|
|
75
100
|
template: `
|
|
76
101
|
<div style="margin: 36px; max-width: 320px;">
|
|
77
|
-
|
|
102
|
+
${parentNote}
|
|
103
|
+
<InputNumber v-bind="args" v-model="modelValue">
|
|
78
104
|
<template #left>
|
|
79
|
-
<
|
|
105
|
+
<InputButton type="button" button-text="Step down" variant="inline" @click="modelValue = Math.max(args.min, modelValue - args.step)">
|
|
106
|
+
<template #iconOnly><Icon name="mdi:minus" /></template>
|
|
107
|
+
</InputButton>
|
|
80
108
|
</template>
|
|
81
109
|
<template #right>
|
|
82
|
-
<
|
|
110
|
+
<InputButton type="button" button-text="Step up" variant="inline" @click="modelValue = Math.min(args.max, modelValue + args.step)">
|
|
111
|
+
<template #iconOnly><Icon name="mdi:plus" /></template>
|
|
112
|
+
</InputButton>
|
|
83
113
|
</template>
|
|
84
|
-
</
|
|
114
|
+
</InputNumber>
|
|
85
115
|
</div>
|
|
86
116
|
`,
|
|
87
117
|
});
|
|
88
118
|
|
|
89
|
-
export const
|
|
119
|
+
export const Default: Story = { render };
|
|
120
|
+
|
|
121
|
+
export const ErrorState: Story = {
|
|
122
|
+
args: { fieldHasError: true },
|
|
123
|
+
render,
|
|
124
|
+
parameters: { docs: { description: { story: "`fieldHasError` sets `data-invalid` on the wrapper." } } },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const WithStepButtons: Story = {
|
|
128
|
+
render: renderWithSlots,
|
|
129
|
+
parameters: {
|
|
130
|
+
docs: {
|
|
131
|
+
description: {
|
|
132
|
+
story:
|
|
133
|
+
"Inline `InputButton`s in the `left`/`right` slots become flush segments of the control. The wiring here is deliberately minimal; `InputNumberField` does it properly (clamping, rounding, empty field).",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
90
138
|
|
|
91
|
-
export const Underlined =
|
|
92
|
-
|
|
139
|
+
export const Underlined: Story = {
|
|
140
|
+
args: { inputVariant: "underlined" },
|
|
141
|
+
render: renderWithSlots,
|
|
142
|
+
parameters: { docs: { description: { story: "`inputVariant=\"underlined\"` keeps only a bottom border." } } },
|
|
143
|
+
};
|