@citizenplane/pimp 9.7.5 → 9.7.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@citizenplane/pimp",
3
- "version": "9.7.5",
3
+ "version": "9.7.6",
4
4
  "scripts": {
5
5
  "dev": "storybook dev -p 8080",
6
6
  "build-storybook": "storybook build --output-dir ./docs",
@@ -3,19 +3,20 @@
3
3
  <div class="cpCheckbox__wrapper">
4
4
  <input
5
5
  :id="checkboxUniqueId"
6
- v-model="isChecked"
6
+ ref="inputRef"
7
7
  :autofocus="autofocus"
8
+ :checked="isChecked"
8
9
  :disabled="isDisabled"
9
10
  :name="groupName"
10
11
  type="checkbox"
11
12
  :value="checkboxValue"
12
- @change="onChange(checkboxValue)"
13
+ @change="onChange"
13
14
  />
14
- <cp-icon type="check" />
15
+ <cp-icon :type="icon" />
15
16
  </div>
16
17
  <div class="cpCheckbox__content">
17
18
  <slot>
18
- <span v-if="checkboxLabel" class="cpCheckbox__label" :class="labelComputedClasses">
19
+ <span v-if="checkboxLabel" class="cpCheckbox__label">
19
20
  {{ checkboxLabel }}
20
21
  </span>
21
22
  </slot>
@@ -27,19 +28,23 @@
27
28
  </template>
28
29
 
29
30
  <script setup lang="ts">
30
- import { computed, ref, useSlots, useId } from 'vue'
31
+ import { computed, ref, useSlots, useId, watch, nextTick } from 'vue'
31
32
 
32
33
  import { ToggleColors } from '@/constants'
33
34
  import { capitalizeFirstLetter } from '@/helpers'
34
35
 
36
+ type EmitType = {
37
+ (e: 'update:modelValue', value: boolean | unknown[]): void
38
+ }
39
+
35
40
  interface Props {
36
41
  autofocus?: boolean
37
- capitalizeLabel?: boolean
38
42
  checkboxLabel?: string
39
43
  checkboxValue?: string | number
40
44
  color?: string
41
45
  groupName?: string
42
46
  helper?: string
47
+ indeterminate?: boolean
43
48
  isDisabled?: boolean
44
49
  modelValue?: boolean | unknown[]
45
50
  reverseLabel?: boolean
@@ -49,34 +54,42 @@ const props = withDefaults(defineProps<Props>(), {
49
54
  modelValue: false,
50
55
  checkboxValue: '',
51
56
  checkboxLabel: '',
52
- isDisabled: false,
53
57
  groupName: '',
54
- // eslint-disable-next-line vue/no-boolean-default
55
- capitalizeLabel: true,
56
58
  color: ToggleColors.BLUE,
57
- reverseLabel: false,
58
- autofocus: false,
59
59
  helper: '',
60
60
  })
61
61
 
62
- const emit = defineEmits(['update:modelValue'])
62
+ const emit = defineEmits<EmitType>()
63
63
 
64
- const checkedProxy = ref(false)
65
64
  const checkboxUniqueId = useId()
66
-
67
- const isChecked = computed({
68
- get() {
69
- if (Array.isArray(props.modelValue)) {
70
- return props.modelValue.includes(props.checkboxValue)
71
- }
72
- return props.modelValue
73
- },
74
- set(newValue) {
75
- checkedProxy.value = newValue
65
+ const inputRef = ref<HTMLInputElement>()
66
+
67
+ // Synchronize native checkbox state with our props
68
+ // as it will not reactively update otherwise
69
+ watch(
70
+ () => ({ checked: props.modelValue, indeterminate: props.indeterminate }),
71
+ ({ checked, indeterminate }) => {
72
+ nextTick(() => {
73
+ if (inputRef.value) {
74
+ inputRef.value.indeterminate = indeterminate
75
+ if (!Array.isArray(checked)) {
76
+ inputRef.value.checked = checked as boolean
77
+ }
78
+ }
79
+ })
76
80
  },
81
+ { immediate: true },
82
+ )
83
+
84
+ const isChecked = computed(() => {
85
+ if (Array.isArray(props.modelValue)) {
86
+ return props.modelValue.includes(props.checkboxValue)
87
+ }
88
+ return props.modelValue
77
89
  })
78
90
 
79
91
  const capitalizedColor = computed(() => capitalizeFirstLetter(props.color))
92
+ const icon = computed(() => (props.indeterminate ? 'minus' : 'check'))
80
93
 
81
94
  const slots = useSlots()
82
95
  const hasDefaultSlot = computed(() => !!slots.default)
@@ -89,36 +102,39 @@ const computedClasses = computed(() => {
89
102
  'cpCheckbox--isEmpty': isEmpty.value,
90
103
  'cpCheckbox--isDisabled': props.isDisabled,
91
104
  'cpCheckbox--isReversed': props.reverseLabel,
105
+ 'cpCheckbox--isIndeterminate': props.indeterminate,
92
106
  },
93
107
  `cpCheckbox--is${capitalizedColor.value}`,
94
108
  ]
95
109
  })
96
110
 
97
- const labelComputedClasses = computed(() => {
98
- return { 'cpCheckbox__label--isCapitalized': props.capitalizeLabel }
99
- })
100
-
101
- const onChange = (value: string | number) => {
111
+ const onChange = () => {
102
112
  if (Array.isArray(props.modelValue)) {
103
113
  const currentValues = [...props.modelValue]
104
- const valueIndex = currentValues.indexOf(value)
114
+ const valueIndex = currentValues.indexOf(props.checkboxValue)
105
115
 
106
116
  if (valueIndex > -1) {
107
117
  currentValues.splice(valueIndex, 1)
108
118
  } else {
109
- currentValues.push(value)
119
+ currentValues.push(props.checkboxValue)
110
120
  }
111
121
 
112
122
  emit('update:modelValue', currentValues)
113
123
  } else {
114
- emit('update:modelValue', !props.modelValue)
124
+ // Always uncheck when previously indeterminate
125
+ if (props.indeterminate) {
126
+ emit('update:modelValue', false)
127
+ } else {
128
+ emit('update:modelValue', !props.modelValue)
129
+ }
115
130
  }
116
131
  }
117
132
  </script>
118
133
 
119
134
  <style lang="scss">
120
135
  @mixin cp-checkbox-style($color, $className) {
121
- &--is#{$className} input:checked {
136
+ &--is#{$className} input:checked,
137
+ &--is#{$className} input:indeterminate {
122
138
  background-color: $color;
123
139
  border-color: $color;
124
140
  }
@@ -127,7 +143,8 @@ const onChange = (value: string | number) => {
127
143
  background-color: color.scale($color, $lightness: 95%);
128
144
  }
129
145
 
130
- &--is#{$className}:hover input:checked {
146
+ &--is#{$className}:hover input:checked,
147
+ &--is#{$className}:hover input:indeterminate {
131
148
  background-color: color.adjust($color, $lightness: -10%);
132
149
  }
133
150
 
@@ -192,7 +209,8 @@ const onChange = (value: string | number) => {
192
209
  stroke-width: 3;
193
210
  }
194
211
 
195
- &:checked + i {
212
+ &:checked + i,
213
+ &:indeterminate + i {
196
214
  visibility: visible;
197
215
  opacity: 1;
198
216
  }
@@ -241,7 +259,7 @@ const onChange = (value: string | number) => {
241
259
  &__label {
242
260
  font-weight: 500;
243
261
 
244
- &--isCapitalized::first-letter {
262
+ &::first-letter {
245
263
  text-transform: capitalize;
246
264
  }
247
265
  }
@@ -1,4 +1,4 @@
1
- import { ref } from 'vue'
1
+ import { ref, computed } from 'vue'
2
2
 
3
3
  import type { Meta, StoryObj } from '@storybook/vue3'
4
4
 
@@ -28,10 +28,6 @@ const meta = {
28
28
  control: 'text',
29
29
  description: 'Name attribute for checkbox group',
30
30
  },
31
- capitalizeLabel: {
32
- control: 'boolean',
33
- description: 'Whether to capitalize the first letter of the label',
34
- },
35
31
  color: {
36
32
  control: 'select',
37
33
  options: ['blue', 'purple'],
@@ -49,6 +45,10 @@ const meta = {
49
45
  control: 'text',
50
46
  description: 'Helper text to display below the label',
51
47
  },
48
+ indeterminate: {
49
+ control: 'boolean',
50
+ description: 'Whether the checkbox is in an indeterminate state',
51
+ },
52
52
  },
53
53
  } satisfies Meta<typeof CpCheckbox>
54
54
 
@@ -57,14 +57,14 @@ type Story = StoryObj<typeof meta>
57
57
 
58
58
  export const Default: Story = {
59
59
  args: {
60
- checkboxLabel: 'Checkbox Label',
60
+ checkboxLabel: 'checkbox label',
61
61
  modelValue: false,
62
62
  isDisabled: false,
63
- capitalizeLabel: true,
64
63
  color: 'blue',
65
64
  reverseLabel: false,
66
65
  autofocus: false,
67
66
  helper: '',
67
+ indeterminate: false,
68
68
  },
69
69
  render: (args) => ({
70
70
  components: { CpCheckbox },
@@ -118,12 +118,71 @@ export const Reversed: Story = {
118
118
  },
119
119
  }
120
120
 
121
- export const WithoutCapitalization: Story = {
122
- args: {
123
- ...Default.args,
124
- capitalizeLabel: false,
125
- checkboxLabel: 'checkbox label',
126
- },
121
+ export const Indeterminate: Story = {
122
+ render: () => ({
123
+ components: { CpCheckbox },
124
+ setup() {
125
+ const childOptions = ref([
126
+ { id: 'email', label: 'Email notifications', checked: false },
127
+ { id: 'push', label: 'Push notifications', checked: true },
128
+ { id: 'sms', label: 'SMS notifications', checked: false },
129
+ ])
130
+
131
+ const parentState = computed(() => {
132
+ const checkedCount = childOptions.value.filter((option) => option.checked).length
133
+ const totalCount = childOptions.value.length
134
+
135
+ if (checkedCount === 0) return { checked: false, indeterminate: false }
136
+ if (checkedCount === totalCount) return { checked: true, indeterminate: false }
137
+ return { checked: false, indeterminate: true }
138
+ })
139
+
140
+ const handleParentChange = (value: boolean) => childOptions.value.forEach((option) => (option.checked = value))
141
+
142
+ const handleChildChange = (childId: string) => {
143
+ const child = childOptions.value.find((option) => option.id === childId)
144
+ if (child) {
145
+ child.checked = !child.checked
146
+ }
147
+ }
148
+
149
+ return {
150
+ childOptions,
151
+ parentState,
152
+ handleParentChange,
153
+ handleChildChange,
154
+ }
155
+ },
156
+ template: `
157
+ <div style="padding: 20px; display: flex; flex-direction: column; gap: 16px;">
158
+ <div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">
159
+ Parent-Child Checkbox Example
160
+ </div>
161
+ <CpCheckbox
162
+ :model-value="parentState.checked"
163
+ :indeterminate="parentState.indeterminate"
164
+ checkbox-label="All notifications"
165
+ @update:model-value="handleParentChange"
166
+ style="font-weight: 500; border-bottom: 1px solid #e0e0e0; padding-bottom: 12px;"
167
+ />
168
+ <div style="margin-left: 24px; display: flex; flex-direction: column; gap: 8px;">
169
+ <CpCheckbox
170
+ v-for="option in childOptions"
171
+ :key="option.id"
172
+ :model-value="option.checked"
173
+ :checkbox-label="option.label"
174
+ @update:model-value="() => handleChildChange(option.id)"
175
+ />
176
+ </div>
177
+ <div style="margin-top: 16px; padding: 12px; background: #f5f5f5; border-radius: 4px; font-size: 14px;">
178
+ <strong>Current state: </strong>
179
+ <span v-if="parentState.indeterminate" style="color: #ff9800;">Indeterminate (some selected)</span>
180
+ <span v-else-if="parentState.checked" style="color: #4caf50;">All selected</span>
181
+ <span v-else style="color: #757575;">None selected</span>
182
+ </div>
183
+ </div>
184
+ `,
185
+ }),
127
186
  }
128
187
 
129
188
  export const CheckboxGroup: Story = {