Skip to content

Commit e54275b

Browse files
authored
feat: add NeFormItemLabel and NeRadioSelection (#16)
1 parent 83becdb commit e54275b

File tree

5 files changed

+426
-0
lines changed

5 files changed

+426
-0
lines changed

src/components/NeFormItemLabel.vue

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!--
2+
Copyright (C) 2024 Nethesis S.r.l.
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
6+
<script setup lang="ts"></script>
7+
8+
<template>
9+
<label class="mb-2 block text-sm font-medium leading-6 text-gray-700 dark:text-gray-200">
10+
<slot></slot>
11+
</label>
12+
</template>

src/components/NeRadioSelection.vue

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<!--
2+
Copyright (C) 2024 Nethesis S.r.l.
3+
SPDX-License-Identifier: GPL-3.0-or-later
4+
-->
5+
6+
<script lang="ts" setup>
7+
import { type PropType, type Ref, ref, watch } from 'vue'
8+
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
9+
import { library } from '@fortawesome/fontawesome-svg-core'
10+
import { faCircleCheck } from '@fortawesome/free-solid-svg-icons'
11+
import NeFormItemLabel from '@/components/NeFormItemLabel.vue'
12+
13+
export type RadioCardSize = 'md' | 'lg' | 'xl'
14+
15+
type RadioOption = {
16+
id: string
17+
label: string
18+
description?: string
19+
icon?: string
20+
iconStyle?: string
21+
disabled?: boolean
22+
}
23+
24+
const props = defineProps({
25+
modelValue: {
26+
type: String
27+
},
28+
label: {
29+
required: true,
30+
type: String
31+
},
32+
description: {
33+
required: false,
34+
type: String
35+
},
36+
options: {
37+
required: true,
38+
type: Array<RadioOption>
39+
},
40+
card: {
41+
type: Boolean,
42+
default: false
43+
},
44+
gridStyle: {
45+
type: String,
46+
default: 'grid-cols-3 gap-3'
47+
},
48+
disabled: {
49+
type: Boolean,
50+
default: false
51+
},
52+
cardSize: {
53+
type: String as PropType<RadioCardSize>,
54+
default: 'md'
55+
},
56+
cardSelectionMark: {
57+
type: Boolean,
58+
default: true
59+
}
60+
})
61+
62+
const emit = defineEmits(['update:modelValue'])
63+
64+
// expose focus function
65+
defineExpose({
66+
focus
67+
})
68+
69+
library.add(faCircleCheck)
70+
71+
const value: Ref<any> = ref(props.modelValue ?? '')
72+
73+
const inputRef = ref()
74+
75+
const cardClasses: Record<RadioCardSize, string> = {
76+
md: 'px-4 py-2',
77+
lg: 'px-5 py-4',
78+
xl: 'px-6 py-5'
79+
}
80+
81+
const iconClasses: Record<RadioCardSize, string> = {
82+
md: 'h-7 w-7 pr-4',
83+
lg: 'h-10 w-10 pr-5',
84+
xl: 'h-12 w-12 pr-6'
85+
}
86+
87+
const textClasses: Record<RadioCardSize, string> = {
88+
md: 'text-sm',
89+
lg: 'text-sm',
90+
xl: 'text-base'
91+
}
92+
93+
const selectionMarkClasses: Record<RadioCardSize, string> = {
94+
md: 'right-2 top-2 h-4 w-4',
95+
lg: 'right-3 top-3 h-4 w-4',
96+
xl: 'right-3 top-3 h-5 w-5'
97+
}
98+
99+
watch(value, (newValue) => emit('update:modelValue', newValue))
100+
101+
watch(
102+
() => props.modelValue,
103+
() => {
104+
value.value = props.modelValue
105+
}
106+
)
107+
108+
function focus() {
109+
inputRef.value[0].focus()
110+
}
111+
</script>
112+
113+
<template>
114+
<div>
115+
<div class="mb-2 text-sm">
116+
<NeFormItemLabel class="mb-0">
117+
{{ label }}
118+
<span v-if="$slots.tooltip" class="ml-1">
119+
<slot name="tooltip"></slot>
120+
</span>
121+
</NeFormItemLabel>
122+
<p v-if="description" class="text-gray-500 dark:text-gray-400">{{ description }}</p>
123+
</div>
124+
<div v-if="card" :class="gridStyle" class="grid">
125+
<button
126+
v-for="option in options"
127+
:key="option.id"
128+
type="button"
129+
:class="[
130+
`${cardClasses[cardSize]}`,
131+
value == option.id ? 'ring-2 ring-primary-700 dark:ring-primary-500' : ''
132+
]"
133+
class="relative flex w-full items-center overflow-hidden rounded-md border text-gray-700 shadow-sm hover:bg-gray-200/70 focus:outline-none focus:ring-2 focus:ring-primary-700 focus:ring-offset-2 focus:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:text-gray-50 dark:hover:bg-gray-600/30 dark:focus:ring-primary-500 dark:focus:ring-offset-primary-950"
134+
role="button"
135+
@click="value = option.id"
136+
:disabled="option.disabled || disabled"
137+
ref="inputRef"
138+
>
139+
<FontAwesomeIcon
140+
v-if="option.icon"
141+
:icon="[option.iconStyle ?? 'fas', option.icon]"
142+
:class="`${iconClasses[cardSize]}`"
143+
/>
144+
<!-- custom content -->
145+
<template v-if="$slots.option">
146+
<slot :option="option" name="option" />
147+
</template>
148+
<!-- standard label and description -->
149+
<div v-else :class="`text-left ${textClasses[cardSize]}`">
150+
<p class="font-medium">
151+
{{ option.label }}
152+
</p>
153+
<p v-if="option.description != undefined">{{ option.description }}</p>
154+
</div>
155+
<!-- top-right selection icon -->
156+
<FontAwesomeIcon
157+
v-if="value == option.id && cardSelectionMark"
158+
:icon="['fas', 'circle-check']"
159+
:class="`absolute text-primary-700 dark:text-primary-500 ${selectionMarkClasses[cardSize]}`"
160+
/>
161+
</button>
162+
</div>
163+
<template v-else>
164+
<fieldset>
165+
<legend class="sr-only">{{ label }}</legend>
166+
<div class="space-y-3">
167+
<div v-for="option in options" :key="option.id" class="flex items-center">
168+
<input
169+
:id="option.id"
170+
v-model="value"
171+
:checked="value == option.id"
172+
:value="option.id"
173+
class="peer border-gray-300 text-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-950 dark:text-primary-500 checked:dark:bg-primary-500 dark:focus:ring-primary-300 focus:dark:ring-primary-200 focus:dark:ring-offset-gray-900"
174+
type="radio"
175+
:disabled="option.disabled || disabled"
176+
ref="inputRef"
177+
/>
178+
<label
179+
:for="option.id"
180+
:disabled="option.disabled"
181+
class="ml-2 text-gray-700 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 dark:text-gray-50"
182+
>
183+
{{ option.label }}
184+
</label>
185+
</div>
186+
</div>
187+
</fieldset>
188+
</template>
189+
</div>
190+
</template>

src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export { default as NeCombobox } from '@/components/NeCombobox.vue'
2525
export { default as NeDropdown } from '@/components/NeDropdown.vue'
2626
export { default as NeCard } from '@/components/NeCard.vue'
2727
export { default as NeLink } from '@/components/NeLink.vue'
28+
export { default as NeFormItemLabel } from '@/components/NeFormItemLabel.vue'
29+
export { default as NeRadioSelection } from '@/components/NeRadioSelection.vue'
2830

2931
// types
3032
export type { NeComboboxOption } from '@/components/NeCombobox.vue'

stories/NeFormItemLabel.stories.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (C) 2024 Nethesis S.r.l.
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
import type { Meta, StoryObj } from '@storybook/vue3'
5+
import { NeFormItemLabel } from '../src/main'
6+
7+
const meta = {
8+
title: 'Visual/NeFormItemLabel',
9+
component: NeFormItemLabel
10+
} satisfies Meta<typeof NeFormItemLabel>
11+
12+
export default meta
13+
type Story = StoryObj<typeof meta>
14+
15+
const template =
16+
'<NeFormItemLabel v-bind="args">Label</NeFormItemLabel><div class="text-sm text-gray-500 dark:text-gray-500">Put here any form item that needs a label</div>'
17+
18+
export const Default: Story = {
19+
render: (args) => ({
20+
components: { NeFormItemLabel },
21+
setup() {
22+
return { args }
23+
},
24+
template: template
25+
}),
26+
args: {}
27+
}

0 commit comments

Comments
 (0)