@polymarbot/nuxt-layer-shadcn-ui 0.8.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/app/components/ui/Checkbox/index.stories.ts +9 -0
  2. package/app/components/ui/Checkbox/index.vue +4 -1
  3. package/app/components/ui/Checkbox/types.ts +3 -1
  4. package/app/components/ui/DatePicker/index.stories.ts +9 -0
  5. package/app/components/ui/DatePicker/index.vue +3 -0
  6. package/app/components/ui/DatePicker/types.ts +2 -0
  7. package/app/components/ui/DateRangePicker/index.stories.ts +9 -0
  8. package/app/components/ui/DateRangePicker/index.vue +3 -0
  9. package/app/components/ui/DateRangePicker/types.ts +2 -0
  10. package/app/components/ui/FormItem/index.stories.ts +137 -10
  11. package/app/components/ui/FormItem/index.vue +10 -3
  12. package/app/components/ui/Input/index.vue +3 -1
  13. package/app/components/ui/InputCurrency/index.stories.ts +9 -0
  14. package/app/components/ui/InputCurrency/types.ts +3 -1
  15. package/app/components/ui/InputNumber/index.vue +10 -7
  16. package/app/components/ui/InputOtp/index.stories.ts +10 -0
  17. package/app/components/ui/InputOtp/index.vue +5 -1
  18. package/app/components/ui/InputOtp/types.ts +1 -0
  19. package/app/components/ui/InputPercent/index.stories.ts +11 -0
  20. package/app/components/ui/InputPercent/index.vue +6 -0
  21. package/app/components/ui/InputPercent/types.ts +3 -0
  22. package/app/components/ui/InputRange/index.stories.ts +11 -0
  23. package/app/components/ui/RadioCardGroup/index.stories.ts +9 -0
  24. package/app/components/ui/RadioCardGroup/index.vue +4 -0
  25. package/app/components/ui/RadioCardGroup/types.ts +1 -0
  26. package/app/components/ui/RadioGroup/index.stories.ts +9 -0
  27. package/app/components/ui/RadioGroup/index.vue +4 -0
  28. package/app/components/ui/RadioGroup/types.ts +1 -0
  29. package/app/components/ui/SearchSelect/index.stories.ts +9 -0
  30. package/app/components/ui/SearchSelect/index.vue +2 -0
  31. package/app/components/ui/SearchSelect/types.ts +1 -0
  32. package/app/components/ui/Select/index.stories.ts +9 -0
  33. package/app/components/ui/Select/index.vue +6 -0
  34. package/app/components/ui/Select/types.ts +1 -0
  35. package/app/components/ui/Textarea/index.vue +3 -1
  36. package/app/composables/useFormItemInvalid.ts +16 -0
  37. package/package.json +2 -2
@@ -10,6 +10,7 @@ const meta = {
10
10
  defaultValue: { control: 'select', options: [ true, false, 'indeterminate' ]},
11
11
  disabled: { control: 'boolean' },
12
12
  required: { control: 'boolean' },
13
+ invalid: { control: 'boolean' },
13
14
  name: { control: 'text' },
14
15
  value: { control: 'text' },
15
16
  },
@@ -18,6 +19,7 @@ const meta = {
18
19
  defaultValue: false,
19
20
  disabled: false,
20
21
  required: false,
22
+ invalid: false,
21
23
  name: '',
22
24
  value: '',
23
25
  },
@@ -104,6 +106,13 @@ export const Indeterminate: Story = {
104
106
  }),
105
107
  }
106
108
 
109
+ export const Invalid: Story = {
110
+ parameters: noControls,
111
+ args: {
112
+ invalid: true,
113
+ },
114
+ }
115
+
107
116
  export const Disabled: Story = {
108
117
  parameters: {
109
118
  ...noControls,
@@ -2,12 +2,15 @@
2
2
  import { Checkbox as ShadcnCheckbox } from '../../shadcn/checkbox'
3
3
  import type { CheckboxProps } from './types'
4
4
 
5
- defineProps<CheckboxProps>()
5
+ const props = defineProps<CheckboxProps>()
6
+
7
+ const isInvalid = useFormItemInvalid(() => props.invalid)
6
8
  </script>
7
9
 
8
10
  <template>
9
11
  <ShadcnCheckbox
10
12
  v-slot="{ state }"
13
+ :aria-invalid="isInvalid || undefined"
11
14
  class="
12
15
  data-[state=indeterminate]:border-primary
13
16
  data-[state=indeterminate]:bg-primary
@@ -1,3 +1,5 @@
1
1
  import type { CheckboxRootProps } from 'reka-ui'
2
2
 
3
- export interface CheckboxProps extends /* @vue-ignore */ CheckboxRootProps {}
3
+ export interface CheckboxProps extends /* @vue-ignore */ CheckboxRootProps {
4
+ invalid?: boolean
5
+ }
@@ -16,6 +16,7 @@ const meta = {
16
16
  showTime: { control: 'boolean' },
17
17
  disabled: { control: 'boolean' },
18
18
  readonly: { control: 'boolean' },
19
+ invalid: { control: 'boolean' },
19
20
  placeholder: { control: 'text' },
20
21
  minDate: { control: 'date' },
21
22
  maxDate: { control: 'date' },
@@ -29,6 +30,7 @@ const meta = {
29
30
  showTime: false,
30
31
  disabled: false,
31
32
  readonly: false,
33
+ invalid: false,
32
34
  placeholder: '',
33
35
  minDate: undefined,
34
36
  maxDate: undefined,
@@ -224,6 +226,13 @@ export const Readonly: Story = {
224
226
  }),
225
227
  }
226
228
 
229
+ export const Invalid: Story = {
230
+ parameters: noControls,
231
+ args: {
232
+ invalid: true,
233
+ },
234
+ }
235
+
227
236
  export const InModal: Story = {
228
237
  parameters: {
229
238
  ...noControls,
@@ -20,6 +20,7 @@ const props = withDefaults(defineProps<DatePickerProps>(), {
20
20
  showTime: false,
21
21
  disabled: false,
22
22
  readonly: false,
23
+ invalid: false,
23
24
  placeholder: undefined,
24
25
  minDate: undefined,
25
26
  maxDate: undefined,
@@ -28,6 +29,8 @@ const props = withDefaults(defineProps<DatePickerProps>(), {
28
29
  class: undefined,
29
30
  })
30
31
 
32
+ useFormItemInvalid(() => props.invalid)
33
+
31
34
  const emit = defineEmits<{
32
35
  'update:modelValue': [value: Date | string | number | null]
33
36
  }>()
@@ -27,6 +27,8 @@ export interface DatePickerProps {
27
27
  disabled?: boolean
28
28
  /** Readonly mode */
29
29
  readonly?: boolean
30
+ /** Mark the field as invalid (renders the inner Input with destructive styling) */
31
+ invalid?: boolean
30
32
  /** Placeholder text */
31
33
  placeholder?: string
32
34
  /** Minimum selectable date */
@@ -13,6 +13,7 @@ const meta = {
13
13
  showTime: { control: 'boolean' },
14
14
  disabled: { control: 'boolean' },
15
15
  readonly: { control: 'boolean' },
16
+ invalid: { control: 'boolean' },
16
17
  startPlaceholder: { control: 'text' },
17
18
  endPlaceholder: { control: 'text' },
18
19
  maxSpanDays: { control: 'number' },
@@ -28,6 +29,7 @@ const meta = {
28
29
  showTime: false,
29
30
  disabled: false,
30
31
  readonly: false,
32
+ invalid: false,
31
33
  startPlaceholder: '',
32
34
  endPlaceholder: '',
33
35
  maxSpanDays: undefined,
@@ -196,3 +198,10 @@ export const Readonly: Story = {
196
198
  `,
197
199
  }),
198
200
  }
201
+
202
+ export const Invalid: Story = {
203
+ parameters: noControls,
204
+ args: {
205
+ invalid: true,
206
+ },
207
+ }
@@ -10,6 +10,7 @@ const props = withDefaults(defineProps<DateRangePickerProps>(), {
10
10
  showTime: false,
11
11
  disabled: false,
12
12
  readonly: false,
13
+ invalid: false,
13
14
  startPlaceholder: undefined,
14
15
  endPlaceholder: undefined,
15
16
  minDate: undefined,
@@ -20,6 +21,8 @@ const props = withDefaults(defineProps<DateRangePickerProps>(), {
20
21
  class: undefined,
21
22
  })
22
23
 
24
+ useFormItemInvalid(() => props.invalid)
25
+
23
26
  const emit = defineEmits<{
24
27
  'update:start': [value: Date | string | number | null]
25
28
  'update:end': [value: Date | string | number | null]
@@ -21,6 +21,8 @@ export interface DateRangePickerProps {
21
21
  disabled?: boolean
22
22
  /** Readonly mode */
23
23
  readonly?: boolean
24
+ /** Mark the field as invalid (renders both inputs with destructive styling) */
25
+ invalid?: boolean
24
26
  /** Placeholder for start date input */
25
27
  startPlaceholder?: string
26
28
  /** Placeholder for end date input */
@@ -1,5 +1,25 @@
1
1
  import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import type { RadioCardGroupOption } from '../RadioCardGroup/types'
3
+ import type { RadioGroupItem } from '../RadioGroup/types'
4
+ import type { SelectOption } from '../Select/types'
5
+ import type {
6
+ SearchSelectLoadMethodParams,
7
+ SearchSelectLoadMethodResult,
8
+ } from '../SearchSelect/types'
9
+ import Checkbox from '../Checkbox/index.vue'
10
+ import DatePicker from '../DatePicker/index.vue'
11
+ import DateRangePicker from '../DateRangePicker/index.vue'
2
12
  import Input from '../Input/index.vue'
13
+ import InputCurrency from '../InputCurrency/index.vue'
14
+ import InputNumber from '../InputNumber/index.vue'
15
+ import InputOtp from '../InputOtp/index.vue'
16
+ import InputPercent from '../InputPercent/index.vue'
17
+ import InputRange from '../InputRange/index.vue'
18
+ import RadioCardGroup from '../RadioCardGroup/index.vue'
19
+ import RadioGroup from '../RadioGroup/index.vue'
20
+ import SearchSelect from '../SearchSelect/index.vue'
21
+ import Select from '../Select/index.vue'
22
+ import Textarea from '../Textarea/index.vue'
3
23
  import FormItem from './index.vue'
4
24
 
5
25
  const orientations = [ 'vertical', 'horizontal', 'responsive' ] as const
@@ -51,15 +71,6 @@ export const Required: Story = {
51
71
  },
52
72
  }
53
73
 
54
- export const WithError: Story = {
55
- parameters: noControls,
56
- args: {
57
- label: 'Username',
58
- required: true,
59
- error: 'Username is already taken',
60
- },
61
- }
62
-
63
74
  export const WithDescription: Story = {
64
75
  parameters: noControls,
65
76
  args: {
@@ -76,6 +87,122 @@ export const Horizontal: Story = {
76
87
  },
77
88
  }
78
89
 
90
+ const selectOptions: SelectOption[] = [
91
+ { label: 'React', value: 'react' },
92
+ { label: 'Vue', value: 'vue' },
93
+ { label: 'Angular', value: 'angular' },
94
+ ]
95
+
96
+ const radioOptions: RadioGroupItem[] = [
97
+ { value: 'a', label: 'Option A' },
98
+ { value: 'b', label: 'Option B' },
99
+ ]
100
+
101
+ const radioCardOptions: RadioCardGroupOption[] = [
102
+ { value: 'card-a', title: 'Card A', description: 'First option' },
103
+ { value: 'card-b', title: 'Card B', description: 'Second option' },
104
+ ]
105
+
106
+ function mockLoadMethod (params: SearchSelectLoadMethodParams): Promise<SearchSelectLoadMethodResult<string>> {
107
+ const items = selectOptions.slice(params.offset, params.offset + params.limit)
108
+ return Promise.resolve({ items, total: selectOptions.length })
109
+ }
110
+
111
+ export const InvalidControls: Story = {
112
+ parameters: {
113
+ ...noControls,
114
+ docs: {
115
+ description: {
116
+ story: 'Every input-like control automatically inherits the surrounding `FormItem`\'s error state via provide/inject — no need to wire `invalid` on each control. The same destructive styling appears whether the error comes from `<FormItem :error>` or the control\'s own `invalid` prop.',
117
+ },
118
+ source: {
119
+ code: `
120
+ <template>
121
+ <FormItem required label="Username" error="Required">
122
+ <Input placeholder="Type your username" />
123
+ </FormItem>
124
+ </template>
125
+ `.trim(),
126
+ },
127
+ },
128
+ },
129
+ render: () => ({
130
+ components: {
131
+ FormItem,
132
+ Input,
133
+ Textarea,
134
+ InputNumber,
135
+ InputCurrency,
136
+ InputPercent,
137
+ InputRange,
138
+ InputOtp,
139
+ Select,
140
+ SearchSelect,
141
+ DatePicker,
142
+ DateRangePicker,
143
+ Checkbox,
144
+ RadioGroup,
145
+ RadioCardGroup,
146
+ },
147
+ setup: () => ({
148
+ error: 'This field is required',
149
+ selectOptions,
150
+ radioOptions,
151
+ radioCardOptions,
152
+ mockLoadMethod,
153
+ }),
154
+ template: `
155
+ <div class="max-w-md space-y-4">
156
+ <FormItem required label="Input" :error="error">
157
+ <Input placeholder="Type something" />
158
+ </FormItem>
159
+ <FormItem required label="Textarea" :error="error">
160
+ <Textarea placeholder="Long text" />
161
+ </FormItem>
162
+ <FormItem required label="InputNumber" :error="error">
163
+ <InputNumber />
164
+ </FormItem>
165
+ <FormItem required label="InputCurrency" :error="error">
166
+ <InputCurrency :modelValue="1000" currency="JPY" />
167
+ </FormItem>
168
+ <FormItem required label="InputPercent" :error="error">
169
+ <InputPercent :modelValue="0.5" />
170
+ </FormItem>
171
+ <FormItem required label="InputRange" :error="error">
172
+ <InputRange />
173
+ </FormItem>
174
+ <FormItem required label="InputOtp" :error="error">
175
+ <InputOtp />
176
+ </FormItem>
177
+ <FormItem required label="Select" :error="error">
178
+ <Select :options="selectOptions" placeholder="Pick a framework" />
179
+ </FormItem>
180
+ <FormItem required label="SearchSelect" :error="error">
181
+ <SearchSelect :loadMethod="mockLoadMethod" autoLoad />
182
+ </FormItem>
183
+ <FormItem required label="DatePicker" :error="error">
184
+ <DatePicker />
185
+ </FormItem>
186
+ <FormItem required label="DateRangePicker" :error="error">
187
+ <DateRangePicker />
188
+ </FormItem>
189
+ <FormItem required label="Checkbox" :error="error">
190
+ <label class="flex items-center gap-2 cursor-pointer">
191
+ <Checkbox />
192
+ <span class="text-sm">Accept terms</span>
193
+ </label>
194
+ </FormItem>
195
+ <FormItem required label="RadioGroup" :error="error">
196
+ <RadioGroup :items="radioOptions" />
197
+ </FormItem>
198
+ <FormItem required label="RadioCardGroup" :error="error">
199
+ <RadioCardGroup :options="radioCardOptions" />
200
+ </FormItem>
201
+ </div>
202
+ `,
203
+ }),
204
+ }
205
+
79
206
  export const Responsive: Story = {
80
207
  parameters: {
81
208
  ...noControls,
@@ -98,7 +225,7 @@ export const Responsive: Story = {
98
225
  template: `
99
226
  <div class="max-w-md">
100
227
  <div class="@container/field-group resize-x overflow-auto rounded border border-dashed border-border bg-card p-4" style="min-width: 200px;">
101
- <FormItem label="Address" orientation="responsive" description="Your mailing address">
228
+ <FormItem required label="Address" orientation="responsive" description="Your mailing address">
102
229
  <Input placeholder="Enter address" />
103
230
  </FormItem>
104
231
  </div>
@@ -10,15 +10,22 @@ import type { FormItemProps } from './types'
10
10
 
11
11
  const props = defineProps<FormItemProps>()
12
12
 
13
+ useFormItemInvalid(() => !!props.error)
14
+
13
15
  const errorArray = computed(() => {
14
16
  if (!props.error) return undefined
15
17
  return [{ message: props.error }]
16
18
  })
17
19
 
18
20
  const labelClass = computed(() => {
19
- if (props.orientation === 'horizontal') return 'justify-end text-right mt-2'
20
- if (props.orientation === 'responsive') return '@md/field-group:justify-end @md/field-group:text-right @md/field-group:mt-2'
21
- return undefined
21
+ const base = 'group-data-[invalid=true]/field:text-foreground'
22
+ if (props.orientation === 'horizontal') return cn(base, `
23
+ mt-2 justify-end text-right
24
+ `)
25
+ if (props.orientation === 'responsive') return cn(base, `
26
+ @md/field-group:justify-end @md/field-group:text-right @md/field-group:mt-2
27
+ `)
28
+ return base
22
29
  })
23
30
  </script>
24
31
 
@@ -16,6 +16,8 @@ const emit = defineEmits<{
16
16
  'change': [value: string | undefined]
17
17
  }>()
18
18
 
19
+ const isInvalid = useFormItemInvalid(() => props.invalid)
20
+
19
21
  const $slots = defineSlots<{
20
22
  prefix?: () => unknown
21
23
  suffix?: () => unknown
@@ -65,7 +67,7 @@ function clearInput () {
65
67
  v-bind="$attrs"
66
68
  :readonly="readonly"
67
69
  :disabled="disabled"
68
- :aria-invalid="invalid || undefined"
70
+ :aria-invalid="isInvalid || undefined"
69
71
  :data-1p-ignore="autocomplete === 'off' || !autocomplete ? true : undefined"
70
72
  :autocomplete="autocomplete || 'off'"
71
73
  @input="handleInput"
@@ -11,11 +11,13 @@ const meta = {
11
11
  modelValue: { control: 'number' },
12
12
  currency: { control: 'text' },
13
13
  currencyDisplay: { control: 'select', options: currencyDisplays },
14
+ invalid: { control: 'boolean' },
14
15
  },
15
16
  args: {
16
17
  modelValue: 1000,
17
18
  currency: 'JPY',
18
19
  currencyDisplay: 'symbol',
20
+ invalid: false,
19
21
  },
20
22
  render: args => {
21
23
  const onUpdate = useArgsModel()
@@ -78,3 +80,10 @@ export const JpyNameDisplay: Story = {
78
80
  currencyDisplay: 'name',
79
81
  },
80
82
  }
83
+
84
+ export const Invalid: Story = {
85
+ parameters: noControls,
86
+ args: {
87
+ invalid: true,
88
+ },
89
+ }
@@ -1,4 +1,6 @@
1
- export interface InputCurrencyProps {
1
+ import type { InputNumberProps } from '../InputNumber/types'
2
+
3
+ export interface InputCurrencyProps extends /* @vue-ignore */ Pick<InputNumberProps, 'invalid'> {
2
4
  currency?: string
3
5
  currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name'
4
6
  }
@@ -28,26 +28,29 @@ const model = computed({
28
28
  set: value => emit('update:modelValue', value),
29
29
  })
30
30
 
31
+ const isInvalid = useFormItemInvalid(() => props.invalid)
32
+
31
33
  const contentClass = computed(() =>
32
34
  cn(
33
35
  `
34
- flex h-9 items-center rounded-md border border-input shadow-xs
35
- transition-[color,box-shadow]
36
+ h-9 rounded-md border-input shadow-xs
36
37
  dark:bg-input/30
38
+ flex items-center border transition-[color,box-shadow]
37
39
  `,
38
40
  `
39
41
  has-[[data-slot=input]:focus-visible]:border-ring
40
- has-[[data-slot=input]:focus-visible]:ring-[3px]
41
42
  has-[[data-slot=input]:focus-visible]:ring-ring/50
43
+ has-[[data-slot=input]:focus-visible]:ring-[3px]
42
44
  `,
43
- props.invalid && `
45
+ isInvalid.value && `
44
46
  border-destructive ring-destructive/20
45
47
  dark:ring-destructive/40
46
48
  `,
47
49
  ),
48
50
  )
49
51
 
50
- const inputClass = 'flex-1 border-0 shadow-none focus-visible:ring-0 rounded-none'
52
+ const buttonClass = 'static translate-y-0 shrink-0 cursor-pointer'
53
+ const inputClass = 'flex-1 min-w-0 border-0 shadow-none focus-visible:ring-0 rounded-none'
51
54
  </script>
52
55
 
53
56
  <template>
@@ -61,7 +64,7 @@ const inputClass = 'flex-1 border-0 shadow-none focus-visible:ring-0 rounded-non
61
64
  <NumberFieldContent :class="contentClass">
62
65
  <NumberFieldDecrement
63
66
  v-if="showButtons"
64
- class="cursor-pointer"
67
+ :class="buttonClass"
65
68
  />
66
69
  <NumberFieldInput
67
70
  :placeholder="placeholder"
@@ -69,7 +72,7 @@ const inputClass = 'flex-1 border-0 shadow-none focus-visible:ring-0 rounded-non
69
72
  />
70
73
  <NumberFieldIncrement
71
74
  v-if="showButtons"
72
- class="cursor-pointer"
75
+ :class="buttonClass"
73
76
  />
74
77
  </NumberFieldContent>
75
78
  </NumberField>
@@ -10,11 +10,13 @@ const meta = {
10
10
  modelValue: { control: 'text' },
11
11
  length: { control: 'number' },
12
12
  disabled: { control: 'boolean' },
13
+ invalid: { control: 'boolean' },
13
14
  },
14
15
  args: {
15
16
  modelValue: '',
16
17
  length: 6,
17
18
  disabled: false,
19
+ invalid: false,
18
20
  },
19
21
  render: args => {
20
22
  const onUpdate = useArgsModel()
@@ -53,6 +55,14 @@ export const Disabled: Story = {
53
55
  },
54
56
  }
55
57
 
58
+ export const Invalid: Story = {
59
+ parameters: noControls,
60
+ args: {
61
+ invalid: true,
62
+ modelValue: '123456',
63
+ },
64
+ }
65
+
56
66
  export const EventHandling: Story = {
57
67
  parameters: noControls,
58
68
  render: () => ({
@@ -6,10 +6,11 @@ import {
6
6
  } from '../../shadcn/pin-input'
7
7
  import type { InputOtpProps } from './types'
8
8
 
9
- withDefaults(defineProps<InputOtpProps>(), {
9
+ const props = withDefaults(defineProps<InputOtpProps>(), {
10
10
  modelValue: '',
11
11
  length: 6,
12
12
  disabled: false,
13
+ invalid: false,
13
14
  })
14
15
 
15
16
  const emit = defineEmits<{
@@ -17,6 +18,8 @@ const emit = defineEmits<{
17
18
  'complete': [value: string]
18
19
  }>()
19
20
 
21
+ const isInvalid = useFormItemInvalid(() => props.invalid)
22
+
20
23
  function handleComplete (values: string[]) {
21
24
  emit('complete', values.join(''))
22
25
  }
@@ -39,6 +42,7 @@ function handleUpdate (value: string[]) {
39
42
  v-for="(_, index) in length"
40
43
  :key="index"
41
44
  :index="index"
45
+ :aria-invalid="isInvalid || undefined"
42
46
  />
43
47
  </PinInputGroup>
44
48
  </PinInput>
@@ -2,4 +2,5 @@ export interface InputOtpProps {
2
2
  modelValue?: string
3
3
  length?: number
4
4
  disabled?: boolean
5
+ invalid?: boolean
5
6
  }
@@ -7,9 +7,11 @@ const meta = {
7
7
  component: InputPercent as any,
8
8
  argTypes: {
9
9
  modelValue: { control: 'number' },
10
+ invalid: { control: 'boolean' },
10
11
  },
11
12
  args: {
12
13
  modelValue: 0.5,
14
+ invalid: false,
13
15
  },
14
16
  render: args => {
15
17
  const onUpdate = useArgsModel()
@@ -29,4 +31,13 @@ const meta = {
29
31
  export default meta
30
32
  type Story = StoryObj<typeof meta>
31
33
 
34
+ const noControls = { controls: { disable: true }} satisfies Story['parameters']
35
+
32
36
  export const Default: Story = {}
37
+
38
+ export const Invalid: Story = {
39
+ parameters: noControls,
40
+ args: {
41
+ invalid: true,
42
+ },
43
+ }
@@ -1,3 +1,9 @@
1
+ <script setup lang="ts">
2
+ import type { InputPercentProps } from './types'
3
+
4
+ defineProps<InputPercentProps>()
5
+ </script>
6
+
1
7
  <template>
2
8
  <InputNumber
3
9
  :formatOptions="{ style: 'percent' }"
@@ -0,0 +1,3 @@
1
+ import type { InputNumberProps } from '../InputNumber/types'
2
+
3
+ export interface InputPercentProps extends /* @vue-ignore */ Pick<InputNumberProps, 'invalid'> {}
@@ -15,6 +15,7 @@ const meta = {
15
15
  startPlaceholder: { control: 'text' },
16
16
  endPlaceholder: { control: 'text' },
17
17
  disabled: { control: 'boolean' },
18
+ invalid: { control: 'boolean' },
18
19
  as: { control: false },
19
20
  },
20
21
  args: {
@@ -25,6 +26,7 @@ const meta = {
25
26
  startPlaceholder: '',
26
27
  endPlaceholder: '',
27
28
  disabled: false,
29
+ invalid: false,
28
30
  as: undefined,
29
31
  },
30
32
  render: args => {
@@ -71,6 +73,15 @@ export const Disabled: Story = {
71
73
  },
72
74
  }
73
75
 
76
+ export const Invalid: Story = {
77
+ parameters: noControls,
78
+ args: {
79
+ invalid: true,
80
+ start: 20,
81
+ end: 80,
82
+ },
83
+ }
84
+
74
85
  export const CustomInput: Story = {
75
86
  parameters: {
76
87
  ...noControls,
@@ -43,11 +43,13 @@ const meta = {
43
43
  modelValue: { control: 'text' },
44
44
  options: { control: 'object' },
45
45
  disabled: { control: 'boolean' },
46
+ invalid: { control: 'boolean' },
46
47
  },
47
48
  args: {
48
49
  modelValue: 'current',
49
50
  options,
50
51
  disabled: false,
52
+ invalid: false,
51
53
  },
52
54
  render: args => {
53
55
  const onUpdate = useArgsModel()
@@ -106,6 +108,13 @@ export const Disabled: Story = {
106
108
  },
107
109
  }
108
110
 
111
+ export const Invalid: Story = {
112
+ parameters: noControls,
113
+ args: {
114
+ invalid: true,
115
+ },
116
+ }
117
+
109
118
  export const EventHandling: Story = {
110
119
  parameters: {
111
120
  ...noControls,
@@ -8,9 +8,12 @@ import type { RadioCardGroupProps } from './types'
8
8
  const props = withDefaults(defineProps<RadioCardGroupProps>(), {
9
9
  modelValue: undefined,
10
10
  disabled: false,
11
+ invalid: false,
11
12
  class: undefined,
12
13
  })
13
14
 
15
+ const isInvalid = useFormItemInvalid(() => props.invalid)
16
+
14
17
  const emit = defineEmits<{
15
18
  'update:modelValue': [value: string]
16
19
  }>()
@@ -47,6 +50,7 @@ const mergedClass = computed(() => cn('gap-3', props.class))
47
50
  <ShadcnRadioGroupItem
48
51
  :value="option.value"
49
52
  :disabled="option.disabled"
53
+ :aria-invalid="isInvalid || undefined"
50
54
  />
51
55
  <div class="gap-0.5 grid flex-1">
52
56
  <span class="text-sm font-medium">
@@ -9,5 +9,6 @@ export interface RadioCardGroupProps {
9
9
  modelValue?: string
10
10
  options: RadioCardGroupOption[]
11
11
  disabled?: boolean
12
+ invalid?: boolean
12
13
  class?: ClassValue
13
14
  }
@@ -23,12 +23,14 @@ const meta = {
23
23
  items: { control: 'object' },
24
24
  modelValue: { control: 'text' },
25
25
  disabled: { control: 'boolean' },
26
+ invalid: { control: 'boolean' },
26
27
  orientation: { control: 'inline-radio', options: [ 'vertical', 'horizontal' ]},
27
28
  },
28
29
  args: {
29
30
  items: options,
30
31
  modelValue: 'option1',
31
32
  disabled: false,
33
+ invalid: false,
32
34
  orientation: 'vertical',
33
35
  },
34
36
  render: args => {
@@ -70,6 +72,13 @@ export const Disabled: Story = {
70
72
  },
71
73
  }
72
74
 
75
+ export const Invalid: Story = {
76
+ parameters: noControls,
77
+ args: {
78
+ invalid: true,
79
+ },
80
+ }
81
+
73
82
  export const CustomSlots: Story = {
74
83
  parameters: {
75
84
  ...noControls,
@@ -9,10 +9,13 @@ const props = withDefaults(defineProps<RadioGroupProps>(), {
9
9
  items: () => [],
10
10
  modelValue: undefined,
11
11
  disabled: false,
12
+ invalid: false,
12
13
  orientation: 'vertical',
13
14
  class: undefined,
14
15
  })
15
16
 
17
+ const isInvalid = useFormItemInvalid(() => props.invalid)
18
+
16
19
  const emit = defineEmits<{
17
20
  'update:modelValue': [value: string]
18
21
  }>()
@@ -58,6 +61,7 @@ const mergedClass = computed(() => cn(
58
61
  <ShadcnRadioGroupItem
59
62
  :value="item.value"
60
63
  :disabled="item.disabled"
64
+ :aria-invalid="isInvalid || undefined"
61
65
  />
62
66
  <slot
63
67
  name="label"
@@ -8,6 +8,7 @@ export interface RadioGroupProps {
8
8
  modelValue?: string
9
9
  items?: RadioGroupItem[]
10
10
  disabled?: boolean
11
+ invalid?: boolean
11
12
  orientation?: 'vertical' | 'horizontal'
12
13
  class?: ClassValue
13
14
  }
@@ -52,6 +52,7 @@ const meta = {
52
52
  loadLimit: { control: 'number' },
53
53
  autoLoad: { control: 'boolean' },
54
54
  disabled: { control: 'boolean' },
55
+ invalid: { control: 'boolean' },
55
56
  },
56
57
  args: {
57
58
  modelValue: undefined,
@@ -64,6 +65,7 @@ const meta = {
64
65
  loadLimit: 20,
65
66
  autoLoad: false,
66
67
  disabled: false,
68
+ invalid: false,
67
69
  },
68
70
  render: args => {
69
71
  const onUpdate = useArgsModel()
@@ -182,6 +184,13 @@ export const Disabled: Story = {
182
184
  },
183
185
  }
184
186
 
187
+ export const Invalid: Story = {
188
+ parameters: noControls,
189
+ args: {
190
+ invalid: true,
191
+ },
192
+ }
193
+
185
194
  export const EventHandling: Story = {
186
195
  parameters: {
187
196
  ...noControls,
@@ -16,6 +16,7 @@ const props = withDefaults(defineProps<SearchSelectProps<TValue, TMeta>>(), {
16
16
  loadLimit: 20,
17
17
  placeholder: undefined,
18
18
  disabled: false,
19
+ invalid: false,
19
20
  searchPlaceholder: undefined,
20
21
  emptyText: undefined,
21
22
  searchEmptyText: undefined,
@@ -191,6 +192,7 @@ defineExpose({ refresh: resetAndLoad })
191
192
  :searchPlaceholder="searchPlaceholder"
192
193
  :emptyText="computedEmptyText"
193
194
  :disabled="disabled"
195
+ :invalid="invalid"
194
196
  :loading="isLoading"
195
197
  @search="handleSearch"
196
198
  @open="handleOpen"
@@ -27,6 +27,7 @@ export interface SearchSelectProps<V extends string | number = string, M = unkno
27
27
  loadLimit?: number
28
28
  placeholder?: string
29
29
  disabled?: boolean
30
+ invalid?: boolean
30
31
  searchPlaceholder?: string
31
32
  /** Message when no options available (no search keyword) */
32
33
  emptyText?: string
@@ -38,6 +38,7 @@ const meta = {
38
38
  modelValue: { control: 'text' },
39
39
  placeholder: { control: 'text' },
40
40
  disabled: { control: 'boolean' },
41
+ invalid: { control: 'boolean' },
41
42
  loading: { control: 'boolean' },
42
43
  filter: { control: 'boolean' },
43
44
  multiple: { control: 'boolean' },
@@ -48,6 +49,7 @@ const meta = {
48
49
  modelValue: undefined,
49
50
  placeholder: 'Select an option',
50
51
  disabled: false,
52
+ invalid: false,
51
53
  loading: false,
52
54
  filter: false,
53
55
  multiple: false,
@@ -290,6 +292,13 @@ export const Loading: Story = {
290
292
  },
291
293
  }
292
294
 
295
+ export const Invalid: Story = {
296
+ parameters: noControls,
297
+ args: {
298
+ invalid: true,
299
+ },
300
+ }
301
+
293
302
  export const EventHandling: Story = {
294
303
  parameters: noControls,
295
304
  render: () => ({
@@ -29,6 +29,7 @@ const props = withDefaults(defineProps<SelectProps<TValue, TMeta>>(), {
29
29
  modelValue: undefined,
30
30
  placeholder: undefined,
31
31
  disabled: false,
32
+ invalid: false,
32
33
  loading: false,
33
34
  filter: false,
34
35
  searchPlaceholder: undefined,
@@ -36,6 +37,8 @@ const props = withDefaults(defineProps<SelectProps<TValue, TMeta>>(), {
36
37
  multiple: false,
37
38
  })
38
39
 
40
+ const isInvalid = useFormItemInvalid(() => props.invalid)
41
+
39
42
  const emit = defineEmits<{
40
43
  'update:modelValue': [value: TValue | TValue[]]
41
44
  'search': [value: string]
@@ -154,10 +157,13 @@ function handleClear (event: MouseEvent) {
154
157
  role="combobox"
155
158
  tabindex="0"
156
159
  :aria-expanded="open"
160
+ :aria-invalid="isInvalid || undefined"
157
161
  :data-disabled="disabled || undefined"
158
162
  :data-state="open ? 'open' : 'closed'"
159
163
  class="
160
164
  data-[state=open]:border-ring data-[state=open]:ring-ring/50
165
+ aria-invalid:ring-destructive/20 aria-invalid:border-destructive
166
+ dark:aria-invalid:ring-destructive/40
161
167
  cursor-pointer
162
168
  data-[state=open]:ring-[3px]
163
169
  "
@@ -13,6 +13,7 @@ export type SelectBaseProps<V extends string | number = string, M = unknown> = {
13
13
  options?: SelectOption<V, M>[]
14
14
  placeholder?: string
15
15
  disabled?: boolean
16
+ invalid?: boolean
16
17
  /** Show a spinner in place of the chevron */
17
18
  loading?: boolean
18
19
  /** true: enable client-side label filter; function: custom filter (disables internal filter) */
@@ -17,6 +17,8 @@ const emit = defineEmits<{
17
17
  'change': [value: string]
18
18
  }>()
19
19
 
20
+ const isInvalid = useFormItemInvalid(() => props.invalid)
21
+
20
22
  function handleInput (event: Event) {
21
23
  const target = event.target as HTMLTextAreaElement
22
24
  emit('update:modelValue', target.value)
@@ -40,7 +42,7 @@ const mergedClass = computed(() =>
40
42
  :modelValue="modelValue"
41
43
  :rows="rows"
42
44
  :class="mergedClass"
43
- :aria-invalid="invalid || undefined"
45
+ :aria-invalid="isInvalid || undefined"
44
46
  :data-1p-ignore="autocomplete === 'off' || !autocomplete ? true : undefined"
45
47
  :autocomplete="autocomplete || 'off'"
46
48
  v-bind="$attrs"
@@ -0,0 +1,16 @@
1
+ import type { InjectionKey, MaybeRefOrGetter, Ref } from 'vue'
2
+
3
+ const formItemInvalidKey: InjectionKey<Ref<boolean>> = Symbol('formItemInvalid')
4
+
5
+ /**
6
+ * Resolves the effective invalid state for an input-like component, combining
7
+ * its own `invalid` prop with any ancestor FormItem's error state. Re-provides
8
+ * the combined state so nested input-likes (e.g. Input inside DatePicker)
9
+ * inherit it without manual prop forwarding.
10
+ */
11
+ export function useFormItemInvalid (localInvalid?: MaybeRefOrGetter<boolean | undefined>) {
12
+ const ancestor = inject(formItemInvalidKey, ref(false))
13
+ const effective = computed(() => !!toValue(localInvalid) || ancestor.value)
14
+ provide(formItemInvalidKey, effective)
15
+ return effective
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymarbot/nuxt-layer-shadcn-ui",
3
- "version": "0.8.8",
3
+ "version": "0.9.0",
4
4
  "description": "Nuxt layer providing shadcn-vue based UI components",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
@@ -42,5 +42,5 @@
42
42
  "vue-i18n": "^11",
43
43
  "vue-router": "^4 || ^5"
44
44
  },
45
- "gitHead": "802c526041ac9f00b5c54357d6a36fe4e549929c"
45
+ "gitHead": "aedca95dc524baad501d555838cae7ffefd4a853"
46
46
  }