Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
jasonlbeggs committed Oct 19, 2022
2 parents 02425ea + b26ef1e commit 0965926
Show file tree
Hide file tree
Showing 7 changed files with 766 additions and 9 deletions.
34 changes: 28 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,36 @@
<script src="./packages/mask/dist/cdn.js"></script>
<script src="./packages/ui/dist/cdn.js" defer></script>
<script src="./packages/alpinejs/dist/cdn.js" defer></script>
<script src="//cdn.tailwindcss.com"></script>
<!-- <script src="https://unpkg.com/[email protected]/dist/cdn.min.js" defer></script> -->

<!-- Play around. -->
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<main x-data="{ active: null, access: [
{
id: 'access-1',
name: 'Public access',
description: 'This project would be available to anyone who has the link',
disabled: false,
},
]}">
<div x-radio group x-model="active">
<fieldset>
<legend>
<h2 x-radio:label>Privacy setting</h2>
<p x-radio:description>Some description</p>
</legend>

<span x-show="open">
Content...
</span>
</div>
<div>
<template x-for="(item, i) in access" :key="item.id">
<div :option="item.id" x-radio:option :value="item" :disabled="item.disabled">
<span :label="item.id" x-radio:label x-text="item.name"></span>
<span :description="item.id" x-radio:description x-text="item.description"></span>
</div>
</template>
</div>
</fieldset>
</div>

<span x-text="JSON.stringify(active)"></span>
</main>
</html>
2 changes: 1 addition & 1 deletion packages/alpinejs/src/directives.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,8 @@ let directiveOrder = [
// @todo: provide better directive ordering mechanisms so
// that I don't have to manually add things like "tabs"
// to the order list...
'tabs',
'radio',
'tabs',
'switch',
'disclosure',
'menu',
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@alpinejs/ui",
"version": "3.10.4-beta.4",
"version": "3.10.4-beta.5",
"description": "Headless UI components for Alpine",
"author": "Caleb Porzio",
"license": "MIT",
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import disclosure from './disclosure'
import menu from './menu'
import notSwitch from './switch'
import popover from './popover'
import radio from './radio'
import tabs from './tabs'

export default function (Alpine) {
Expand All @@ -11,5 +12,6 @@ export default function (Alpine) {
menu(Alpine)
notSwitch(Alpine)
popover(Alpine)
radio(Alpine)
tabs(Alpine)
}
220 changes: 220 additions & 0 deletions packages/ui/src/radio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@

export default function (Alpine) {
Alpine.directive('radio', (el, directive) => {
if (! directive.value) handleRoot(el, Alpine)
else if (directive.value === 'option') handleOption(el, Alpine)
else if (directive.value === 'label') handleLabel(el, Alpine)
else if (directive.value === 'description') handleDescription(el, Alpine)
})

Alpine.magic('radioOption', el => {
let $data = Alpine.$data(el)

return {
get isActive() {
return $data.__option === $data.__active
},
get isChecked() {
return $data.__option === $data.__value
},
get isDisabled() {
let disabled = $data.__disabled

if ($data.__rootDisabled) return true

return disabled
},
}
})
}

function handleRoot(el, Alpine) {
Alpine.bind(el, {
'x-modelable': '__value',
'x-data'() {
return {
init() {
queueMicrotask(() => {
this.__rootDisabled = Alpine.bound(el, 'disabled', false);
this.__value = Alpine.bound(this.$el, 'default-value', false)
this.__inputName = Alpine.bound(this.$el, 'name', false)
this.__inputId = 'alpine-radio-'+Date.now()
})

// Add `role="none"` to all non role elements.
this.$nextTick(() => {
let walker = document.createTreeWalker(
this.$el,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: node => {
if (node.getAttribute('role') === 'radio') return NodeFilter.FILTER_REJECT
if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
return NodeFilter.FILTER_ACCEPT
}
},
false
)

while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
})
},
__value: undefined,
__active: undefined,
__rootEl: this.$el,
__optionValues: [],
__disabledOptions: new Set,
__optionElsByValue: new Map,
__hasLabel: false,
__hasDescription: false,
__rootDisabled: false,
__inputName: undefined,
__inputId: undefined,
__change(value) {
if (this.__rootDisabled) return

this.__value = value
},
__addOption(option, el, disabled) {
// Add current element to element list for navigating.
let options = Alpine.raw(this.__optionValues)
let els = options.map(i => this.__optionElsByValue.get(i))
let inserted = false

for (let i = 0; i < els.length; i++) {
if (els[i].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
options.splice(i, 0, option)
this.__optionElsByValue.set(option, el)
inserted = true
break
}
}

if (!inserted) {
options.push(option)
this.__optionElsByValue.set(option, el)
}

disabled && this.__disabledOptions.add(option)
},
__isFirstOption(option) {
return this.__optionValues.indexOf(option) === 0
},
__setActive(option) {
this.__active = option
},
__focusOptionNext() {
let option = this.__active
let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
let next = all[this.__optionValues.indexOf(option) + 1]
next = next || all[0]

this.__optionElsByValue.get(next).focus()
this.__change(next)
},
__focusOptionPrev() {
let option = this.__active
let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
let prev = all[all.indexOf(option) - 1]
prev = prev || all.slice(-1)[0]

this.__optionElsByValue.get(prev).focus()
this.__change(prev)
},
}
},
'x-effect'() {
let value = this.__value

// Only render a hidden input if the "name" prop is passed...
if (! this.__inputName) return

// First remove a previously appended hidden input (if it exists)...
let nextEl = this.$el.nextElementSibling
if (nextEl && String(nextEl.id) === String(this.__inputId)) {
nextEl.remove()
}

// If the value is true, create the input and append it, otherwise,
// we already removed it in the previous step...
if (value) {
let input = document.createElement('input')

input.type = 'hidden'
input.value = value
input.name = this.__inputName
input.id = this.__inputId

this.$el.after(input)
}
},
'role': 'radiogroup',
'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
'@keydown.up.prevent.stop'() { this.__focusOptionPrev() },
'@keydown.left.prevent.stop'() { this.__focusOptionPrev() },
'@keydown.down.prevent.stop'() { this.__focusOptionNext() },
'@keydown.right.prevent.stop'() { this.__focusOptionNext() },
})
}

function handleOption(el, Alpine) {
Alpine.bind(el, {
'x-data'() {
return {
init() {
queueMicrotask(() => {
this.__disabled = Alpine.bound(el, 'disabled', false)
this.__option = Alpine.bound(el, 'value')
this.$data.__addOption(this.__option, this.$el, this.__disabled)
})
},
__option: undefined,
__disabled: false,
__hasLabel: false,
__hasDescription: false,
}
},
'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
'role': 'radio',
':aria-checked'() { return this.$radioOption.isChecked },
':aria-disabled'() { return this.$radioOption.isDisabled },
':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
':tabindex'() {
if (this.$radioOption.isDisabled) return -1
if (this.$radioOption.isChecked) return 0
if (! this.$data.__value && this.$data.__isFirstOption(this.$data.__option)) return 0

return -1
},
'@click'() {
if (this.$radioOption.isDisabled) return
this.$data.__change(this.$data.__option)
this.$el.focus()
},
'@focus'() {
if (this.$radioOption.isDisabled) return
this.$data.__setActive(this.$data.__option)
},
'@blur'() {
if (this.$data.__active === this.$data.__option) this.$data.__setActive(undefined)
},
'@keydown.space.stop.prevent'() { this.$data.__change(this.$data.__option) },
})
}

function handleLabel(el, Alpine) {
Alpine.bind(el, {
'x-init'() { this.$data.__hasLabel = true },
':id'() { return this.$id('alpine-radio-label') },
})
}

function handleDescription(el, Alpine) {
Alpine.bind(el, {
'x-init'() { this.$data.__hasDescription = true },
':id'() { return this.$id('alpine-radio-description') },
})
}
2 changes: 1 addition & 1 deletion packages/ui/src/switch.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function handleRoot(el, Alpine) {
this.__value = Alpine.bound(this.$el, 'default-checked', false)
this.__inputName = Alpine.bound(this.$el, 'name', false)
this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
this.__inputId = Date.now()
this.__inputId = 'alpine-switch-'+Date.now()
})
},
__value: undefined,
Expand Down
Loading

0 comments on commit 0965926

Please sign in to comment.