base/src/controls.js

163 lines
14 KiB
JavaScript
Raw Normal View History

import { Component, NewState, Util, $ } from '@web/state'
import { AutoForm } from './form.js'
/**
* DatePicker
* 支持单日期及范围选择 (主字段 + 影子字段模式)
*/
Component.register('DatePicker', container => {
container.state = NewState({ start: '', end: '' })
container.addEventListener('bind', e => {
container.state.start = e.detail || ''
const form = container.closest('AutoForm')
const name = container.getAttribute('name')
const item = form?.state?.schema?.find(i => i.name === name)
const rangeEnd = item?.setting?.rangeEnd || container.rangeEnd
if (form && rangeEnd) {
container.state.end = form.data[rangeEnd] || ''
}
})
Object.defineProperty(container, 'isRange', {
get: () => {
const form = container.closest('AutoForm')
const name = container.getAttribute('name')
const item = form?.state?.schema?.find(i => i.name === name)
return !!(item?.setting?.rangeEnd || container.rangeEnd)
}
})
Object.defineProperty(container, 'value', {
get: () => container.state.start,
set: v => { container.state.start = v || ''; }
})
container.updateStart = (val) => {
container.state.start = val
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
}
container.updateEnd = (val) => {
container.state.end = val
const form = container.closest('AutoForm')
const name = container.getAttribute('name')
const item = form?.state?.schema?.find(i => i.name === name)
const rangeEnd = item?.setting?.rangeEnd || container.rangeEnd
if (form && rangeEnd) {
form.data[rangeEnd] = val
}
}
}, Util.makeDom(/*html*/`
<div class="d-flex align-items-center gap-1 w-100">
<input type="date" class="form-control h-100" $bind="this.state.start" $onchange="this.updateStart(thisNode.value)">
<template $if="this.isRange">
<span class="text-muted mx-1">-</span>
<input type="date" class="form-control h-100" $bind="this.state.end" $onchange="this.updateEnd(thisNode.value)">
</template>
</div>
`))
/**
* ColorPicker
* 支持颜色选择与十六进制文本输入
*/
Component.register('ColorPicker', container => {
container.state = NewState({ value: '#000000' })
container.addEventListener('bind', e => {
container.state.value = e.detail || '#000000'
})
Object.defineProperty(container, 'value', {
get: () => container.state.value,
set: v => { container.state.value = v || '#000000'; }
})
container.updateValue = (val) => {
container.state.value = val
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
}
}, Util.makeDom(/*html*/`
<div class="d-flex align-items-center gap-2 w-100 h-100">
<input type="color" class="form-control form-control-color" style="width: 3rem; height: 100%; padding: 0.25rem" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
<input type="text" class="form-control h-100" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
</div>
`))
const BOOTSTRAP_ICONS = ['alarm', 'archive', 'arrow-left', 'arrow-right', 'bag', 'bank', 'basket', 'bell', 'bookmark', 'box', 'briefcase', 'calendar', 'camera', 'cart', 'chat', 'check', 'chevron-down', 'chevron-left', 'chevron-right', 'chevron-up', 'clock', 'cloud', 'code', 'collection', 'command', 'cpu', 'credit-card', 'cup', 'dash', 'database', 'display', 'door-closed', 'download', 'droplet', 'earbuds', 'edit', 'egg', 'eject', 'envelope', 'eraser', 'eye', 'file', 'filter', 'flag', 'folder', 'gear', 'gem', 'gift', 'graph-up', 'grid', 'hammer', 'hand-thumbs-up', 'heart', 'house', 'image', 'inbox', 'info-circle', 'journal', 'key', 'laptop', 'layers', 'layout-text-sidebar-reverse', 'lightbulb', 'link', 'list', 'lock', 'map', 'mic', 'moon', 'mouse', 'music-note', 'newspaper', 'palette', 'paperclip', 'pause', 'pencil', 'person', 'phone', 'pie-chart', 'play', 'plus', 'printer', 'puzzle', 'question-circle', 'reception-4', 'record', 'reply', 'rss', 'save', 'search', 'send', 'server', 'share', 'shield', 'shop', 'shuffle', 'skip-end', 'skip-start', 'slash', 'sliders', 'smartphone', 'speaker', 'speedometer', 'spellcheck', 'square', 'star', 'stickies', 'stop', 'stopwatch', 'suit-heart', 'sun', 'table', 'tag', 'tags', 'telephone', 'terminal', 'text-paragraph', 'thermometer', 'three-dots', 'ticket', 'tools', 'trash', 'trophy', 'truck', 'tv', 'umbrella', 'unlock', 'upload', 'vector-pen', 'wallet', 'watch', 'wifi', 'window', 'wrench', 'x', 'zoom-in', 'zoom-out', 'activity', 'at', 'award', 'backspace', 'badge-3d', 'badge-4k', 'badge-8k', 'badge-ad', 'badge-ar', 'badge-cc', 'badge-hd', 'badge-tm', 'badge-vo', 'badge-vr', 'badge-wc', 'bar-chart', 'battery', 'bicycle', 'binoculars', 'blockquote-left', 'blockquote-right', 'book', 'bookshelf', 'bootstrap', 'border-all', 'border-bottom', 'border-center', 'border-inner', 'border-left', 'border-middle', 'border-outer', 'border-right', 'border-style', 'border-top', 'border-width', 'bounding-box', 'box-arrow-down', 'box-arrow-in-down', 'box-arrow-in-left', 'box-arrow-in-right', 'box-arrow-in-up', 'box-arrow-left', 'box-arrow-right', 'box-arrow-up', 'box-seam', 'brightness-alt-high', 'brightness-alt-low', 'brightness-high', 'brightness-low', 'broadcast', 'brush', 'bucket', 'bug', 'building', 'bullseye', 'calculator', 'calendar-check', 'calendar-date', 'calendar-day', 'calendar-event', 'calendar-minus', 'calendar-month', 'calendar-plus', 'calendar-range', 'calendar-week', 'calendar-x', 'calendar2', 'calendar3', 'calendar4', 'camera-reels', 'camera-video', 'capslock', 'card-checklist', 'card-heading', 'card-image', 'card-list', 'card-text', 'caret-down', 'caret-left', 'caret-right', 'caret-up', 'cart-check', 'cart-dash', 'cart-plus', 'cart-x', 'cash', 'cash-stack', 'cast', 'chat-dots', 'chat-left', 'chat-quote', 'chat-right', 'chat-square', 'chat-text', 'check-all', 'check-circle', 'check-square', 'circle', 'clipboard', 'cloud-arrow-down', 'cloud-arrow-up', 'cloud-check', 'cloud-download', 'cloud-fog', 'cloud-hail', 'cloud-lightning', 'cloud-minus', 'cloud-moon', 'cloud-plus', 'cloud-rain', 'cloud-slash', 'cloud-snow', 'cloud-sun', 'cloud-upload', 'clouds', 'cloudy', 'code-slash', 'code-square', 'collection-play', 'columns', 'columns-gap', 'compass', 'cone', 'cone-striped', 'controller', 'credit-card-2-back', 'credit-card-2-front', 'crop', 'cup-straw', 'cursor', 'dash-circle', 'dash-square', 'diagram-2', 'diagram-3', 'diamond', 'dice-1', 'dice-2', 'dice-3', 'dice-4', 'dice-5', 'dice-6', 'disc', 'discord', 'distribute-horizontal', 'distribute-vertical', 'door-open', 'dot', 'droplet-half', 'easel', 'egg-fried', 'emoji-angry', 'emoji-dizzy', 'emoji-expressionless', 'emoji-frown', 'emoji-heart-eyes', 'emoji-laughing', 'emoji-neutral', 'emoji-smile', 'emoji-sunglasses', 'emoji-wink', 'envelope-open', 'exclamation', 'exclamation-circle', 'exclamation-diamond', 'exclamation-octagon', 'exclamation-square', 'exclamation-triangle', 'eye-slash', 'eyedropper', 'facebook', 'file-arrow-down', 'file-arrow-up', 'file-binary', 'file-break', 'file-check', 'file-code', 'file-diff', 'file-earmark', 'file-excel', 'file
/**
* IconPicker
* 基于 Bootstrap Icons 的可视化选择控件
*/
Component.register('IconPicker', container => {
container.state = NewState({ value: '', search: '', open: false })
container.addEventListener('bind', e => {
container.state.value = e.detail || ''
})
Object.defineProperty(container, 'value', {
get: () => container.state.value,
set: v => { container.state.value = v || ''; }
})
Object.defineProperty(container, 'filteredIcons', {
get: () => {
const s = container.state.search?.toLowerCase() || ''
return BOOTSTRAP_ICONS.filter(i => i.includes(s))
}
})
container.selectIcon = (icon) => {
container.state.value = icon
container.state.open = false
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: icon }))
}
container.toggle = () => {
container.state.open = !container.state.open
if (container.state.open) {
setTimeout(() => {
$(container, 'input')?.focus()
}, 10)
}
}
const onGlobalClick = (e) => {
if (!container.contains(e.target)) {
container.state.open = false
}
}
window.addEventListener('click', onGlobalClick)
container.addEventListener('remove', () => window.removeEventListener('click', onGlobalClick))
}, Util.makeDom(/*html*/`
<div class="dropdown w-100 h-100">
<button class="btn btn-outline-secondary dropdown-toggle w-100 h-100 d-flex align-items-center justify-content-between px-2" type="button" $onclick="this.toggle()">
<span class="d-flex align-items-center overflow-hidden">
<i $if="this.state.value" $class="bi bi-\${this.state.value} me-2"></i>
<span class="text-truncate" $text="this.state.value || '{#Select Icon#}'"></span>
</span>
</button>
<div $class="dropdown-menu p-2 shadow \${this.state.open ? 'show' : ''}" style="width: 300px; max-height: 350px; overflow-y: hidden; display: flex; flex-direction: column; z-index: 1050">
<input type="text" class="form-control form-control-sm mb-2" placeholder="Search icons..." $bind="this.state.search" $onclick="event.stopPropagation()">
<div class="d-flex flex-wrap gap-1 overflow-auto p-1" style="flex: 1" $onclick="event.stopPropagation()">
<template $each="this.filteredIcons" as="icon">
<button type="button" class="btn btn-sm btn-outline-light text-dark p-2 d-flex align-items-center justify-content-center" $onclick="this.selectIcon(icon)" $title="icon" style="width: 40px; height: 40px">
<i $class="bi bi-\${icon}"></i>
</button>
</template>
<div $if="this.filteredIcons.length === 0" class="text-muted p-2 w-100 text-center">No icons found</div>
</div>
</div>
</div>
`), Util.makeDom(/*html*/`<style>
IconPicker { display: block; height: 100%; }
IconPicker .dropdown-menu { left: 0; }
IconPicker .btn-outline-light:hover { background-color: var(--bs-primary-bg-subtle); border-color: var(--bs-primary); }
</style>`))
// 注册到 AutoForm
AutoForm.register('DatePicker')
AutoForm.register('ColorPicker')
AutoForm.register('IconPicker')
export { }