@tak-ps/vue-tabler 4.12.0 → 4.13.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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,14 @@
10
10
 
11
11
  ## Version History
12
12
 
13
+ ### v4.13.0
14
+
15
+ - :tada: Add PillGroup Component
16
+
17
+ ### v4.12.1
18
+
19
+ - :rocket: Additional Light mode improvements
20
+
13
21
  ### v4.12.0
14
22
 
15
23
  - :rocket: Light mode support for TablerIconButton and TablerInput
@@ -49,18 +49,23 @@ const iconButtonStyle = computed(() => {
49
49
  }
50
50
 
51
51
  .custom-hover:not(.disabled) {
52
- transition: background-color 0.15s ease, color 0.15s ease;
52
+ border: 1px solid transparent;
53
+ transition: background-color 0.15s ease, border-color 0.15s ease;
53
54
  }
54
55
 
55
56
  [data-bs-theme='light'] .custom-hover:not(.disabled):hover,
56
- [data-bs-theme='light'] .custom-hover:not(.disabled):focus-visible {
57
- background-color: var(--cloudtak-light, rgba(var(--tblr-primary-rgb), 0.08));
58
- color: var(--tblr-body-color);
57
+ [data-bs-theme='light'] .custom-hover:not(.disabled):focus-visible,
58
+ [data-bs-theme='light'] .custom-hover:not(.disabled):focus-within {
59
+ border-radius: 6px;
60
+ border-color: color-mix(in srgb, var(--tblr-body-color) 18%, transparent);
61
+ background: color-mix(in srgb, var(--tblr-body-color) 8%, transparent);
59
62
  }
60
63
 
61
64
  [data-bs-theme='dark'] .custom-hover:not(.disabled):hover,
62
- [data-bs-theme='dark'] .custom-hover:not(.disabled):focus-visible {
63
- background-color: var(--tblr-light);
64
- color: var(--tblr-dark);
65
+ [data-bs-theme='dark'] .custom-hover:not(.disabled):focus-visible,
66
+ [data-bs-theme='dark'] .custom-hover:not(.disabled):focus-within {
67
+ border-radius: 6px;
68
+ border-color: color-mix(in srgb, var(--tblr-light) 30%, transparent);
69
+ background: color-mix(in srgb, var(--tblr-light) 12%, transparent);
65
70
  }
66
71
  </style>
@@ -0,0 +1,91 @@
1
+ <template>
2
+ <div
3
+ :class='groupClasses'
4
+ role='group'
5
+ >
6
+ <template
7
+ v-for='(option, i) in options'
8
+ :key='option.value'
9
+ >
10
+ <input
11
+ :id='inputId(option, i)'
12
+ type='radio'
13
+ class='btn-check'
14
+ :name='name'
15
+ autocomplete='off'
16
+ :checked='modelValue === option.value'
17
+ :disabled='disabled || option.disabled'
18
+ @click='emit("update:modelValue", option.value)'
19
+ >
20
+ <label
21
+ :for='inputId(option, i)'
22
+ type='button'
23
+ :class='buttonClasses'
24
+ >
25
+ <slot
26
+ name='option'
27
+ :option='option'
28
+ :index='i'
29
+ :active='modelValue === option.value'
30
+ >
31
+ {{ option.label }}
32
+ </slot>
33
+ </label>
34
+ </template>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed, useId } from 'vue';
40
+
41
+ const uid = useId();
42
+
43
+ export interface PillGroupOption {
44
+ value: string;
45
+ label: string;
46
+ disabled?: boolean;
47
+ }
48
+
49
+ export interface PillGroupProps {
50
+ modelValue: string;
51
+ options: PillGroupOption[];
52
+ name?: string;
53
+ rounded?: boolean;
54
+ fullWidth?: boolean;
55
+ size?: 'sm' | 'default';
56
+ disabled?: boolean;
57
+ padding?: string;
58
+ }
59
+
60
+ const props = withDefaults(defineProps<PillGroupProps>(), {
61
+ name: 'pill-group',
62
+ rounded: true,
63
+ fullWidth: true,
64
+ size: 'sm',
65
+ disabled: false,
66
+ padding: 'px-2 py-2',
67
+ });
68
+
69
+ const emit = defineEmits<{
70
+ (e: 'update:modelValue', value: string): void;
71
+ }>();
72
+
73
+ function inputId(option: PillGroupOption, index: number): string {
74
+ return `${props.name}-${uid}-${option.value}-${index}`;
75
+ }
76
+
77
+ const groupClasses = computed(() => {
78
+ const classes: string[] = ['btn-group'];
79
+ if (props.rounded) classes.push('round');
80
+ if (props.fullWidth) classes.push('w-100');
81
+ if (props.padding) classes.push(props.padding);
82
+ if (props.size === 'sm' && !props.rounded) classes.push('btn-group-sm');
83
+ return classes;
84
+ });
85
+
86
+ const buttonClasses = computed(() => {
87
+ const classes: string[] = ['btn'];
88
+ if (props.size === 'sm') classes.push('btn-sm');
89
+ return classes;
90
+ });
91
+ </script>
package/lib.ts CHANGED
@@ -30,5 +30,6 @@ export { default as TablerEpoch } from './components/Epoch.vue';
30
30
  export { default as TablerEpochRange } from './components/EpochRange.vue';
31
31
  export { default as TablerMarkdown } from './components/Markdown.vue';
32
32
  export { default as TablerDelete } from './components/Delete.vue';
33
+ export { default as TablerPillGroup } from './components/PillGroup.vue';
33
34
  export { default as TablerSchema } from './components/Schema.vue';
34
35
  export { default as TablerSchemaBuilder } from './components/SchemaBuilder.vue';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tak-ps/vue-tabler",
3
3
  "type": "module",
4
- "version": "4.12.0",
4
+ "version": "4.13.0",
5
5
  "lib": "lib.ts",
6
6
  "main": "lib.ts",
7
7
  "module": "lib.ts",
@@ -45,15 +45,20 @@ describe('TablerIconButton', () => {
45
45
  expect(wrapper.get('div').classes()).not.toContain('custom-hover')
46
46
  })
47
47
 
48
- it('uses CloudTAK light hover colors in light mode and inverted colors in dark mode', () => {
48
+ it('uses theme-derived hover colors and borders instead of fixed values', () => {
49
+ expect(iconButtonSource).toContain('transition: background-color 0.15s ease, border-color 0.15s ease;')
50
+ expect(iconButtonSource).toContain('border: 1px solid transparent;')
51
+
49
52
  expect(iconButtonSource).toContain("[data-bs-theme='light'] .custom-hover:not(.disabled):hover,")
50
- expect(iconButtonSource).toContain("[data-bs-theme='light'] .custom-hover:not(.disabled):focus-visible {")
51
- expect(iconButtonSource).toContain('background-color: var(--cloudtak-light, rgba(var(--tblr-primary-rgb), 0.08));')
52
- expect(iconButtonSource).toContain('color: var(--tblr-body-color);')
53
+ expect(iconButtonSource).toContain("[data-bs-theme='light'] .custom-hover:not(.disabled):focus-visible,")
54
+ expect(iconButtonSource).toContain("[data-bs-theme='light'] .custom-hover:not(.disabled):focus-within {")
55
+ expect(iconButtonSource).toContain('border-color: color-mix(in srgb, var(--tblr-body-color) 18%, transparent);')
56
+ expect(iconButtonSource).toContain('background: color-mix(in srgb, var(--tblr-body-color) 8%, transparent);')
53
57
 
54
58
  expect(iconButtonSource).toContain("[data-bs-theme='dark'] .custom-hover:not(.disabled):hover,")
55
- expect(iconButtonSource).toContain("[data-bs-theme='dark'] .custom-hover:not(.disabled):focus-visible {")
56
- expect(iconButtonSource).toContain('background-color: var(--tblr-light);')
57
- expect(iconButtonSource).toContain('color: var(--tblr-dark);')
59
+ expect(iconButtonSource).toContain("[data-bs-theme='dark'] .custom-hover:not(.disabled):focus-visible,")
60
+ expect(iconButtonSource).toContain("[data-bs-theme='dark'] .custom-hover:not(.disabled):focus-within {")
61
+ expect(iconButtonSource).toContain('border-color: color-mix(in srgb, var(--tblr-light) 30%, transparent);')
62
+ expect(iconButtonSource).toContain('background: color-mix(in srgb, var(--tblr-light) 12%, transparent);')
58
63
  })
59
64
  })
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import PillGroup from '../components/PillGroup.vue'
4
+
5
+ const baseOptions = [
6
+ { value: 'a', label: 'Alpha' },
7
+ { value: 'b', label: 'Beta' },
8
+ { value: 'c', label: 'Gamma' },
9
+ ]
10
+
11
+ describe('TablerPillGroup', () => {
12
+ it('renders all options as radio inputs with labels', () => {
13
+ const wrapper = mount(PillGroup, {
14
+ props: { modelValue: 'a', options: baseOptions },
15
+ })
16
+
17
+ const inputs = wrapper.findAll('input[type="radio"]')
18
+ const labels = wrapper.findAll('label')
19
+
20
+ expect(inputs).toHaveLength(3)
21
+ expect(labels).toHaveLength(3)
22
+ expect(labels[0].text()).toBe('Alpha')
23
+ expect(labels[1].text()).toBe('Beta')
24
+ expect(labels[2].text()).toBe('Gamma')
25
+ })
26
+
27
+ it('checks the input matching modelValue', () => {
28
+ const wrapper = mount(PillGroup, {
29
+ props: { modelValue: 'b', options: baseOptions },
30
+ })
31
+
32
+ const inputs = wrapper.findAll('input[type="radio"]')
33
+ expect((inputs[0].element as HTMLInputElement).checked).toBe(false)
34
+ expect((inputs[1].element as HTMLInputElement).checked).toBe(true)
35
+ expect((inputs[2].element as HTMLInputElement).checked).toBe(false)
36
+ })
37
+
38
+ it('emits update:modelValue on click', async () => {
39
+ const wrapper = mount(PillGroup, {
40
+ props: { modelValue: 'a', options: baseOptions },
41
+ })
42
+
43
+ await wrapper.findAll('input[type="radio"]')[2].trigger('click')
44
+ expect(wrapper.emitted('update:modelValue')).toEqual([['c']])
45
+ })
46
+
47
+ it('applies rounded and fullWidth classes by default', () => {
48
+ const wrapper = mount(PillGroup, {
49
+ props: { modelValue: 'a', options: baseOptions },
50
+ })
51
+
52
+ const group = wrapper.find('[role="group"]')
53
+ expect(group.classes()).toContain('btn-group')
54
+ expect(group.classes()).toContain('round')
55
+ expect(group.classes()).toContain('w-100')
56
+ })
57
+
58
+ it('omits round and w-100 classes when disabled', () => {
59
+ const wrapper = mount(PillGroup, {
60
+ props: { modelValue: 'a', options: baseOptions, rounded: false, fullWidth: false },
61
+ })
62
+
63
+ const group = wrapper.find('[role="group"]')
64
+ expect(group.classes()).toContain('btn-group')
65
+ expect(group.classes()).not.toContain('round')
66
+ expect(group.classes()).not.toContain('w-100')
67
+ })
68
+
69
+ it('applies btn-sm class when size is sm', () => {
70
+ const wrapper = mount(PillGroup, {
71
+ props: { modelValue: 'a', options: baseOptions, size: 'sm' },
72
+ })
73
+
74
+ const label = wrapper.find('label')
75
+ expect(label.classes()).toContain('btn')
76
+ expect(label.classes()).toContain('btn-sm')
77
+ })
78
+
79
+ it('does not apply btn-sm when size is default', () => {
80
+ const wrapper = mount(PillGroup, {
81
+ props: { modelValue: 'a', options: baseOptions, size: 'default' },
82
+ })
83
+
84
+ const label = wrapper.find('label')
85
+ expect(label.classes()).toContain('btn')
86
+ expect(label.classes()).not.toContain('btn-sm')
87
+ })
88
+
89
+ it('disables all inputs when disabled prop is true', () => {
90
+ const wrapper = mount(PillGroup, {
91
+ props: { modelValue: 'a', options: baseOptions, disabled: true },
92
+ })
93
+
94
+ wrapper.findAll('input[type="radio"]').forEach(input => {
95
+ expect((input.element as HTMLInputElement).disabled).toBe(true)
96
+ })
97
+ })
98
+
99
+ it('disables individual options', () => {
100
+ const opts = [
101
+ { value: 'a', label: 'A' },
102
+ { value: 'b', label: 'B', disabled: true },
103
+ { value: 'c', label: 'C' },
104
+ ]
105
+ const wrapper = mount(PillGroup, {
106
+ props: { modelValue: 'a', options: opts },
107
+ })
108
+
109
+ const inputs = wrapper.findAll('input[type="radio"]')
110
+ expect((inputs[0].element as HTMLInputElement).disabled).toBe(false)
111
+ expect((inputs[1].element as HTMLInputElement).disabled).toBe(true)
112
+ expect((inputs[2].element as HTMLInputElement).disabled).toBe(false)
113
+ })
114
+
115
+ it('uses custom name prop for radio group', () => {
116
+ const wrapper = mount(PillGroup, {
117
+ props: { modelValue: 'a', options: baseOptions, name: 'my-group' },
118
+ })
119
+
120
+ wrapper.findAll('input[type="radio"]').forEach(input => {
121
+ expect(input.attributes('name')).toBe('my-group')
122
+ })
123
+ })
124
+
125
+ it('applies custom padding', () => {
126
+ const wrapper = mount(PillGroup, {
127
+ props: { modelValue: 'a', options: baseOptions, padding: 'p-1' },
128
+ })
129
+
130
+ const group = wrapper.find('[role="group"]')
131
+ expect(group.classes()).toContain('p-1')
132
+ expect(group.classes()).not.toContain('px-2')
133
+ })
134
+
135
+ it('supports scoped option slot', () => {
136
+ const wrapper = mount(PillGroup, {
137
+ props: { modelValue: 'b', options: baseOptions },
138
+ slots: {
139
+ option: `<template #option="{ option, active }">
140
+ <span class="custom">{{ option.label }} {{ active ? 'ON' : 'OFF' }}</span>
141
+ </template>`,
142
+ },
143
+ })
144
+
145
+ const labels = wrapper.findAll('label')
146
+ expect(labels[0].text()).toContain('Alpha OFF')
147
+ expect(labels[1].text()).toContain('Beta ON')
148
+ })
149
+ })