Skip to main content
Logo USWDS + Tailwind

JavaScript

USWDS + Tailwind relies on Alpine.js plugins to power components' functionality. You can install each them independently, as you need them.

Install and import

You can initialize your Alpine plugins like so:

src/assets/js/main.js
import Alpine from '@alpinejs/csp'
import customPlugin from 'path/to/plugin.js'
Alpine.plugin(customPlugin)

Alpine Plugins

If you haven’t done so already, install the default Alpine plugins with the following:

Terminal window
npm i @alpinejs/anchor @alpinejs/focus @alpinejs/mask

Custom Plugins

Install the custom plugins by copying and pasting the following code. Not all of the plugins are required and you can install or omit them as necessary for your project.

Accordion

src/assets/js/accordion.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
export default function (Alpine: Alpine) {
Alpine.directive('accordion', (el, directive) => {
if (directive.value === 'item') accordionItem(el, Alpine)
else if (directive.value === 'trigger') accordionTrigger(el, Alpine)
else if (directive.value === 'content') accordionContent(el, Alpine)
else accordionRoot(el, Alpine)
})
Alpine.magic('accordion', el => {
let $data = Alpine.$data(el)
return {
get value() {
return $data.value
},
add(id: string) {
$data.add(id)
},
remove(id: string) {
$data.remove(id)
}
}
})
}
const accordionRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-data'() {
return {
rootEl: el,
multiple: el.hasAttribute('data-multiple'),
value: [],
add(id: string) {
return this.value = this.multiple ? [...this.value, id] : [id]
},
remove(id: string) {
return this.value = this.value.filter((v: string) => v !== id)
},
}
}
})
}
const accordionItem = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.value === undefined) console.warn('"x-accordion:item" is missing a parent element with "x-accordion".')
},
'x-id'() {
return ['accordion']
},
})
}
const accordionTrigger = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.value === undefined) console.warn('"x-accordion:trigger" is missing a parent element with "x-accordion".')
},
'@click'() {
return this.value.includes(this.$id('accordion'))
? this.remove(this.$id('accordion'))
: this.add(this.$id('accordion'))
},
':aria-controls'() {
return this.$id('accordion')
},
':aria-expanded'() {
return this.value.includes(this.$id('accordion'))
},
'@keydown.prevent.down'() {
return this.$focus.within(this.rootEl).wrap().next()
},
'@keydown.prevent.up'() {
return this.$focus.within(this.rootEl).wrap().previous()
},
'@keydown.prevent.home'() {
return this.$focus.within(this.rootEl).first()
},
'@keydown.prevent.end'() {
return this.$focus.within(this.rootEl).last()
},
})
}
const accordionContent = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.value === undefined) console.warn('"x-accordion:content" is missing a parent element with "x-accordion".')
},
':id'() {
return this.$id('accordion')
},
':hidden'() {
return this.value.includes(this.$id('accordion')) ? false : 'until-found'
}
})
}

Character Count

src/assets/js/character-count.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
export default function (Alpine: Alpine) {
Alpine.directive('character-count', (el, directive) => {
if (directive.value === 'input') characterCountInput(el, Alpine)
else if (directive.value === 'status') characterCountStatus(el, Alpine)
else if (directive.value === 'sr-status') srCharacterCountStatus(el, Alpine)
else characterCountRoot(el, Alpine)
})
}
const characterCountRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-id'() {
return ['character-count-input', 'character-count-status']
},
'x-data'() {
return {
isInitialized: false,
maxLength: undefined,
charCount: 0,
charsRemaining: 0,
srStatusText: undefined,
setSrStatusText: undefined,
debouncedSetSrStatusText() {
if (!this.setSrStatusText) {
this.setSrStatusText = Alpine.debounce(() => {
this.srStatusText = this.statusText
}, 1000)
}
this.setSrStatusText()
},
get statusText() {
if (this.maxLength) {
const difference = Math.abs(this.maxLength - this.charCount);
const characters = difference === 1 ? "character" : "characters";
const guidance = this.charCount === 0
? "allowed"
: this.charCount > this.maxLength
? "over limit"
: "left";
return `${difference} ${characters} ${guidance}`;
} else {
return undefined
}
},
get isInvalid() {
return this.charsRemaining < 0 ? true : false
},
}
},
'x-init'() {
this.$watch('isInvalid', value => {
return value ? el.setAttribute('data-invalid', 'true') : el.removeAttribute('data-invalid')
})
},
})
}
const characterCountInput = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-character-count:input" is missing a parent element with "x-character-count".')
if (el.maxLength <= 0) {
console.error(`Invalid or no "maxlength" attribute set on element #${el.id}`)
} else {
this.maxLength = Number(el.maxLength)
el.removeAttribute('maxlength')
}
this.$watch('isInvalid', value => {
if (value) {
el.setAttribute('data-invalid', 'true')
el.setAttribute('aria-invalid', 'true')
} else {
el.removeAttribute('data-invalid')
el.removeAttribute('aria-invalid')
}
})
},
'@input'() {
this.charCount = el.value.length
this.charsRemaining = this.maxLength - this.charCount
this.debouncedSetSrStatusText()
},
})
}
const characterCountStatus = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-character-count:status" is missing a parent element with "x-character-count".')
this.$watch('isInvalid', value => {
return value ? el.setAttribute('data-invalid', 'true') : el.removeAttribute('data-invalid')
})
},
'x-text'() {
return this.statusText
}
})
}
const srCharacterCountStatus = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-character-count:status" is missing a parent element with "x-character-count".')
this.srStatusText = this.statusText
this.$watch('isInvalid', value => {
return value ? el.setAttribute('data-invalid', 'true') : el.removeAttribute('data-invalid')
})
},
'x-text'() {
return this.srStatusText
}
})
}

Collapse

src/assets/js/collapse.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
export default function (Alpine: Alpine) {
Alpine.directive('collapse', (el, directive) => {
if (directive.value === 'trigger') collapseTrigger(el, Alpine)
else if (directive.value === 'content') collapseContent(el, Alpine)
else collapseRoot(el, Alpine)
})
Alpine.magic('collapse', el => {
let $data = Alpine.$data(el)
return {
get isOpen() {
return $data.isOpen
},
open() {
return $data.open()
},
close() {
return $data.close()
}
}
})
}
const collapseRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-id'() {
return ['collapse']
},
'x-data'() {
return {
isOpen: false,
open() {
this.isOpen = true
},
close() {
this.isOpen = false
},
toggle() {
this.isOpen = !this.isOpen
}
}
},
':data-open'() {
return this.isOpen || undefined
}
})
}
const collapseTrigger = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':aria-expanded'() {
return this.isOpen
},
':aria-controls'() {
return this.$id('collapse')
},
'@click'() {
this.toggle()
},
})
}
const collapseContent = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':id'() {
return this.$id('collapse')
},
'x-show'() {
return this.isOpen
},
})
}

Combobox

src/assets/js/combobox.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs";
export default function (Alpine: Alpine) {
Alpine.directive('combobox', (el, directive) => {
if (directive.value === 'input') comboboxInput(el, Alpine)
else if (directive.value === 'label') comboboxLabel(el, Alpine)
else if (directive.value === 'list') comboboxList(el, Alpine)
else if (directive.value === 'item') comboboxListItem(el, Alpine)
else if (directive.value === 'values') comboboxValues(el, Alpine)
else if (directive.value === 'clear') comboboxClearButton(el, Alpine)
else if (directive.value === 'toggle') comboboxToggleButton(el, Alpine)
else comboboxRoot(el, Alpine)
})
Alpine.magic('combobox', el => {
const $data = Alpine.$data(el)
return {
get allOptions() {
return $data.allOptions
},
get isLoaded() {
return $data.isLoaded
},
get noResults() {
return $data.noResults
}
}
})
}
const comboboxRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-id'() {
return ['combobox-label', 'combobox-list', 'combobox-input']
},
'x-init'() {
this.isLoaded = true;
this.$watch('isOpen', value => {
if (value && this.selectedEl) {
this.$nextTick(() => {
this.selectedEl.scrollIntoView();
})
}
if (!value) this.activeEl = undefined
})
},
'x-data'() {
return {
listEl: undefined as HTMLElement | undefined,
inputEl: undefined as HTMLElement | undefined,
labelEl: undefined as HTMLElement | undefined,
selectedEl: undefined as HTMLElement | undefined,
activeEl: undefined as HTMLElement | undefined,
inputValue: '',
isLoaded: false,
isOpen: false,
isDirty: false,
allOptions: [] as Array<{ label: string, value: string }>,
get selectedValue() {
return this.selectedEl ? this.selectedEl.textContent : ''
},
get noResults() {
return this.isDirty && !this.allOptions.some(o => o.label.toLowerCase().startsWith(this.inputValue.toLowerCase()))
},
select(el: HTMLElement) {
this.selectedEl = el
this.inputEl.value = el.textContent;
this.isOpen = false;
this.isDirty = false;
this.$focus.focus(this.inputEl);
},
reset() {
this.inputEl.value = '';
this.inputValue = '';
this.isDirty = false;
this.selectedEl = undefined;
},
focusFirst() {
const el = this.$focus.within(this.listEl).getFirst()
if (el) {
this.activeEl = el;
this.$focus.focus(el)
}
},
focusLast() {
const el = this.$focus.within(this.listEl).getLast()
if (el) {
this.activeEl = el;
this.$focus.focus(el)
}
},
focusNext() {
const el = this.$focus.within(this.listEl).getNext()
if (el) {
this.activeEl = el;
this.$focus.focus(el)
} else {
this.isOpen = false;
this.$focus.focus(this.inputEl)
}
},
focusPrev() {
const el = this.$focus.within(this.listEl).getPrevious()
if (el) {
this.activeEl = el;
this.$focus.focus(el)
} else {
this.isOpen = false;
this.$focus.focus(this.inputEl)
}
},
focusSelected() {
this.activeEl = this.selectedEl;
this.$focus.focus(this.selectedEl)
},
validateInput() {
this.isOpen = false
// If typed value matches an option value, set it as selectedEl
if (this.isDirty) {
const found = [...this.listEl.getElementsByTagName('LI')]
.find((li) => li.textContent === this.inputValue)
this.selectedEl = found ? found : this.selectedEl
}
this.isDirty = false;
this.inputEl.value = this.selectedValue
}
}
},
'@focusout'() {
if (!el.contains(this.$event.relatedTarget)) {
this.validateInput()
}
},
})
}
const comboboxValues = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
el.id = '';
el.hidden = true;
this.allOptions = [...el.children].map((o) => ({ label: o.textContent, value: o.value }))
}
})
}
const comboboxInput = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':type'() {
return 'text'
},
':role'() {
return 'combobox'
},
':autocapitalize'() {
return 'off'
},
':autocomplete'() {
return 'off'
},
':id'() {
return this.$id('combobox-input')
},
':aria-expanded'() {
return this.isOpen
},
':aria-activedescendant'() {
return this.activeEl ? this.activeEl.id : undefined
},
':aria-controls'() {
return this.$id('combobox-list')
},
':aria-owns'() {
return this.$id('combobox-list')
},
'x-init'() {
this.inputEl = el;
},
'@mousedown'() {
this.isOpen = true;
this.activeEl = undefined
},
'@input'() {
this.inputValue = el.value;
this.isOpen = true;
this.isDirty = true;
if (!el.value) {
this.reset()
}
},
'@keydown.prevent.up'() {
this.isOpen = true;
this.$nextTick(() => {
this.selectedEl ? this.focusSelected() : this.focusLast()
})
},
'@keydown.prevent.down'() {
this.isOpen = true;
this.$nextTick(() => {
this.selectedEl ? this.focusSelected() : this.focusFirst()
})
},
})
}
const comboboxList = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':role'() {
return 'listbox'
},
':id'() {
return this.$id('combobox-list')
},
':tabindex'() {
return '-1'
},
':aria-labelledby'() {
return this.$id('combobox-label')
},
'x-init'() {
return this.listEl = el
},
'x-show'() {
return this.isOpen
},
'x-anchor.bottom'() {
return this.inputEl;
},
'@keydown.prevent.up'() {
this.focusPrev()
},
'@keydown.prevent.down'() {
this.focusNext()
},
'@keydown.prevent.home'() {
this.focusFirst()
},
'@keydown.prevent.end'() {
this.focusLast()
},
'@keydown.prevent.shift.tab'() {
return false
},
})
}
const comboboxClearButton = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':type'() {
return 'button'
},
':aria-label'() {
return 'clear input'
},
':tabindex'() {
return '-1'
},
'x-show'() {
return !!this.selectedValue
},
'@mouseup.prevent'() {
this.reset();
this.$focus.focus(this.inputEl);
}
})
}
const comboboxToggleButton = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':type'() {
return 'button'
},
':aria-label'() {
return 'toggle options'
},
':tabindex'() {
return '-1'
},
'@mouseup.prevent'() {
this.isOpen = !this.isOpen;
this.$focus.focus(this.inputEl)
}
})
}
const comboboxListItem = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':role'() {
return 'option'
},
':id'() {
return this.$id('combobox-item')
},
':aria-selected'() {
return el === this.selectedEl;
},
':data-active'() {
return el === this.activeEl;
},
':tabIndex'() {
return el === this.activeEl ? 0 : -1;
},
'x-init'() {
this.$nextTick(() => {
this.label = el.textContent
})
},
'x-data'() {
return {
label: ''
}
},
'x-show'() {
return this.isDirty ? this.$data.label.toLowerCase().startsWith(this.inputValue.toLowerCase()) : true
},
'@mousedown.prevent'() {
return true
},
'@mouseup.prevent'() {
this.select(el);
this.isDirty = false;
this.isOpen = false;
},
'@keydown.prevent.enter'() {
this.select(el);
this.isDirty = false;
this.isOpen = false;
},
'@mousemove.prevent'() {
this.activeEl = el
this.$focus.focus(el)
},
'@keydown.prevent.esc'() {
this.isOpen = false;
this.$focus.focus(this.inputEl)
},
})
}
const comboboxLabel = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':id'() {
return this.$id('combobox-label')
},
':for'() {
return this.$id('combobox-input')
},
'x-init'() {
this.labelEl = el
},
'@click'() {
this.isOpen = true
}
})
}
src/assets/js/dropdown.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
export default function (Alpine: Alpine) {
Alpine.directive('dropdown', (el, directive) => {
if (directive.value === 'trigger') dropdownTrigger(el, Alpine)
else if (directive.value === 'content') dropdownContent(el, Alpine)
else if (directive.value === 'item') dropdownItem(el, Alpine)
else dropdownRoot(el, Alpine)
})
Alpine.magic('dropdown', el => {
const $data = Alpine.$data(el)
return {
get isOpen() {
return $data.isOpen
},
open() {
return $data.open()
},
close() {
return $data.close()
}
}
})
}
const dropdownRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-data'() {
return {
contentEl: undefined as HTMLElement | undefined,
triggerEl: undefined as HTMLElement | undefined,
rootEl: undefined as HTMLElement | undefined,
isOpen: false,
open() {
return this.isOpen = true
},
close() {
return this.isOpen = false
},
toggle() {
return this.isOpen = !this.isOpen
}
}
},
'x-init'() {
this.rootEl = el;
},
'x-id'() {
return ['dropdown-trigger', 'dropdown-content']
},
'@focusout'() {
if (!el.contains(this.$event.relatedTarget)) {
this.close()
}
},
})
}
const dropdownContent = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':role'() {
return 'menu'
},
':aria-labelledby'() {
return this.$id('dropown-trigger')
},
':tabIndex'() {
return -1
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-dropdown:content" is missing a parent element with "x-dropdown".')
this.contentEl = el
},
'x-show'() {
return this.isOpen
},
'x-anchor.bottom-start'() {
return this.triggerEl;
},
'@keydown.prevent.escape'() {
this.close()
this.$focus.focus(this.triggerEl)
},
'@keydown.prevent.up'() {
if (this.$focus.within(this.contentEl).getPrevious()) {
this.$focus.within(this.contentEl).wrap().previous()
} else {
this.$focus.within(this.contentEl).last()
}
},
'@keydown.prevent.down'() {
if (this.$focus.within(this.contentEl).getNext()) {
this.$focus.within(this.contentEl).wrap().next()
} else {
this.$focus.within(this.contentEl).first()
}
},
'@keydown.prevent.home'() {
this.$focus.within(this.contentEl).first()
},
'@keydown.prevent.end'() {
this.$focus.within(this.contentEl).last()
},
})
}
const dropdownItem = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':role'() {
return 'menuitem'
},
':tabIndex'() {
return -1
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-dropdown:item" is missing a parent element with "x-dropdown".')
},
'@click'() {
this.close()
this.$focus.focus(this.triggerEl)
},
'@keydown.stop.prevent.space'() {
this.$event.target.click()
},
'@keydown.stop.prevent.enter'() {
this.$event.target.click()
},
})
}
const dropdownTrigger = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':aria-controls'() {
return this.$id('dropdown-content')
},
':aria-expanded'() {
return this.isOpen
},
':aria-haspopup'() {
return true
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-dropdown:trigger" is missing a parent element with "x-dropdown".')
this.triggerEl = el;
},
'@click'() {
this.toggle()
},
'@keydown.prevent.enter'() {
this.open();
this.$focus.within(this.contentEl).first()
},
'@keydown.prevent.space'() {
this.open();
this.$focus.within(this.contentEl).first()
},
'@keydown.prevent.up'() {
this.open();
this.$focus.within(this.contentEl).last()
},
'@keydown.prevent.down'() {
this.open();
this.$focus.within(this.contentEl).first()
},
'@keydown.prevent.home'() {
this.open();
this.$focus.within(this.contentEl).first()
},
'@keydown.prevent.end'() {
this.open();
this.$focus.within(this.contentEl).last()
},
})
}

File Input

src/assets/js/file-input.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
export default function (Alpine: Alpine) {
Alpine.directive('file-input', (el, directive) => {
if (directive.value === 'dropzone') fileInputDropzone(el, Alpine)
else if (directive.value === 'input') fileInputInput(el, Alpine)
else if (directive.value === 'error-message') fileInputErrorMessage(el, Alpine)
else fileInputRoot(el, Alpine)
})
}
const fileInputRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-data'() {
return {
isInitialized: false,
isValid: true,
errorMessage: '',
inputEl: undefined as HTMLElement | undefined,
isDragging: false,
validateFiles() {
const acceptedTypes = this.inputEl.getAttribute("accept");
const minFileSize = this.inputEl.getAttribute("data-minsize");
const maxFileSize = this.inputEl.getAttribute("data-maxsize");
// const maxFiles = this.inputEl.getAttribute("data-maxfiles");
const files: File[] = Array.from(this.$event.target.files || this.$event.dataTransfer.files)
const acceptedFiles: File[] = []
const rejectedFiles: { file: File, errors: string[] }[] = []
files.forEach(file => {
const { isValid: isFileTypeValid, errorMessage: fileTypeError } = isValidFileType(file, acceptedTypes)
const { isValid: isFileSizeValid, errorMessage: fileSizeError } = isValidFileSize(file, minFileSize, maxFileSize)
if (isFileTypeValid && isFileSizeValid) {
acceptedFiles.push(file)
} else {
const errors = [fileTypeError, fileSizeError]
rejectedFiles.push({ file, errors: errors.filter((e) => e !== null) as string[] })
}
});
if (rejectedFiles.length > 0) {
this.isValid = false
this.errorMessage = rejectedFiles[0]?.errors[0]
}
}
}
},
'x-init'() {
this.isInitialized = true
},
':data-dragging'() {
return this.isDragging
},
':data-invalid'() {
return !this.isValid
}
})
}
const fileInputDropzone = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-file-input:dropzone" is missing a parent element with "x-file-input".')
},
'@dragover'() {
return this.isDragging = true
},
'@dragleave'() {
return this.isDragging = false
},
'@drop'() {
return this.isDragging = false
},
'@change'() {
this.validateFiles()
},
':data-dragging'() {
return this.isDragging
},
})
}
const fileInputInput = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-file-input:input" is missing a parent element with "x-file-input".')
this.inputEl = el;
},
':data-invalid'() {
return !this.isValid
},
':aria-invalid'() {
return !this.isValid
}
})
}
const fileInputErrorMessage = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-file-input:error-message" is missing a parent element with "x-file-input".')
this.inputEl = el;
},
'x-text'() {
return this.errorMessage
},
})
}
function isFileAccepted(file: File | null, accept: string | undefined) {
if (file && accept) {
const types = accept.split(",")
const fileName = file.name || ""
const mimeType = (file.type || "").toLowerCase()
const baseMimeType = mimeType.replace(/\/.*$/, "")
return types.some((type) => {
const validType = type.trim().toLowerCase()
if (validType.charAt(0) === ".") {
return fileName.toLowerCase().endsWith(validType)
}
if (validType.endsWith("/*")) {
return baseMimeType === validType.replace(/\/.*$/, "")
}
return mimeType === validType
})
}
return true
}
function isDefined<T>(v: T | undefined): v is T {
return v !== undefined && v !== null
}
function isValidFileType(file: File, accept: string | undefined) {
const isAcceptable = file.type === "application/x-moz-file" || isFileAccepted(file, accept)
return isAcceptable
? { isValid: true, errorMessage: null }
: { isValid: false, errorMessage: 'The selected file is an incorrect file type.' }
}
function isValidFileSize(file: File, minSize?: number, maxSize?: number) {
if (isDefined(file.size)) {
if (isDefined(minSize) && isDefined(maxSize)) {
if (file.size > maxSize) return { isValid: false, errorMessage: `The selected file must be smaller than ${minSize}.` }
if (file.size < minSize) return { isValid: false, errorMessage: `The selected file must be larger than ${maxSize}.` }
} else if (isDefined(minSize) && file.size < minSize) {
return { isValid: false, errorMessage: `The selected file must be larger than ${maxSize}.` }
} else if (isDefined(maxSize) && file.size > maxSize) {
return { isValid: false, errorMessage: `The selected file must be smaller than ${minSize}.` }
}
}
return { isValid: true, errorMessage: null }
}

Input Mask

src/assets/js/input-mask.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
export default function (Alpine: Alpine) {
Alpine.directive('input-mask', (el, directive) => {
if (directive.value === 'input') inputMaskInput(el, Alpine)
if (directive.value === 'input-placeholder') inputMaskInputDisplay(el, Alpine)
if (directive.value === 'mask-placeholder') inputMaskMaskDisplay(el, Alpine)
else inputMaskRoot(el, Alpine)
})
}
const inputMaskRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-data'() {
return {
maskPlaceholder: '',
inputValue: '',
isInitialized: false
}
},
'x-init'() {
this.isInitialized = true
},
})
}
const inputMaskInput = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-input-mask:input" is missing a parent element with "x-input-mask".')
if (el.placeholder) {
this.maskPlaceholder = el.placeholder
el.dataset.placeholder = el.placeholder
el.removeAttribute('placeholder')
}
return
},
'@input'() {
return this.$nextTick(() => {
this.inputValue = el.value
})
},
})
}
const inputMaskInputDisplay = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-input-mask:input-placeholder" is missing a parent element with "x-input-mask".')
},
'x-text'() {
return this.inputValue
}
})
}
const inputMaskMaskDisplay = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isInitialized === undefined) console.warn('"x-input-mask:mask-placeholder" is missing a parent element with "x-input-mask".')
},
'x-text'() {
return this.maskPlaceholder.split('').map((val, idx) => this.inputValue[idx] ? '' : val).join('')
}
})
}
src/assets/js/modal.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs";
export default function (Alpine: Alpine) {
Alpine.directive('modal', (el, directive) => {
if (directive.value === 'title') modalTitle(el, Alpine)
else if (directive.value === 'description') modalDescription(el, Alpine)
else if (directive.value === 'backdrop') modalBackdrop(el, Alpine)
else if (directive.value === 'dialog') modalDialog(el, Alpine)
else if (directive.value === 'content') modalContent(el, Alpine)
else if (directive.value === 'trigger') modalTrigger(el, Alpine)
else if (directive.value === 'close-button') modalCloseButton(el, Alpine)
else modalRoot(el, Alpine)
})
Alpine.magic('modal', el => {
const $data = Alpine.$data(el)
return {
get isOpen() {
return $data.isOpen
},
close() {
return $data.close()
}
}
})
}
const modalRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-data'() {
return {
isOpen: false,
isDismissable: true,
open() {
return this.isOpen = true
},
close() {
return this.isOpen = false
}
}
},
'x-id'() {
return ['modal-title', 'modal-description']
},
})
}
const modalTitle = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':id'() {
return this.$id('modal-title')
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-modal:title" is missing a parent element with "x-modal".')
},
})
}
const modalDescription = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':id'() {
return this.$id('modal-description')
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-modal:description" is missing a parent element with "x-modal".')
},
})
}
const modalBackdrop = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':aria-haspopup'() {
return 'true'
},
':aria-hidden'() {
return 'true'
},
'x-show'() {
return this.isOpen
},
'@click.stop.prevent'() {
if (this.isDismissable) this.close()
return
},
})
}
const modalDialog = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-modal:dialog" is missing a parent element with "x-modal".')
this.isDismissable = !el.hasAttribute('data-force-action')
},
':tabIndex'() {
return -1
},
':aria-labelledby'() {
return this.$id('modal-title')
},
':aria-describedby'() {
return this.$id('modal-description')
},
':aria-modal'() {
return 'true'
},
':role'() {
return 'dialog'
},
'x-show'() {
return this.isOpen
},
'x-trap.inert.noscroll'() {
return this.isOpen
},
'@keydown.stop.prevent.escape'() {
if (this.isDismissable) this.close()
return
},
})
}
const modalContent = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-modal:content" is missing a parent element with "x-modal".')
},
'@click.outside'() {
if (this.isDismissable) this.close()
return
},
})
}
const modalTrigger = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-modal:trigger" is missing a parent element with "x-modal".')
},
'@click'() {
this.isOpen ? this.close() : this.open()
}
})
}
const modalCloseButton = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-modal:close-button" is missing a parent element with "x-modal".')
},
'@click'() {
this.close()
}
})
}

Tooltip

src/assets/js/tooltip.ts
import type { Alpine, ElementWithXAttributes } from "alpinejs"
const validPositions = ['top', 'top-start', 'top-end', 'right', 'right-start', 'right-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'left-start', 'left-end']
export default function (Alpine: Alpine) {
Alpine.directive('tooltip', (el, directive) => {
if (directive.value === 'trigger') tooltipTrigger(el, Alpine)
else if (directive.value === 'content') tooltipContent(el, Alpine)
else tooltipRoot(el, Alpine)
})
}
const tooltipRoot = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
'x-data'() {
return {
contentEl: undefined as HTMLElement | undefined,
triggerEl: undefined as HTMLElement | undefined,
rootEl: undefined as HTMLElement | undefined,
isOpen: false,
position: 'top',
open() {
return this.isOpen = true
},
close() {
return this.isOpen = false
},
}
},
'x-init'() {
this.rootEl = el;
this.isOpen = false;
if (el.dataset.position) {
this.position = el.dataset.position
}
if (!validPositions.includes(this.position)) {
console.warn(`Tooltip "data-position" of "${el.dataset.position}" is invalid. Defaulting to "top".`)
}
},
'x-id'() {
return ['tooltip-content']
},
})
}
const tooltipContent = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':id'() {
return this.$id('tooltip-content')
},
':aria-hidden'() {
return this.isOpen ? 'false' : 'true'
},
':role'() {
return 'tooltip'
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-tooltip:content" is missing a parent element with "x-tooltip".')
},
'x-show'() {
return this.isOpen
},
'x-transition.opacity.80ms'() {
return true
},
'x-bind'() {
return {
[`x-anchor.${this.position}.offset.5`]() {
return this.triggerEl;
},
};
},
})
}
const tooltipTrigger = (el: ElementWithXAttributes<HTMLElement>, Alpine: Alpine) => {
Alpine.bind(el, {
':aria-describedby'() {
return this.$id('tooltip-content')
},
'x-init'() {
if (this.isOpen === undefined) console.warn('"x-tooltip:trigger" is missing a parent element with "x-tooltip".')
this.triggerEl = el
},
'@mouseover'() {
this.open()
},
'@mouseleave'() {
this.close()
},
'@focus'() {
this.open()
},
'@blur'() {
this.close()
}
})
}