srcdev-nuxt-components 9.6.8 → 9.6.9
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/skills/component-compliance-checklist.md +12 -1
- package/.claude/skills/component-inline-action-button.md +1 -1
- package/.claude/skills/component-local-style-override.md +1 -1
- package/.claude/skills/component-naming.md +11 -11
- package/.claude/skills/components/container-glow.md +2 -2
- package/.claude/skills/components/form-field.md +2 -2
- package/.claude/skills/components/form-wrapper.md +1 -1
- package/.claude/skills/components/glowing-border.md +1 -1
- package/.claude/skills/components/input-description.md +7 -7
- package/.claude/skills/components/input-error.md +3 -3
- package/.claude/skills/components/input-label.md +7 -8
- package/.claude/skills/components/input-number.md +17 -17
- package/.claude/skills/components/input-select.md +153 -0
- package/.claude/skills/components/input-text.md +220 -0
- package/.claude/skills/components/input-textarea.md +147 -0
- package/.claude/skills/components/select-menu.md +4 -4
- package/.claude/skills/components/toggle-switch.md +143 -0
- package/.claude/skills/composable-zod-validation.md +1 -1
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/icon-sizing.md +1 -1
- package/.claude/skills/index.md +5 -5
- package/.claude/skills/storybook-add-story.md +1 -1
- package/.claude/skills/theming-component-token-pattern.md +8 -8
- package/.claude/skills/theming-form-geometry-tokens.md +1 -1
- package/.vscode/srcdev-component-input-select.code-snippets +20 -14
- package/.vscode/srcdev-component-input-text.code-snippets +46 -40
- package/.vscode/srcdev-component-input-textarea.code-snippets +19 -14
- package/.vscode/srcdev-component-toggle-switch.code-snippets +19 -29
- package/app/assets/styles/setup/04.elements/forms/00.element-defaults.css +2 -2
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +1 -1
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +4 -4
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +4 -4
- package/app/components/05.forms/form-errors/CONSUMER-STYLING.md +1 -1
- package/app/components/05.forms/form-field/CONSUMER-STYLING.md +2 -2
- package/app/components/05.forms/input-button/stories/InputButton.stories.ts +3 -3
- package/app/components/05.forms/input-description/CONSUMER-STYLING.md +6 -6
- package/app/components/05.forms/input-label/CONSUMER-STYLING.md +5 -5
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +9 -5
- package/app/components/05.forms/input-number/InputNumber.vue +2 -2
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +80 -10
- package/app/components/05.forms/input-select/InputSelect.vue +198 -0
- package/app/components/05.forms/input-select/InputSelectField.vue +130 -0
- package/app/components/05.forms/input-select/stories/InputSelect.stories.ts +184 -0
- package/app/components/05.forms/input-select/stories/InputSelectField.stories.ts +187 -0
- package/app/components/05.forms/input-select/tests/{InputSelectCore.spec.ts → InputSelect.spec.ts} +75 -12
- package/app/components/05.forms/input-select/tests/InputSelectField.spec.ts +121 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +85 -15
- package/app/components/05.forms/input-text/{InputPasswordWithLabel.vue → InputPasswordField.vue} +16 -19
- package/app/components/05.forms/input-text/{InputTextCore.vue → InputText.vue} +42 -78
- package/app/components/05.forms/input-text/{InputTextAsNumberWithLabel.vue → InputTextAsNumberField.vue} +66 -62
- package/app/components/05.forms/input-text/InputTextField.vue +168 -0
- package/app/components/05.forms/input-text/stories/InputPasswordField.stories.ts +163 -0
- package/app/components/05.forms/input-text/stories/InputText.stories.ts +194 -0
- package/app/components/05.forms/input-text/stories/InputTextAsNumberField.stories.ts +182 -0
- package/app/components/05.forms/input-text/stories/InputTextField.stories.ts +206 -0
- package/app/components/05.forms/input-text/tests/{InputPasswordWithLabel.spec.ts → InputPasswordField.spec.ts} +42 -3
- package/app/components/05.forms/input-text/tests/InputText.spec.ts +166 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberField.spec.ts +135 -0
- package/app/components/05.forms/input-text/tests/InputTextField.spec.ts +130 -0
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +94 -12
- package/app/components/05.forms/input-textarea/InputTextarea.vue +165 -0
- package/app/components/05.forms/input-textarea/InputTextareaField.vue +159 -0
- package/app/components/05.forms/input-textarea/stories/InputTextarea.stories.ts +198 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaField.stories.ts +161 -0
- package/app/components/05.forms/input-textarea/tests/InputTextarea.spec.ts +149 -0
- package/app/components/05.forms/input-textarea/tests/InputTextareaField.spec.ts +122 -0
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +13 -13
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +6 -6
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +30 -32
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +71 -50
- package/app/components/05.forms/toggle-switch/{ToggleSwitchCore.vue → ToggleSwitch.vue} +58 -73
- package/app/components/05.forms/toggle-switch/{ToggleSwitchWithLabel.vue → ToggleSwitchField.vue} +65 -19
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitch.stories.ts +148 -0
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchField.stories.ts +176 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitch.spec.ts +145 -0
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchField.spec.ts +155 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-select-core.md +0 -151
- package/.claude/skills/components/input-text-core.md +0 -204
- package/.claude/skills/components/input-textarea-core.md +0 -154
- package/.claude/skills/components/toggle-switch-core.md +0 -169
- package/app/components/05.forms/input-select/InputSelectCore.vue +0 -188
- package/app/components/05.forms/input-select/InputSelectWithLabel.vue +0 -118
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +0 -69
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +0 -115
- package/app/components/05.forms/input-select/tests/InputSelectWithLabel.spec.ts +0 -87
- package/app/components/05.forms/input-text/InputTextWithLabel.vue +0 -154
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +0 -368
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +0 -480
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +0 -267
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +0 -319
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +0 -75
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +0 -60
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +0 -71
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +0 -159
- package/app/components/05.forms/input-textarea/InputTextareaWithLabel.vue +0 -136
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +0 -247
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +0 -458
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +0 -119
- package/app/components/05.forms/input-textarea/tests/InputTextareaWithLabel.spec.ts +0 -93
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabelInline.vue +0 -81
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +0 -251
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +0 -283
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +0 -101
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabel.spec.ts +0 -133
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchWithLabelInline.spec.ts +0 -66
|
@@ -7,11 +7,11 @@ to override just this component; leave it unset and it inherits the shared `--th
|
|
|
7
7
|
`--theme-*` token every other themed component also falls back to. See
|
|
8
8
|
`theming-component-token-pattern.md` for why this two-tier shape exists.
|
|
9
9
|
|
|
10
|
-
This shape mirrors `
|
|
11
|
-
is a native `<input type="number">` sibling to the text-based `
|
|
10
|
+
This shape mirrors `InputText`'s token set (see its own CONSUMER-STYLING.md) — `InputNumber`
|
|
11
|
+
is a native `<input type="number">` sibling to the text-based `InputTextAsNumberField` variant,
|
|
12
12
|
and both are meant to look and behave the same way. Border colour also doubles as the divider
|
|
13
13
|
colour between the input and an embedded left/right `InputButton` — there's no separate divider
|
|
14
|
-
token, same as `
|
|
14
|
+
token, same as `InputText`.
|
|
15
15
|
|
|
16
16
|
| Token | Falls back to | Controls |
|
|
17
17
|
|---|---|---|
|
|
@@ -27,6 +27,10 @@ Geometry (`--input-padding-block`, `--input-padding-inline`, `--input-min-height
|
|
|
27
27
|
`--form-input-border-radius`, `--input-font-size`, etc.) is shared across every `05.forms`
|
|
28
28
|
component and untouched by this — see `theming-form-geometry-tokens.md`.
|
|
29
29
|
|
|
30
|
+
Private (not public API): `--_surface`, `--_border` and `--_border-focus` (each resolved once and
|
|
31
|
+
read in several places) and `--_min-width` (the input's minimum width, worked out from `min`, `max`
|
|
32
|
+
and `step` and capped at `12em`).
|
|
33
|
+
|
|
30
34
|
---
|
|
31
35
|
|
|
32
36
|
## State hooks
|
|
@@ -62,8 +66,8 @@ Inner elements: `.input-number-slot` (both slot holders), `.input-number-slot-le
|
|
|
62
66
|
## Input variant
|
|
63
67
|
|
|
64
68
|
`:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
|
|
65
|
-
`
|
|
66
|
-
(bottom-border only) have CSS today; `outlined` has no styling in `
|
|
69
|
+
`InputText`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
|
|
70
|
+
(bottom-border only) have CSS today; `outlined` has no styling in `InputText` either, so passing it renders with no border/outline at all — a
|
|
67
71
|
pre-existing gap in the type, not something specific to `InputNumber`. Declared for prop-shape
|
|
68
72
|
consistency with the rest of `05.forms`.
|
|
69
73
|
|
|
@@ -100,8 +100,8 @@ const inputmode = computed(() => {
|
|
|
100
100
|
|
|
101
101
|
/* Public --input-number-* tokens, inline-fallback to the shared --theme-* tokens (see
|
|
102
102
|
theming-component-token-pattern.md) — overriding one here doesn't touch every other themed
|
|
103
|
-
input/control that also reads --theme-input-surface/--theme-border. Mirrors
|
|
104
|
-
token shape so a native number input and the text-based
|
|
103
|
+
input/control that also reads --theme-input-surface/--theme-border. Mirrors InputText's
|
|
104
|
+
token shape so a native number input and the text-based InputTextAsNumberField variant
|
|
105
105
|
look and behave the same way (see input-number.md's "Fixed 2026-09-22" note). */
|
|
106
106
|
--_surface: var(--input-number-surface, var(--theme-input-surface));
|
|
107
107
|
--_border: var(--input-number-border, var(--theme-border));
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# InputSelect — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
> **Renamed 2026-10-16**: `InputSelectCore` → `InputSelect`, `InputSelectWithLabel` →
|
|
4
|
+
> `InputSelectField`. Class changes are listed under "State hooks" below.
|
|
2
5
|
|
|
3
6
|
## Public token API
|
|
4
7
|
|
|
@@ -7,23 +10,73 @@ to override just this component; leave it unset and it inherits the shared `--th
|
|
|
7
10
|
`--theme-*` token every other themed component also falls back to. See
|
|
8
11
|
`theming-component-token-pattern.md` for why this two-tier shape exists.
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
"local override path" — that's gone. A private, underscore-prefixed custom property was never a
|
|
12
|
-
real override point; renaming it public with a real fallback is the actual fix, not a second
|
|
13
|
-
documented path to reach for.)
|
|
13
|
+
### Control
|
|
14
14
|
|
|
15
|
-
| Token |
|
|
15
|
+
| Token | Default | Controls |
|
|
16
16
|
|---|---|---|
|
|
17
17
|
| `--input-select-surface` | `var(--theme-input-surface)` | Wrapper/select background |
|
|
18
|
-
| `--input-select-
|
|
19
|
-
| `--input-select-border` | `var(--theme-border)` | Border colour (
|
|
18
|
+
| `--input-select-text-color` | `var(--theme-input-text-color-normal)` | Select text colour (added 2026-10-16; it inherited the page colour before) |
|
|
19
|
+
| `--input-select-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variants and the open picker, resting) |
|
|
20
20
|
| `--input-select-border-hover` | `var(--theme-border-focus)` | Outline colour on hover (mouse) |
|
|
21
|
-
| `--input-select-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive) |
|
|
21
|
+
| `--input-select-border-focus` | `var(--theme-border-focus)` | Outline colour on `:focus-visible` (keyboard/assistive), and the open picker's outline |
|
|
22
|
+
|
|
23
|
+
### Options
|
|
24
|
+
|
|
25
|
+
| Token | Default | Controls |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `--input-select-surface-hover` | `var(--theme-input-surface-hover)` | Option row background on hover |
|
|
28
|
+
| `--input-select-option-gap` | `1.5rem` | Gap between an option's icon and its label |
|
|
29
|
+
| `--input-select-option-icon-size` | `1.5rem` | Option icon width and height |
|
|
22
30
|
|
|
23
31
|
Geometry (`--input-padding-*`, `--input-min-height`, `--input-select-option-min-height`,
|
|
24
32
|
`--form-element-*`, etc.) is shared across every `05.forms` component and untouched by this — see
|
|
25
33
|
`theming-form-geometry-tokens.md`.
|
|
26
34
|
|
|
35
|
+
Private (not public API): `--_border` and `--_border-focus`, each resolved once and read in several
|
|
36
|
+
places.
|
|
37
|
+
|
|
38
|
+
Option rows (`--input-select-surface-hover`, the gap and the icon) and the open picker's border are
|
|
39
|
+
only styled where the browser supports `appearance: base-select`. Elsewhere the native dropdown
|
|
40
|
+
shows and ignores them.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## State hooks
|
|
45
|
+
|
|
46
|
+
| Attribute | On | When |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `data-input-variant="normal" \| "outlined" \| "underlined"` | `.input-select-wrapper`, `.input-select-field` | Always, mirrors `input-variant` |
|
|
49
|
+
| `data-theme` | `.input-select-wrapper`, `.input-select-field` | Always. The Field's own is the `theme` prop; the inner wrapper's switches to `error` while `field-has-error` is set |
|
|
50
|
+
| `data-invalid` | `.input-select-wrapper`, `.input-select-field` | `field-has-error` |
|
|
51
|
+
| `data-dirty` | `.input-select-wrapper` | The value has changed (the `is-dirty` model) |
|
|
52
|
+
| `data-active` | `.input-select-wrapper` | The select has focus (the `is-active` model) |
|
|
53
|
+
|
|
54
|
+
Inner elements: `.input-select` (the native select), `.input-select-option` (each option),
|
|
55
|
+
`.input-select-option-placeholder` (the placeholder option), `.input-select-option-icon`,
|
|
56
|
+
`.input-select-label` (the Field's `InputLabel`), `.input-text-description` (the Field's
|
|
57
|
+
`InputDescription`, shared with the other Field components).
|
|
58
|
+
|
|
59
|
+
`outlined` has no CSS of its own: the control renders with no border or outline. In the Field it
|
|
60
|
+
moves the description after the control and error.
|
|
61
|
+
|
|
62
|
+
> **Changed 2026-10-16**: state used to be bare classes, which collide with common consumer CSS
|
|
63
|
+
> (an unlayered `.error` or `.normal` rule would restyle the field). Select on the attributes
|
|
64
|
+
> above instead:
|
|
65
|
+
>
|
|
66
|
+
> - `.input-select-wrapper.normal` / `.underlined` / `.outlined` → `[data-input-variant="…"]`
|
|
67
|
+
> - `.input-select-wrapper.dirty` / `.active` / `.error` → `[data-dirty]` / `[data-active]` /
|
|
68
|
+
> `[data-invalid]`
|
|
69
|
+
> - `.input-select-core` → `.input-select`; `.input-select-core-option` → `.input-select-option`;
|
|
70
|
+
> `option.placeholder` → `.input-select-option-placeholder`;
|
|
71
|
+
> `.input-select-core-option-decorator-icon` → `.input-select-option-icon`
|
|
72
|
+
> - `.input-select-with-label` → `.input-select-field`, which is now the root (it used to sit
|
|
73
|
+
> inside an unclassed `<div>`); its `.error`, variant, `dirty` and `active` classes are gone, use
|
|
74
|
+
> `[data-invalid]` / `[data-input-variant]`, or the inner wrapper's attributes
|
|
75
|
+
> - the Field's default `data-testid` is `input-select-field` (was `input-select-with-label`)
|
|
76
|
+
>
|
|
77
|
+
> Also: long label, description and error copy now wraps (`overflow-wrap: anywhere` on
|
|
78
|
+
> `.input-select-field`), and long option labels wrap in the open picker.
|
|
79
|
+
|
|
27
80
|
---
|
|
28
81
|
|
|
29
82
|
## Global theming
|
|
@@ -52,6 +105,23 @@ Set the tokens above on an element you own (a page or section class, or a class
|
|
|
52
105
|
### One instance
|
|
53
106
|
|
|
54
107
|
```vue
|
|
55
|
-
<
|
|
108
|
+
<InputSelectField
|
|
109
|
+
v-model="country"
|
|
110
|
+
v-model:field-data="countries"
|
|
111
|
+
name="country"
|
|
112
|
+
label="Country"
|
|
113
|
+
:error-message="errors.country"
|
|
114
|
+
style="--input-select-border-focus: var(--gold-04);"
|
|
115
|
+
/>
|
|
56
116
|
```
|
|
57
117
|
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Class passthrough
|
|
121
|
+
|
|
122
|
+
`InputSelect`'s `:style-class-passthrough` lands on the native `<select class="input-select">`.
|
|
123
|
+
`InputSelectField`'s lands on its root `.input-select-field` only.
|
|
124
|
+
|
|
125
|
+
> **Changed 2026-10-16**: `InputSelectWithLabel` forwarded its passthrough classes to the inner
|
|
126
|
+
> select instead of its own root. If you styled the select through the Field's passthrough, select
|
|
127
|
+
> `.your-class .input-select` instead.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="input-select-wrapper"
|
|
4
|
+
:data-theme="theme"
|
|
5
|
+
:data-input-variant="inputVariant"
|
|
6
|
+
:data-invalid="fieldHasError ? '' : null"
|
|
7
|
+
:data-dirty="isDirty ? '' : null"
|
|
8
|
+
:data-active="isActive ? '' : null"
|
|
9
|
+
>
|
|
10
|
+
<select
|
|
11
|
+
:id
|
|
12
|
+
v-model="modelValue"
|
|
13
|
+
:aria-invalid="fieldHasError ? 'true' : undefined"
|
|
14
|
+
:class="['input-select', elementClasses]"
|
|
15
|
+
:name
|
|
16
|
+
:title
|
|
17
|
+
:required
|
|
18
|
+
:aria-describedby="ariaDescribedby || undefined"
|
|
19
|
+
@focusin="isActive = true"
|
|
20
|
+
@focusout="isActive = false"
|
|
21
|
+
@change="isDirty = true"
|
|
22
|
+
>
|
|
23
|
+
<option
|
|
24
|
+
v-if="placeholder"
|
|
25
|
+
value=""
|
|
26
|
+
disabled
|
|
27
|
+
:selected="isEmpty"
|
|
28
|
+
class="input-select-option input-select-option-placeholder"
|
|
29
|
+
>
|
|
30
|
+
{{ placeholder }}
|
|
31
|
+
</option>
|
|
32
|
+
<option
|
|
33
|
+
v-for="item in fieldData.data"
|
|
34
|
+
:key="item.id"
|
|
35
|
+
:value="item.value"
|
|
36
|
+
:selected="String(item.value) === String(modelValue)"
|
|
37
|
+
class="input-select-option"
|
|
38
|
+
>
|
|
39
|
+
<Icon
|
|
40
|
+
v-if="item.icon"
|
|
41
|
+
:name="item.icon"
|
|
42
|
+
class="input-select-option-icon"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
focusable="false"
|
|
45
|
+
/>
|
|
46
|
+
{{ item.label }}
|
|
47
|
+
</option>
|
|
48
|
+
</select>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup lang="ts">
|
|
53
|
+
import type { IFormMultipleOptions, FormUiTheme, InputUiVariant } from "~/types/forms/types.forms";
|
|
54
|
+
|
|
55
|
+
interface Props {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
placeholder?: string;
|
|
59
|
+
title?: string;
|
|
60
|
+
required?: boolean;
|
|
61
|
+
fieldHasError?: boolean;
|
|
62
|
+
styleClassPassthrough?: string | string[];
|
|
63
|
+
theme?: FormUiTheme;
|
|
64
|
+
inputVariant?: InputUiVariant;
|
|
65
|
+
ariaDescribedby?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
69
|
+
placeholder: "",
|
|
70
|
+
title: "Please select an option",
|
|
71
|
+
required: false,
|
|
72
|
+
fieldHasError: false,
|
|
73
|
+
styleClassPassthrough: () => [],
|
|
74
|
+
theme: "default",
|
|
75
|
+
inputVariant: "normal",
|
|
76
|
+
ariaDescribedby: "",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const modelValue = defineModel<string | number | readonly number[]>({ required: true });
|
|
80
|
+
const isDirty = defineModel<boolean>("isDirty");
|
|
81
|
+
const isActive = defineModel<boolean>("isActive");
|
|
82
|
+
const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
|
|
83
|
+
|
|
84
|
+
const isEmpty = computed(() => modelValue.value === "" || modelValue.value == null);
|
|
85
|
+
|
|
86
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
87
|
+
|
|
88
|
+
watch(
|
|
89
|
+
() => props.styleClassPassthrough,
|
|
90
|
+
() => {
|
|
91
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<style lang="css">
|
|
97
|
+
@layer components {
|
|
98
|
+
.input-select-wrapper {
|
|
99
|
+
--_border: var(--input-select-border, var(--theme-border));
|
|
100
|
+
--_border-focus: var(--input-select-border-focus, var(--theme-border-focus));
|
|
101
|
+
|
|
102
|
+
min-inline-size: 0;
|
|
103
|
+
max-inline-size: 100%;
|
|
104
|
+
background-color: var(--input-select-surface, var(--theme-input-surface));
|
|
105
|
+
overflow: hidden;
|
|
106
|
+
|
|
107
|
+
z-index: 2;
|
|
108
|
+
position: relative;
|
|
109
|
+
transition: all var(--theme-form-transition-duration) ease-in-out;
|
|
110
|
+
|
|
111
|
+
&[data-input-variant="normal"] {
|
|
112
|
+
border: var(--form-element-border-width) solid var(--_border);
|
|
113
|
+
border-radius: var(--form-input-border-radius);
|
|
114
|
+
outline: var(--form-element-outline-width) solid transparent;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&[data-input-variant="underlined"] {
|
|
118
|
+
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
&:has(:hover) {
|
|
122
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
123
|
+
var(--input-select-border-hover, var(--theme-border-focus));
|
|
124
|
+
outline-offset: var(--form-element-outline-offset-focus);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&:has(select:focus-visible) {
|
|
128
|
+
outline: var(--form-element-outline-width-focus) solid var(--_border-focus);
|
|
129
|
+
outline-offset: var(--form-element-outline-offset-focus);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.input-select {
|
|
133
|
+
appearance: none;
|
|
134
|
+
background-color: transparent;
|
|
135
|
+
color: var(--input-select-text-color, var(--theme-input-text-color-normal));
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
|
|
139
|
+
&,
|
|
140
|
+
&::picker(select) {
|
|
141
|
+
appearance: base-select;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&::picker(select) {
|
|
145
|
+
transition:
|
|
146
|
+
display allow-discrete var(--theme-form-transition-duration),
|
|
147
|
+
opacity var(--theme-form-transition-duration),
|
|
148
|
+
overlay var(--theme-form-transition-duration) allow-discrete;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
&:not(:open)::picker(select) {
|
|
152
|
+
opacity: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
&:open::picker(select) {
|
|
156
|
+
opacity: 1;
|
|
157
|
+
border: var(--form-element-border-width) solid var(--_border);
|
|
158
|
+
outline: var(--form-element-outline-width) solid var(--_border-focus);
|
|
159
|
+
|
|
160
|
+
@starting-style {
|
|
161
|
+
opacity: 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
border: none;
|
|
166
|
+
outline: none;
|
|
167
|
+
border-radius: 0;
|
|
168
|
+
width: 100%;
|
|
169
|
+
min-inline-size: 0;
|
|
170
|
+
|
|
171
|
+
font-family: var(--font-family);
|
|
172
|
+
font-size: var(--input-font-size);
|
|
173
|
+
padding-block: var(--input-padding-block);
|
|
174
|
+
padding-inline: var(--input-padding-inline);
|
|
175
|
+
min-height: var(--input-min-height);
|
|
176
|
+
|
|
177
|
+
.input-select-option {
|
|
178
|
+
min-height: var(--input-select-option-min-height);
|
|
179
|
+
gap: var(--input-select-option-gap, 1.5rem);
|
|
180
|
+
overflow-wrap: anywhere;
|
|
181
|
+
white-space: normal;
|
|
182
|
+
transition: all var(--theme-form-transition-duration) ease-in-out;
|
|
183
|
+
|
|
184
|
+
&:hover {
|
|
185
|
+
background-color: var(--input-select-surface-hover, var(--theme-input-surface-hover));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.input-select-option-icon {
|
|
189
|
+
display: inline-block;
|
|
190
|
+
flex-shrink: 0;
|
|
191
|
+
width: var(--input-select-option-icon-size, 1.5rem);
|
|
192
|
+
height: var(--input-select-option-icon-size, 1.5rem);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
</style>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="input-select-field"
|
|
4
|
+
:class="elementClasses"
|
|
5
|
+
:data-testid
|
|
6
|
+
:data-theme="theme"
|
|
7
|
+
:data-input-variant="inputVariant"
|
|
8
|
+
:data-invalid="fieldHasError ? '' : null"
|
|
9
|
+
>
|
|
10
|
+
<InputLabel
|
|
11
|
+
:id
|
|
12
|
+
:required
|
|
13
|
+
:input-variant
|
|
14
|
+
:field-has-error
|
|
15
|
+
:style-class-passthrough="['input-select-label']"
|
|
16
|
+
>
|
|
17
|
+
<template #textLabel>{{ label }}</template>
|
|
18
|
+
</InputLabel>
|
|
19
|
+
|
|
20
|
+
<InputDescription
|
|
21
|
+
v-if="inputVariant !== 'outlined'"
|
|
22
|
+
:description-id
|
|
23
|
+
:input-variant
|
|
24
|
+
:field-has-error="fieldHasError"
|
|
25
|
+
:style-class-passthrough="['input-text-description']"
|
|
26
|
+
>
|
|
27
|
+
<template v-if="slots.descriptionHtml" #descriptionHtml>
|
|
28
|
+
<slot name="descriptionHtml"></slot>
|
|
29
|
+
</template>
|
|
30
|
+
<template v-if="slots.descriptionText" #descriptionText>
|
|
31
|
+
<slot name="descriptionText"></slot>
|
|
32
|
+
</template>
|
|
33
|
+
</InputDescription>
|
|
34
|
+
|
|
35
|
+
<InputSelect
|
|
36
|
+
:id
|
|
37
|
+
v-model="modelValue"
|
|
38
|
+
v-model:field-data="fieldData"
|
|
39
|
+
v-model:is-dirty="isDirty"
|
|
40
|
+
v-model:is-active="isActive"
|
|
41
|
+
:name
|
|
42
|
+
:placeholder
|
|
43
|
+
:title
|
|
44
|
+
:field-has-error
|
|
45
|
+
:required
|
|
46
|
+
:theme="selectTheme"
|
|
47
|
+
:aria-describedby="ariaDescribedby()"
|
|
48
|
+
:input-variant
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
<InputError :id="errorId" :error-message :show-error="fieldHasError" :is-detached="false" :input-variant />
|
|
52
|
+
|
|
53
|
+
<InputDescription
|
|
54
|
+
v-if="inputVariant === 'outlined'"
|
|
55
|
+
:description-id
|
|
56
|
+
:input-variant
|
|
57
|
+
:field-has-error="fieldHasError"
|
|
58
|
+
:style-class-passthrough="['input-text-description']"
|
|
59
|
+
>
|
|
60
|
+
<template v-if="slots.descriptionHtml" #descriptionHtml>
|
|
61
|
+
<slot name="descriptionHtml"></slot>
|
|
62
|
+
</template>
|
|
63
|
+
<template v-if="slots.descriptionText" #descriptionText>
|
|
64
|
+
<slot name="descriptionText"></slot>
|
|
65
|
+
</template>
|
|
66
|
+
</InputDescription>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<script setup lang="ts">
|
|
71
|
+
import type { FormUiTheme, InputUiVariant, IFormMultipleOptions } from "~/types/forms/types.forms";
|
|
72
|
+
|
|
73
|
+
interface Props {
|
|
74
|
+
dataTestid?: string;
|
|
75
|
+
name: string;
|
|
76
|
+
label: string;
|
|
77
|
+
placeholder?: string;
|
|
78
|
+
title?: string;
|
|
79
|
+
errorMessage: object | string;
|
|
80
|
+
required?: boolean;
|
|
81
|
+
fieldHasError?: boolean;
|
|
82
|
+
styleClassPassthrough?: string | string[];
|
|
83
|
+
theme?: FormUiTheme;
|
|
84
|
+
inputVariant?: InputUiVariant;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
88
|
+
dataTestid: "input-select-field",
|
|
89
|
+
placeholder: "",
|
|
90
|
+
title: "Please select an option",
|
|
91
|
+
required: false,
|
|
92
|
+
fieldHasError: false,
|
|
93
|
+
styleClassPassthrough: () => [],
|
|
94
|
+
theme: "default",
|
|
95
|
+
inputVariant: "normal",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const slots = useSlots();
|
|
99
|
+
|
|
100
|
+
const selectTheme = computed(() => (props.fieldHasError ? "error" : props.theme));
|
|
101
|
+
|
|
102
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
103
|
+
|
|
104
|
+
watch(
|
|
105
|
+
() => props.styleClassPassthrough,
|
|
106
|
+
() => {
|
|
107
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
112
|
+
props.name,
|
|
113
|
+
toRef(props, "fieldHasError"),
|
|
114
|
+
slots
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const modelValue = defineModel<string | number | readonly number[]>({ required: true });
|
|
118
|
+
const isDirty = defineModel<boolean>("isDirty");
|
|
119
|
+
const isActive = defineModel<boolean>("isActive");
|
|
120
|
+
const fieldData = defineModel<IFormMultipleOptions>("fieldData", { required: true });
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<style lang="css">
|
|
124
|
+
@layer components {
|
|
125
|
+
.input-select-field {
|
|
126
|
+
min-inline-size: 0;
|
|
127
|
+
overflow-wrap: anywhere;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</style>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { computed, ref } from "vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
import InputSelect from "../InputSelect.vue";
|
|
4
|
+
import CanvasSwitcher from "../../../01.atoms/canvas-switcher/CanvasSwitcher.vue";
|
|
5
|
+
import type { FormUiTheme, IFormMultipleOptions, IOptionsConfig, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
6
|
+
import type { MediaCanvas } from "~/types/components";
|
|
7
|
+
|
|
8
|
+
interface StoryArgs {
|
|
9
|
+
modelValue: string | number;
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
placeholder: string;
|
|
13
|
+
title: string;
|
|
14
|
+
required: boolean;
|
|
15
|
+
fieldHasError: boolean;
|
|
16
|
+
theme: FormUiTheme;
|
|
17
|
+
inputVariant: InputUiVariant;
|
|
18
|
+
options: IOptionsConfig[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const colourOptions: IOptionsConfig[] = [
|
|
22
|
+
{ id: "1", name: "red", value: "red", label: "Red" },
|
|
23
|
+
{ id: "2", name: "blue", value: "blue", label: "Blue" },
|
|
24
|
+
{ id: "3", name: "green", value: "green", label: "Green" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const meta: Meta<StoryArgs> = {
|
|
28
|
+
title: "Components/Forms/Input Select/InputSelect",
|
|
29
|
+
component: InputSelect,
|
|
30
|
+
argTypes: {
|
|
31
|
+
modelValue: { control: "text", table: { category: "Model" } },
|
|
32
|
+
id: { control: "text", table: { category: "Basic" } },
|
|
33
|
+
name: { control: "text", table: { category: "Basic" } },
|
|
34
|
+
placeholder: { control: "text", table: { category: "Basic" } },
|
|
35
|
+
title: { control: "text", description: "Native tooltip; pass translated copy", table: { category: "Accessibility" } },
|
|
36
|
+
required: { control: "boolean", table: { category: "Basic" } },
|
|
37
|
+
fieldHasError: { control: "boolean", table: { category: "States" } },
|
|
38
|
+
theme: {
|
|
39
|
+
control: { type: "select" },
|
|
40
|
+
options: ["default", "success", "error", "warning"],
|
|
41
|
+
table: { category: "Styling" },
|
|
42
|
+
},
|
|
43
|
+
inputVariant: {
|
|
44
|
+
control: { type: "select" },
|
|
45
|
+
options: ["normal", "outlined", "underlined"],
|
|
46
|
+
table: { category: "Styling" },
|
|
47
|
+
},
|
|
48
|
+
options: {
|
|
49
|
+
control: "object",
|
|
50
|
+
description: "Story-only: the `data` array of `v-model:field-data`",
|
|
51
|
+
table: { category: "Model" },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
args: {
|
|
55
|
+
modelValue: "",
|
|
56
|
+
id: "select-default",
|
|
57
|
+
name: "selectDefault",
|
|
58
|
+
placeholder: "Choose a colour",
|
|
59
|
+
title: "Please select an option",
|
|
60
|
+
required: false,
|
|
61
|
+
fieldHasError: false,
|
|
62
|
+
theme: "default",
|
|
63
|
+
inputVariant: "normal",
|
|
64
|
+
options: colourOptions,
|
|
65
|
+
},
|
|
66
|
+
decorators: [
|
|
67
|
+
(story, context) => ({
|
|
68
|
+
components: { story, CanvasSwitcher },
|
|
69
|
+
setup() {
|
|
70
|
+
const canvasName = ref<MediaCanvas>(context.parameters.initialCanvas ?? "fullWidthCanvas");
|
|
71
|
+
return { canvasName };
|
|
72
|
+
},
|
|
73
|
+
template: `
|
|
74
|
+
<div style="padding: 1.2rem 1.6rem; border-block-end: 1px solid currentColor;">
|
|
75
|
+
<CanvasSwitcher v-model:canvas-name="canvasName" />
|
|
76
|
+
</div>
|
|
77
|
+
<div :class="canvasName" style="margin-inline: auto; padding: 2rem; outline: 1px dashed currentColor;">
|
|
78
|
+
<story />
|
|
79
|
+
</div>
|
|
80
|
+
`,
|
|
81
|
+
}),
|
|
82
|
+
],
|
|
83
|
+
parameters: {
|
|
84
|
+
layout: "fullscreen",
|
|
85
|
+
docs: {
|
|
86
|
+
description: {
|
|
87
|
+
component:
|
|
88
|
+
"Bare native `<select>` for the `05.forms` select family: no label, description or error (use `InputSelectField` for those). Options come from `v-model:field-data` (`{ data: IOptionsConfig[] }`), each with an optional decorative `icon`. `placeholder` adds a disabled first option. Where the browser supports `appearance: base-select` the open picker is styled too; elsewhere the native dropdown shows. State is exposed as `data-input-variant`, `data-invalid`, `data-dirty` and `data-active` on `.input-select-wrapper`.",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default meta;
|
|
95
|
+
type Story = StoryObj<StoryArgs>;
|
|
96
|
+
|
|
97
|
+
const render = (args: StoryArgs) => ({
|
|
98
|
+
components: { InputSelect },
|
|
99
|
+
setup() {
|
|
100
|
+
const fieldData = computed<IFormMultipleOptions>(() => ({
|
|
101
|
+
data: args.options,
|
|
102
|
+
total: args.options.length,
|
|
103
|
+
skip: 0,
|
|
104
|
+
limit: args.options.length,
|
|
105
|
+
}));
|
|
106
|
+
const componentArgs = computed(() => {
|
|
107
|
+
const { modelValue: _modelValue, options: _options, ...rest } = args;
|
|
108
|
+
return rest;
|
|
109
|
+
});
|
|
110
|
+
return { args, componentArgs, fieldData };
|
|
111
|
+
},
|
|
112
|
+
template: `
|
|
113
|
+
<InputSelect v-model="args.modelValue" :field-data="fieldData" v-bind="componentArgs" />
|
|
114
|
+
<p style="margin-block-start: 1.6rem; font-family: monospace;">modelValue: {{ JSON.stringify(args.modelValue) }}</p>
|
|
115
|
+
`,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export const Default: Story = { render };
|
|
119
|
+
|
|
120
|
+
export const WithIcons: Story = {
|
|
121
|
+
args: {
|
|
122
|
+
options: [
|
|
123
|
+
{ id: "1", name: "email", value: "email", label: "Email", icon: "mdi:email-outline" },
|
|
124
|
+
{ id: "2", name: "phone", value: "phone", label: "Phone", icon: "mdi:phone-outline" },
|
|
125
|
+
{ id: "3", name: "post", value: "post", label: "Post", icon: "mdi:mailbox-outline" },
|
|
126
|
+
],
|
|
127
|
+
placeholder: "Contact preference",
|
|
128
|
+
},
|
|
129
|
+
render,
|
|
130
|
+
parameters: {
|
|
131
|
+
docs: {
|
|
132
|
+
description: {
|
|
133
|
+
story: "Options with an `icon`. The icon only shows in browsers that support `appearance: base-select`; the native dropdown shows the label alone.",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const ErrorState: Story = { args: { fieldHasError: true, theme: "error" }, render };
|
|
140
|
+
|
|
141
|
+
export const Underlined: Story = { args: { inputVariant: "underlined" }, render };
|
|
142
|
+
|
|
143
|
+
export const StressTest: Story = {
|
|
144
|
+
name: "Stress Test (Worst-Case Data)",
|
|
145
|
+
args: {
|
|
146
|
+
modelValue: "0",
|
|
147
|
+
placeholder: "Bitte wählen Sie die gewünschte Zustellungsart für Ihre Bestellung aus der folgenden Liste aus",
|
|
148
|
+
title: "Bitteeineoptionauswählenundanschließendbestätigen",
|
|
149
|
+
fieldHasError: true,
|
|
150
|
+
theme: "error",
|
|
151
|
+
options: [
|
|
152
|
+
{ id: "0", name: "zero", value: "0", label: "0 (falsy-looking value)" },
|
|
153
|
+
{
|
|
154
|
+
id: "1",
|
|
155
|
+
name: "long",
|
|
156
|
+
value: "long",
|
|
157
|
+
label: "Expresszustellungmitsendungsverfolgungundversicherungbiszueinemwarenwertvonzehntausendeuro",
|
|
158
|
+
},
|
|
159
|
+
{ id: "2", name: "url", value: "url", label: "https://example.com/a/very/long/unbroken/url/that/never/wraps/anywhere" },
|
|
160
|
+
{ id: "3", name: "emoji", value: "emoji", label: "🚚 Delivery 📦", icon: "mdi:truck-outline" },
|
|
161
|
+
{ id: "4", name: "html", value: "html", label: "<b>not bold</b> <script>alert(1)</script>" },
|
|
162
|
+
{ id: "5", name: "rtl", value: "rtl", label: "التوصيل السريع إلى باب المنزل" },
|
|
163
|
+
{ id: "6", name: "one", value: "x", label: "x" },
|
|
164
|
+
{ id: "7", name: "empty", value: "empty", label: "" },
|
|
165
|
+
...Array.from({ length: 30 }, (_, index) => ({
|
|
166
|
+
id: `n${index}`,
|
|
167
|
+
name: `n${index}`,
|
|
168
|
+
value: `n${index}`,
|
|
169
|
+
label: `Option ${index + 1}`,
|
|
170
|
+
})),
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
render,
|
|
174
|
+
parameters: {
|
|
175
|
+
initialCanvas: "mobileCanvas",
|
|
176
|
+
docs: {
|
|
177
|
+
story: { inline: false, height: "520px" },
|
|
178
|
+
description: {
|
|
179
|
+
story:
|
|
180
|
+
"38 options: a `0` value (selected on load, not the placeholder), long unbroken German and URL labels, emoji, HTML-like text (must render as text), right-to-left text, a single character and an empty label, plus a very long placeholder. Check at every canvas width: the closed select stays inside the dashed outline, and in a `base-select` browser the open picker's long labels wrap instead of running off screen. The empty label renders as a blank row; that's the data, not a bug.",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|