Skip to content

Commit 73288c0

Browse files
feat: [Switch] implements
1 parent 86ec77b commit 73288c0

File tree

16 files changed

+508
-4
lines changed

16 files changed

+508
-4
lines changed

packages/vue-primitives/src/checkbox/BubbleInput.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ watch(() => props.checked, (checked, prevChecked) => {
4545
type="checkbox"
4646
aria-hidden
4747
tabindex="-1"
48-
:checked="isIndeterminate(props.checked) ? false : props.checked"
48+
:checked="isIndeterminate(checked) ? false : checked"
4949
:style="{
5050
width: `${controlSize?.width || 0}px`,
5151
height: `${controlSize?.width || 0}px`,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export interface BubbleInputProps {
2+
checked: boolean
3+
control: HTMLElement | undefined
4+
bubbles: boolean
5+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<script setup lang="ts">
2+
import { shallowRef, watch } from 'vue'
3+
import { useSize } from '../hooks/useSize.ts'
4+
import type { BubbleInputProps } from './BubbleInput.ts'
5+
6+
defineOptions({
7+
name: 'BubbleInput',
8+
})
9+
10+
const props = withDefaults(defineProps<BubbleInputProps>(), {
11+
checked: undefined,
12+
control: undefined,
13+
bubbles: true,
14+
})
15+
const elRef = shallowRef<HTMLInputElement>()
16+
17+
const controlSize = useSize(() => props.control)
18+
// TODO: Check if this is the correct way to create a change event
19+
// const initChecked = 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+
setChecked.call(input, checked)
35+
input.dispatchEvent(event)
36+
}
37+
})
38+
</script>
39+
40+
<template>
41+
<input
42+
ref="elRef"
43+
type="checkbox"
44+
aria-hidden
45+
tabindex="-1"
46+
:checked="checked"
47+
:style="{
48+
width: `${controlSize?.width || 0}px`,
49+
height: `${controlSize?.width || 0}px`,
50+
position: 'absolute',
51+
pointerEvents: 'none',
52+
opacity: 0,
53+
margin: 0,
54+
}"
55+
>
56+
</template>
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 SwitchProps extends PrimitiveProps {
6+
checked?: boolean
7+
defaultChecked?: boolean
8+
required?: boolean
9+
10+
disabled?: boolean
11+
value?: string
12+
name?: string
13+
}
14+
15+
// eslint-disable-next-line ts/consistent-type-definitions
16+
export type SwitchEmits = {
17+
'update:checked': [checked: boolean]
18+
}
19+
20+
export interface SwitchContext {
21+
checked: Ref<boolean>
22+
disabled: () => boolean
23+
}
24+
25+
export const [provideSwitchContext, useSwitchContext] = createContext<SwitchContext>('Switch')
26+
27+
export function getState(checked: boolean) {
28+
return checked ? 'checked' : 'unchecked'
29+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<script setup lang="ts">
2+
import { computed, shallowRef, useAttrs } 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 { isFunction } from '../utils/is.ts'
7+
import { type SwitchEmits, type SwitchProps, getState, provideSwitchContext } from './Switch.ts'
8+
import BubbleInput from './BubbleInput.vue'
9+
10+
defineOptions({
11+
name: 'OkuSwitch',
12+
inheritAttrs: false,
13+
})
14+
15+
const props = withDefaults(defineProps<SwitchProps>(), {
16+
as: 'button',
17+
checked: undefined,
18+
defaultChecked: false,
19+
value: 'on',
20+
})
21+
const emit = defineEmits<SwitchEmits>()
22+
const attrs = useAttrs()
23+
24+
const buttonEl = shallowRef<HTMLButtonElement>()
25+
let hasConsumerStoppedPropagation: boolean
26+
27+
// We set this to true by default so that events bubble to forms without JS (SSR)
28+
const isFormControl = computed(() => buttonEl.value ? Boolean(buttonEl.value.closest('form')) : true)
29+
30+
const checked = useControllableState(props, v => emit('update:checked', v), 'checked', props.defaultChecked)
31+
32+
provideSwitchContext({
33+
checked,
34+
disabled() {
35+
return props.disabled
36+
},
37+
})
38+
39+
type CliclEvent = Event & { _stopPropagation: Event['stopPropagation'], _isPropagationStopped: boolean, isPropagationStopped: () => boolean }
40+
const onClick = composeEventHandlers<CliclEvent>((event) => {
41+
event._stopPropagation = event.stopPropagation
42+
event._isPropagationStopped = false
43+
event.stopPropagation = function stopPropagation() {
44+
this._isPropagationStopped = true
45+
event._stopPropagation()
46+
}
47+
event.isPropagationStopped = function isPropagationStopped() {
48+
return this._isPropagationStopped
49+
}
50+
isFunction(attrs.onClick) && attrs.onClick(event)
51+
}, (event) => {
52+
checked.value = !checked.value
53+
54+
if (isFormControl.value) {
55+
hasConsumerStoppedPropagation = event.isPropagationStopped()
56+
// if switch is in a form, stop propagation from the button so that we only propagate
57+
// one click event (from the input). We propagate changes from an input so that native
58+
// form validation works and form events reflect switch updates.
59+
if (!hasConsumerStoppedPropagation)
60+
event.stopPropagation()
61+
}
62+
})
63+
64+
defineExpose({
65+
$el: buttonEl,
66+
})
67+
</script>
68+
69+
<template>
70+
<Primitive
71+
:ref="(el: any) => buttonEl = el?.$el"
72+
:as="as"
73+
:as-child="asChild"
74+
type="button"
75+
role="switch"
76+
:aria-checked="checked"
77+
:aria-required="required"
78+
:data-state="getState(checked)"
79+
:data-disabled="disabled ? '' : undefined"
80+
:disabled="disabled"
81+
:value="value"
82+
v-bind="{
83+
...attrs,
84+
onClick,
85+
}"
86+
>
87+
<slot />
88+
</Primitive>
89+
<BubbleInput
90+
v-if="isFormControl"
91+
:control="buttonEl"
92+
:bubbles="!hasConsumerStoppedPropagation"
93+
:name="name"
94+
:value="value"
95+
:checked="checked"
96+
:required="required"
97+
:disabled="disabled"
98+
:style="{
99+
// We transform because the input is absolutely positioned but we have
100+
// rendered it **after** the button. This pulls it back to sit on top
101+
// of the button.
102+
transform: 'translateX(-100%)',
103+
}"
104+
/>
105+
</template>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type { PrimitiveProps } from '../primitive/index.ts'
2+
3+
export interface SwitchThumbProps extends PrimitiveProps {}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<script setup lang="ts">
2+
import { Primitive } from '../primitive/index.ts'
3+
import { getState, useSwitchContext } from './Switch.ts'
4+
import type { SwitchThumbProps } from './SwitchThumb.ts'
5+
6+
defineOptions({
7+
name: 'OkuSwitchThumb',
8+
})
9+
10+
withDefaults(defineProps<SwitchThumbProps>(), {
11+
as: 'span',
12+
})
13+
14+
const context = useSwitchContext('OkuSwitchThumb')
15+
</script>
16+
17+
<template>
18+
<Primitive
19+
:as="as"
20+
:as-child="asChild"
21+
:data-state="getState(context.checked.value)"
22+
>
23+
<slot />
24+
</Primitive>
25+
</template>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as Switch } from './Switch.vue'
2+
export { default as SwitchThumb } from './SwitchThumb.vue'
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { Label } from '../../label/index.ts'
4+
import { Switch, SwitchThumb } from '../index.ts'
5+
import './styles.css'
6+
</script>
7+
8+
<template>
9+
<div>
10+
<h1>Uncontrolled</h1>
11+
<h2>Off</h2>
12+
<Switch class="switch_rootClass">
13+
<SwitchThumb class="switch_thumbClass" />
14+
</Switch>
15+
16+
<h2>On</h2>
17+
<Switch class="switch_rootClass" default-checked>
18+
<SwitchThumb class="switch_thumbClass" />
19+
</Switch>
20+
21+
<h1>Controlled</h1>
22+
<h2>Off</h2>
23+
<Switch class="switch_rootClass" :checked="false">
24+
<SwitchThumb class="switch_thumbClass" />
25+
</Switch>
26+
27+
<h2>On</h2>
28+
<Switch class="switch_rootClass" checked>
29+
<SwitchThumb class="switch_thumbClass" />
30+
</Switch>
31+
32+
<h1>Disabled</h1>
33+
<Switch class="switch_rootClass" disabled>
34+
<SwitchThumb class="switch_thumbClass" />
35+
</Switch>
36+
37+
<h1>State attributes</h1>
38+
<h2>Unchecked</h2>
39+
<Switch class="switch_rootAttrClass">
40+
<SwitchThumb class="switch_thumbAttrClass" />
41+
</Switch>
42+
43+
<h2>Checked</h2>
44+
<Switch class="switch_rootAttrClass" default-checked>
45+
<SwitchThumb class="switch_thumbAttrClass" />
46+
</Switch>
47+
48+
<h2>Disabled</h2>
49+
<Switch class="switch_rootAttrClass" default-checked disabled>
50+
<SwitchThumb class="switch_thumbAttrClass" />
51+
</Switch>
52+
</div>
53+
</template>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script setup lang="ts">
2+
import { shallowRef } from 'vue'
3+
import { Label } from '../../label/index.ts'
4+
import { Switch, SwitchThumb } from '../index.ts'
5+
import './styles.css'
6+
7+
const checked = shallowRef(true)
8+
</script>
9+
10+
<template>
11+
<div>
12+
<p>This This switch is placed adjacent to its label. The state is controlled.</p>
13+
<Label for="randBox">
14+
This is the label
15+
</Label>
16+
{{ ' ' }}
17+
<Switch id="randBox" v-model:checked="checked" class="switch_rootClass">
18+
<SwitchThumb class="switch_thumbClass" />
19+
</Switch>
20+
</div>
21+
</template>

0 commit comments

Comments
 (0)