@polymarbot/nuxt-layer-shadcn-ui 0.8.7 → 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 (43) 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/Drawer/index.stories.ts +78 -1
  11. package/app/components/ui/Drawer/index.vue +31 -9
  12. package/app/components/ui/Drawer/types.ts +5 -0
  13. package/app/components/ui/FormItem/index.stories.ts +137 -10
  14. package/app/components/ui/FormItem/index.vue +10 -3
  15. package/app/components/ui/Input/index.vue +3 -1
  16. package/app/components/ui/InputCurrency/index.stories.ts +9 -0
  17. package/app/components/ui/InputCurrency/types.ts +3 -1
  18. package/app/components/ui/InputNumber/index.vue +10 -7
  19. package/app/components/ui/InputOtp/index.stories.ts +10 -0
  20. package/app/components/ui/InputOtp/index.vue +5 -1
  21. package/app/components/ui/InputOtp/types.ts +1 -0
  22. package/app/components/ui/InputPercent/index.stories.ts +11 -0
  23. package/app/components/ui/InputPercent/index.vue +6 -0
  24. package/app/components/ui/InputPercent/types.ts +3 -0
  25. package/app/components/ui/InputRange/index.stories.ts +11 -0
  26. package/app/components/ui/Modal/index.stories.ts +78 -0
  27. package/app/components/ui/Modal/index.vue +31 -9
  28. package/app/components/ui/Modal/types.ts +5 -0
  29. package/app/components/ui/RadioCardGroup/index.stories.ts +9 -0
  30. package/app/components/ui/RadioCardGroup/index.vue +4 -0
  31. package/app/components/ui/RadioCardGroup/types.ts +1 -0
  32. package/app/components/ui/RadioGroup/index.stories.ts +9 -0
  33. package/app/components/ui/RadioGroup/index.vue +4 -0
  34. package/app/components/ui/RadioGroup/types.ts +1 -0
  35. package/app/components/ui/SearchSelect/index.stories.ts +9 -0
  36. package/app/components/ui/SearchSelect/index.vue +2 -0
  37. package/app/components/ui/SearchSelect/types.ts +1 -0
  38. package/app/components/ui/Select/index.stories.ts +9 -0
  39. package/app/components/ui/Select/index.vue +6 -0
  40. package/app/components/ui/Select/types.ts +1 -0
  41. package/app/components/ui/Textarea/index.vue +3 -1
  42. package/app/composables/useFormItemInvalid.ts +16 -0
  43. package/package.json +2 -2
@@ -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,
@@ -4,6 +4,7 @@ import EventLog from '#storybook/EventLog.vue'
4
4
  import { useArgsModel } from '#storybook/argsModel'
5
5
  import Button from '../Button/index.vue'
6
6
  import Input from '../Input/index.vue'
7
+ import type { ModalAction } from './types'
7
8
  import Modal from './index.vue'
8
9
 
9
10
  const types: ModalContentType[] = [ 'default', 'success', 'info', 'help', 'warn', 'danger', 'error' ]
@@ -255,6 +256,83 @@ export const WithTrigger: Story = {
255
256
  }),
256
257
  }
257
258
 
259
+ export const PreventClose: Story = {
260
+ parameters: {
261
+ ...noControls,
262
+ docs: {
263
+ source: {
264
+ code: `
265
+ <template>
266
+ <Modal
267
+ v-model:visible="visible"
268
+ title="Type to Continue"
269
+ description="beforeClose intercepts the confirm action; cancel/X/ESC close normally."
270
+ showCancel
271
+ confirmText="Submit"
272
+ :beforeClose="onBeforeClose"
273
+ >
274
+ <Input v-model="value" placeholder="Type 'confirm' to close" />
275
+ <p v-if="error" class="mt-2 text-sm text-destructive">{{ error }}</p>
276
+ </Modal>
277
+ </template>
278
+
279
+ <script setup lang="ts">
280
+ import type { ModalAction } from '#components'
281
+
282
+ const visible = ref(false)
283
+ const value = ref('')
284
+ const error = ref('')
285
+
286
+ function onBeforeClose (action: ModalAction) {
287
+ if (action === 'cancel') return
288
+ if (value.value !== 'confirm') {
289
+ error.value = "Value must be 'confirm' to close."
290
+ return false
291
+ }
292
+ error.value = ''
293
+ return new Promise(resolve => setTimeout(resolve, 1000))
294
+ }
295
+ </script>
296
+ `.trim(),
297
+ },
298
+ },
299
+ },
300
+ render: () => ({
301
+ components: { Modal, Button, Input },
302
+ setup () {
303
+ const visible = ref(false)
304
+ const value = ref('')
305
+ const error = ref('')
306
+ function onBeforeClose (action: ModalAction) {
307
+ if (action === 'cancel') return
308
+ if (value.value !== 'confirm') {
309
+ error.value = 'Value must be "confirm" to close.'
310
+ return false
311
+ }
312
+ error.value = ''
313
+ return new Promise<void>(resolve => setTimeout(resolve, 1000))
314
+ }
315
+ return { visible, value, error, onBeforeClose }
316
+ },
317
+ template: `
318
+ <div>
319
+ <Button @click="visible = true">Open Modal</Button>
320
+ <Modal
321
+ v-model:visible="visible"
322
+ title="Type to Continue"
323
+ description="beforeClose intercepts the confirm action; cancel/X/ESC close normally."
324
+ showCancel
325
+ confirmText="Submit"
326
+ :beforeClose="onBeforeClose"
327
+ >
328
+ <Input v-model="value" placeholder="Type 'confirm' to close" />
329
+ <p v-if="error" class="mt-2 text-sm text-destructive">{{ error }}</p>
330
+ </Modal>
331
+ </div>
332
+ `,
333
+ }),
334
+ }
335
+
258
336
  export const EventHandling: Story = {
259
337
  parameters: {
260
338
  ...noControls,
@@ -9,7 +9,7 @@ import {
9
9
  DialogTitle,
10
10
  DialogTrigger,
11
11
  } from '../../shadcn/dialog'
12
- import type { ModalProps } from './types'
12
+ import type { ModalAction, ModalProps } from './types'
13
13
 
14
14
  defineOptions({ inheritAttrs: false })
15
15
 
@@ -25,6 +25,7 @@ const props = withDefaults(defineProps<ModalProps>(), {
25
25
  content: undefined,
26
26
  confirmVariant: 'default',
27
27
  cancelVariant: 'outline',
28
+ beforeClose: undefined,
28
29
  type: undefined,
29
30
  class: undefined,
30
31
  })
@@ -48,6 +49,8 @@ const resolvedCancelText = computed(
48
49
  )
49
50
 
50
51
  const dialogOpen = ref(props.visible ?? false)
52
+ const internalLoading = ref(false)
53
+ const isLoading = computed(() => internalLoading.value || props.loading)
51
54
 
52
55
  watch(() => props.visible, value => {
53
56
  if (value !== undefined) dialogOpen.value = value
@@ -60,18 +63,37 @@ watch(dialogOpen, value => {
60
63
  })
61
64
 
62
65
  function onOpenUpdate (value: boolean) {
63
- if (!value && props.loading) return
66
+ if (!value && isLoading.value) return
64
67
  if (value) dialogOpen.value = true
65
- else onCancel()
68
+ else handleClose('cancel')
66
69
  }
67
70
 
68
71
  function onConfirm () {
69
72
  emit('confirm')
70
- dialogOpen.value = false
73
+ handleClose('confirm')
71
74
  }
72
75
 
73
76
  function onCancel () {
74
77
  emit('cancel')
78
+ handleClose('cancel')
79
+ }
80
+
81
+ function handleClose (action: ModalAction) {
82
+ if (!props.beforeClose) {
83
+ dialogOpen.value = false
84
+ return
85
+ }
86
+ const result = props.beforeClose(action)
87
+ if (result === false) return
88
+ if (result instanceof Promise) {
89
+ internalLoading.value = true
90
+ result.then(() => {
91
+ dialogOpen.value = false
92
+ }).finally(() => {
93
+ internalLoading.value = false
94
+ })
95
+ return
96
+ }
75
97
  dialogOpen.value = false
76
98
  }
77
99
 
@@ -143,8 +165,8 @@ const contentClass = computed(() =>
143
165
  <ModalContent
144
166
  :type="type"
145
167
  :content="content"
146
- :inert="loading || disabled || undefined"
147
- :class="[ loading || disabled ? 'opacity-50' : undefined ]"
168
+ :inert="isLoading || disabled || undefined"
169
+ :class="[ isLoading || disabled ? 'opacity-50' : undefined ]"
148
170
  class="p-1"
149
171
  >
150
172
  <slot />
@@ -171,7 +193,7 @@ const contentClass = computed(() =>
171
193
  v-if="showCancel"
172
194
  class="min-w-32"
173
195
  :variant="cancelVariant"
174
- :disabled="loading"
196
+ :disabled="isLoading"
175
197
  @click="onCancel"
176
198
  >
177
199
  {{ resolvedCancelText }}
@@ -179,7 +201,7 @@ const contentClass = computed(() =>
179
201
  <Button
180
202
  :class="showCancel ? 'min-w-32' : 'min-w-48'"
181
203
  :variant="confirmVariant"
182
- :loading="loading"
204
+ :loading="isLoading"
183
205
  :disabled="disabled || confirmDisabled"
184
206
  @click="onConfirm"
185
207
  >
@@ -191,7 +213,7 @@ const contentClass = computed(() =>
191
213
 
192
214
  <DialogClose
193
215
  v-if="showClose"
194
- :disabled="loading"
216
+ :disabled="isLoading"
195
217
  class="
196
218
  top-3 right-3 size-8 text-muted-foreground ring-offset-background
197
219
  hover:bg-accent/50 hover:text-foreground
@@ -1,6 +1,10 @@
1
1
  import type { ButtonVariants } from '../../shadcn/button'
2
2
  import type { ModalContentProps } from '../ModalContent/types'
3
3
 
4
+ export type ModalAction = 'confirm' | 'cancel'
5
+
6
+ export type ModalBeforeClose = (action: ModalAction) => boolean | undefined | Promise<unknown>
7
+
4
8
  export interface ModalProps {
5
9
  visible?: boolean
6
10
  loading?: boolean
@@ -20,6 +24,7 @@ export interface ModalProps {
20
24
  cancelText?: string
21
25
  confirmVariant?: ButtonVariants['variant']
22
26
  cancelVariant?: ButtonVariants['variant']
27
+ beforeClose?: ModalBeforeClose
23
28
  type?: ModalContentProps['type']
24
29
  class?: ClassValue
25
30
  }
@@ -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"