Skip to content

Commit 1271b60

Browse files
feat: [Checkbox] implements
1 parent 8ee8e26 commit 1271b60

File tree

25 files changed

+742
-13
lines changed

25 files changed

+742
-13
lines changed

packages/vue-primitives/src/accordion/stories/styles.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,15 @@
4646
&:focus {
4747
outline: none;
4848
box-shadow: inset 0 -5px 0 0 var(--shadow-color);
49-
color: red;
49+
color: crimson;
5050
}
5151

5252
&[data-disabled] {
5353
color: #ccc;
5454
}
5555

5656
&[data-state="open"] {
57-
background-color: red;
57+
background-color: crimson;
5858
color: white;
5959

6060
&:focus {
@@ -155,7 +155,7 @@
155155
padding: 10px;
156156

157157
&[data-state="closed"] {
158-
border-color: red;
158+
border-color: crimson;
159159
}
160160

161161
&[data-state="open"] {

packages/vue-primitives/src/aspect-ratio/stories/styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
display: flex;
33
align-items: center;
44
justify-content: center;
5-
background-color: red;
5+
background-color: crimson;
66
color: white;
77
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<script setup lang="ts">
2+
import { shallowRef, watch } from 'vue'
3+
import { useSize } from '../hooks/useSize.ts'
4+
import type { BubbleInputProps } from './BubbleInputProps.ts'
5+
import { isIndeterminate } from './utils.ts'
6+
7+
defineOptions({
8+
name: 'BubbleInput',
9+
})
10+
11+
const props = withDefaults(defineProps<BubbleInputProps>(), {
12+
checked: undefined,
13+
control: undefined,
14+
bubbles: true,
15+
})
16+
const elRef = shallowRef<HTMLInputElement>()
17+
18+
const controlSize = useSize(() => props.control)
19+
const checked = isIndeterminate(props.checked) ? false : props.checked
20+
21+
// Bubble checked change to parents (e.g form change event)
22+
watch(() => props.checked, (checked, prevChecked) => {
23+
const input = elRef.value
24+
if (!input)
25+
return
26+
27+
const inputProto = window.HTMLInputElement.prototype
28+
const descriptor = Object.getOwnPropertyDescriptor(inputProto, 'checked') as PropertyDescriptor
29+
const setChecked = descriptor.set
30+
31+
if (prevChecked !== checked && setChecked) {
32+
// TODO: Check if this is the correct way to create a change event
33+
const event = new Event('change', { bubbles: props.bubbles })
34+
input.indeterminate = isIndeterminate(checked)
35+
setChecked.call(input, isIndeterminate(checked) ? false : checked)
36+
input.dispatchEvent(event)
37+
}
38+
})
39+
</script>
40+
41+
<template>
42+
<input
43+
ref="elRef"
44+
type="checkbox"
45+
aria-hidden
46+
tabindex="-1"
47+
:checked="checked"
48+
:style="{
49+
width: `${controlSize?.width || 0}px`,
50+
height: `${controlSize?.width || 0}px`,
51+
position: 'absolute',
52+
pointerEvents: 'none',
53+
opacity: 0,
54+
margin: 0,
55+
}"
56+
>
57+
</template>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { CheckedState } from './Checkbox.ts'
2+
3+
export interface BubbleInputProps {
4+
checked: CheckedState
5+
control: HTMLElement | undefined
6+
bubbles: boolean
7+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Ref } from 'vue'
2+
import { createContext } from '../hooks/createContext.ts'
3+
import type { PrimitiveProps } from '../primitive/index.ts'
4+
5+
export interface CheckboxProps extends PrimitiveProps {
6+
checked?: CheckedState
7+
defaultChecked?: CheckedState
8+
disabled?: boolean
9+
required?: boolean
10+
value?: string
11+
name?: string
12+
}
13+
14+
// eslint-disable-next-line ts/consistent-type-definitions
15+
export type CheckboxEmits = {
16+
'update:checked': [value: CheckedState]
17+
}
18+
19+
export type CheckedState = boolean | 'indeterminate'
20+
21+
export interface CheckboxContext {
22+
state: Ref<CheckedState>
23+
disabled: Ref<boolean>
24+
}
25+
26+
export const [provideCheckboxContext, useCheckboxContext] = createContext<CheckboxContext>('Checkbox')
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<script setup lang="ts">
2+
import { computed, shallowRef, toRef, useAttrs, watchEffect } from 'vue'
3+
import { useControllableState } from '../hooks/useControllableState.ts'
4+
import { Primitive } from '../primitive/index.ts'
5+
import { composeEventHandlers } from '../utils/composeEventHandlers.ts'
6+
import { type CheckboxEmits, type CheckboxProps, provideCheckboxContext } from './Checkbox.ts'
7+
import { getState, isIndeterminate } from './utils.ts'
8+
import BubbleInput from './BubbleInput.vue'
9+
10+
defineOptions({
11+
name: 'Checkbox',
12+
inheritAttrs: false,
13+
})
14+
15+
const props = withDefaults(defineProps<CheckboxProps>(), {
16+
checked: undefined,
17+
value: 'on',
18+
as: 'button',
19+
})
20+
const emit = defineEmits<CheckboxEmits>()
21+
const attrs = useAttrs()
22+
const elRef = shallowRef<HTMLButtonElement>()
23+
24+
const hasConsumerStoppedPropagation = shallowRef(false)
25+
// We set this to true by default so that events bubble to forms without JS (SSR)
26+
const isFormControl = computed(() => elRef.value ? Boolean(elRef.value.closest('form')) : true)
27+
const checked = useControllableState(props, emit, 'checked', props.defaultChecked)
28+
29+
const initialCheckedStateRef = checked.value
30+
31+
watchEffect((onCleanup) => {
32+
const form = elRef.value?.form
33+
if (form) {
34+
const reset = () => {
35+
checked.value = initialCheckedStateRef
36+
}
37+
38+
form.addEventListener('reset', reset)
39+
40+
onCleanup(() => form.removeEventListener('reset', reset))
41+
}
42+
})
43+
44+
const onKeydown = composeEventHandlers<KeyboardEvent>((event) => {
45+
(attrs.onKeydown as Function | undefined)?.(event)
46+
}, (event) => {
47+
// According to WAI ARIA, Checkboxes don't activate on enter keypress
48+
if (event.key === 'Enter')
49+
event.preventDefault()
50+
})
51+
52+
type CliclEvent = Event & { _stopPropagation: Event['stopPropagation'], _isPropagationStopped: boolean, isPropagationStopped: () => boolean }
53+
const onClick = composeEventHandlers<CliclEvent>((event) => {
54+
event._stopPropagation = event.stopPropagation
55+
event._isPropagationStopped = false
56+
event.stopPropagation = function stopPropagation() {
57+
this._isPropagationStopped = true
58+
event._stopPropagation()
59+
}
60+
event.isPropagationStopped = function isPropagationStopped() {
61+
return this._isPropagationStopped
62+
}
63+
64+
;(attrs.onClick as Function | undefined)?.(event)
65+
}, (event) => {
66+
checked.value = isIndeterminate(checked.value) ? true : !checked.value
67+
if (isFormControl.value) {
68+
console.error('event.isPropagationStopped()', event.isPropagationStopped())
69+
hasConsumerStoppedPropagation.value = event.isPropagationStopped()
70+
// if checkbox is in a form, stop propagation from the button so that we only propagate
71+
// one click event (from the input). We propagate changes from an input so that native
72+
// form validation works and form events reflect checkbox updates.
73+
if (!hasConsumerStoppedPropagation.value)
74+
event.stopPropagation()
75+
}
76+
})
77+
78+
provideCheckboxContext({
79+
disabled: toRef(props, 'disabled'),
80+
state: checked,
81+
})
82+
83+
defineExpose({
84+
$el: elRef,
85+
})
86+
</script>
87+
88+
<template>
89+
<Primitive
90+
:ref="(el: any) => elRef = el?.$el"
91+
:as="as"
92+
:as-child="asChild"
93+
type="button"
94+
role="checkbox"
95+
:aria-checked="isIndeterminate(checked) ? 'mixed' : checked"
96+
:aria-required="attrs.required"
97+
:data-state="getState(checked)"
98+
:data-disabled="disabled ? '' : undefined"
99+
:disabled="disabled"
100+
:value="value"
101+
v-bind="{
102+
...attrs,
103+
onKeydown,
104+
onClick,
105+
}"
106+
>
107+
<slot />
108+
</Primitive>
109+
110+
<BubbleInput
111+
v-if="isFormControl"
112+
:control="elRef"
113+
:bubbles="!hasConsumerStoppedPropagation"
114+
:name="name"
115+
:value="value"
116+
:checked="checked"
117+
:required="required"
118+
:disabled="disabled"
119+
:style="{
120+
transform: 'translateX(-100%)',
121+
}"
122+
/>
123+
</template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { PrimitiveProps } from '../primitive/index.ts'
2+
3+
export interface CheckboxIndicatorProps extends PrimitiveProps {
4+
/**
5+
* Used to force mounting when more control is needed. Useful when
6+
* controlling animation with React animation libraries.
7+
*/
8+
forceMount?: true
9+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { usePresence } from '../presence/index.ts'
4+
import Primitive from '../primitive/Primitive.vue'
5+
import { useCheckboxContext } from './Checkbox.ts'
6+
import type { CheckboxIndicatorProps } from './CheckboxIndicator.ts'
7+
import { getState, isIndeterminate } from './utils.ts'
8+
9+
defineOptions({
10+
name: 'CheckboxIndicator',
11+
})
12+
13+
const props = withDefaults(defineProps<CheckboxIndicatorProps>(), {
14+
as: 'span',
15+
})
16+
const elRef = shallowRef<HTMLElement>()
17+
18+
const context = useCheckboxContext()
19+
20+
const isPresent = usePresence(elRef, () => props.forceMount || isIndeterminate(context.state.value) || context.state.value === true)
21+
</script>
22+
23+
<template>
24+
<Primitive
25+
v-if="isPresent"
26+
:ref="(el: any) => elRef = el?.$el"
27+
:as="as"
28+
:as-child="asChild"
29+
:data-state="getState(context.state.value)"
30+
:data-disabled="context.disabled.value ? '' : undefined"
31+
:style="{ pointerEvents: 'none' }"
32+
>
33+
<slot />
34+
</Primitive>
35+
</template>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as Checkbox } from './Checkbox.vue'
2+
export { default as CheckboxIndicator } from './CheckboxIndicator.vue'
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { Checkbox, CheckboxIndicator } from '../index.ts'
4+
import './styles.css'
5+
6+
const checked = shallowRef<boolean | 'indeterminate'>('indeterminate')
7+
</script>
8+
9+
<template>
10+
<div>
11+
<p>
12+
<Checkbox v-model:checked="checked" class="checkbox_rootClass">
13+
<CheckboxIndicator class="checkbox_indicatorClass checkbox_animatedIndicatorClass" />
14+
</Checkbox>
15+
</p>
16+
17+
<button
18+
type="button"
19+
@click="() => {
20+
checked = checked === 'indeterminate' ? false : 'indeterminate'
21+
}"
22+
>
23+
Toggle indeterminate
24+
</button>
25+
</div>
26+
</template>

0 commit comments

Comments
 (0)