@getdom/studio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +109 -0
- package/README.md +44 -0
- package/SKILL.md +83 -0
- package/package.json +73 -0
- package/public/llms.txt +57 -0
- package/public/skills.sh +11 -0
- package/src/pages/_layout/componentCatalog.generated.js +4514 -0
- package/src/pages/_layout/componentInspector.js +60 -0
- package/src/pages/_layout/componentManager.js +192 -0
- package/src/pages/_layout/componentRuntimeRecords.js +49 -0
- package/src/pages/_layout/inspector/componentRegistry.js +639 -0
- package/src/pages/_layout/inspector/serialize.js +134 -0
- package/src/pages/_layout/inspector/useInspector.js +293 -0
- package/src/pages/components/accordion/DomAccordion.vue +39 -0
- package/src/pages/components/breadcrumbs/DomBreadcrumbs.vue +347 -0
- package/src/pages/components/button/DomButton.vue +132 -0
- package/src/pages/components/command-palette/DomCommandPalette.vue +482 -0
- package/src/pages/components/context-menu/DomContextMenu.vue +178 -0
- package/src/pages/components/dialog/DomDialog.vue +271 -0
- package/src/pages/components/dialog/DomDialogSchemaForm.vue +72 -0
- package/src/pages/components/dialog/DomDialogStack.vue +289 -0
- package/src/pages/components/dialog/useDialogs.js +253 -0
- package/src/pages/components/drawer/DomDrawer.vue +159 -0
- package/src/pages/components/dropdown/DomDropdown.vue +261 -0
- package/src/pages/components/icon-button/DomIconButton.vue +155 -0
- package/src/pages/components/lightbox/DomLightbox.vue +1561 -0
- package/src/pages/components/menu/DomMenu.vue +359 -0
- package/src/pages/components/menu/DomMenuItem.vue +233 -0
- package/src/pages/components/menu/DomMenuLabel.vue +19 -0
- package/src/pages/components/menu/DomMenuSeparator.vue +14 -0
- package/src/pages/components/menu/menuContext.js +11 -0
- package/src/pages/components/menu/menuPlacement.js +26 -0
- package/src/pages/components/menu/submenu.js +58 -0
- package/src/pages/components/pagination/DomPagination.vue +265 -0
- package/src/pages/components/playground/DomRenderer.vue +48 -0
- package/src/pages/components/popover/DomPopover.vue +244 -0
- package/src/pages/components/profile-menu/DomProfileMenu.vue +408 -0
- package/src/pages/components/profile-menu/DomProfileMenuItemContent.vue +45 -0
- package/src/pages/components/scroll-menu/DomScrollMenu.vue +430 -0
- package/src/pages/components/scroll-menu/DomScrollMenuItem.vue +212 -0
- package/src/pages/components/scroll-menu/scrollMenuContext.js +22 -0
- package/src/pages/components/splitter-panel/DomSplitterPanel.vue +283 -0
- package/src/pages/components/stepper/DomStepper.vue +386 -0
- package/src/pages/components/tabs/DomTabs.vue +149 -0
- package/src/pages/components/theme-switcher/DomThemeSwitcher.vue +226 -0
- package/src/pages/components/toast/DomToastItem.vue +52 -0
- package/src/pages/components/toast/DomToastStack.vue +768 -0
- package/src/pages/components/toast/useToasts.js +237 -0
- package/src/pages/components/toolbar/DomToolbar.vue +158 -0
- package/src/pages/components/tooltip/DomTooltip.vue +69 -0
- package/src/pages/components/tooltip/vDomTooltip.js +106 -0
- package/src/pages/components/tree-view/DomTreeView.vue +961 -0
- package/src/pages/components/tree-view/treeSchemas.js +129 -0
- package/src/pages/components/tree-view/treeUtils.js +228 -0
- package/src/pages/data/_shared/calculateVirtualWindow.js +101 -0
- package/src/pages/data/_shared/resourceRecord.js +29 -0
- package/src/pages/data/activity-timeline/DomActivityTimeline.vue +417 -0
- package/src/pages/data/agent-chat/DomAgentChatShell.vue +565 -0
- package/src/pages/data/agent-chat/DomAgentConversation.vue +737 -0
- package/src/pages/data/agent-chat/DomAgentConversationList.vue +319 -0
- package/src/pages/data/agent-chat/examples/agentChatMockApi.js +620 -0
- package/src/pages/data/agent-chat/examples/useAgentChatDemoStore.js +730 -0
- package/src/pages/data/data-grid/DataGridVirtualRows.vue +220 -0
- package/src/pages/data/data-grid/DomDataGrid.vue +1643 -0
- package/src/pages/data/document-editor/DomDocumentEditor.vue +3030 -0
- package/src/pages/data/document-editor/documentEditorCodeBlock.js +282 -0
- package/src/pages/data/document-editor/examples/documentEditorMockApi.js +272 -0
- package/src/pages/data/document-editor/examples/useDocumentEditorDemoStore.js +284 -0
- package/src/pages/data/filter-bar/DomFilterBar.vue +533 -0
- package/src/pages/data/kanban/DomKanban.vue +1703 -0
- package/src/pages/data/kanban/kanbanApi.js +80 -0
- package/src/pages/data/media-browser/DomMediaBrowser.vue +2344 -0
- package/src/pages/data/media-browser/examples/mediaBrowserMockApi.js +653 -0
- package/src/pages/data/media-browser/examples/useMediaBrowserDemoStore.js +1157 -0
- package/src/pages/data/month-calendar/DomMonthCalendar.vue +1718 -0
- package/src/pages/data/week-planner/DomWeekPlanner.vue +2308 -0
- package/src/pages/dev/code-block/DomCodeBlock.vue +263 -0
- package/src/pages/dev/json-viewer/DomJsonViewer.vue +962 -0
- package/src/pages/forms/_shared/DomFieldLoadingSpinner.vue +12 -0
- package/src/pages/forms/_shared/options.js +61 -0
- package/src/pages/forms/_shared/useOptionMenu.js +59 -0
- package/src/pages/forms/autocomplete/DomAutocomplete.vue +180 -0
- package/src/pages/forms/calendar/DomCalendar.vue +442 -0
- package/src/pages/forms/checkbox/DomCheckbox.vue +69 -0
- package/src/pages/forms/class-toggle-input/DomClassToggleInput.vue +316 -0
- package/src/pages/forms/code-input/DomCodeInput.vue +202 -0
- package/src/pages/forms/color-input/DomColorInput.vue +118 -0
- package/src/pages/forms/combobox/DomCombobox.vue +237 -0
- package/src/pages/forms/confirmation-code-input/DomConfirmationCodeInput.vue +345 -0
- package/src/pages/forms/date-picker/DomDatePicker.vue +299 -0
- package/src/pages/forms/date-time-picker/DomDateTimePicker.vue +630 -0
- package/src/pages/forms/email-input/DomEmailInput.vue +55 -0
- package/src/pages/forms/field/DomField.vue +267 -0
- package/src/pages/forms/field/fieldLayout.js +1 -0
- package/src/pages/forms/field/fieldProps.js +114 -0
- package/src/pages/forms/field/layouts/DomGridFieldLayout.vue +38 -0
- package/src/pages/forms/field/useField.js +343 -0
- package/src/pages/forms/field/validators.js +364 -0
- package/src/pages/forms/file-upload/DomFileUpload.vue +361 -0
- package/src/pages/forms/form/DomForm.vue +985 -0
- package/src/pages/forms/form/adapters/fieldBuilder.js +45 -0
- package/src/pages/forms/form/adapters/jsonSchema.js +264 -0
- package/src/pages/forms/form/adapters/shared.js +53 -0
- package/src/pages/forms/form/adapters/zod.js +235 -0
- package/src/pages/forms/form/formApi.js +38 -0
- package/src/pages/forms/form/formDefinition.js +392 -0
- package/src/pages/forms/form/schemaAdapter.js +54 -0
- package/src/pages/forms/icon-selector/DomIconSelector.vue +460 -0
- package/src/pages/forms/icon-selector/iconSelectorItems.js +490 -0
- package/src/pages/forms/input-group/DomInputGroup.vue +232 -0
- package/src/pages/forms/json-input/DomJsonInput.vue +227 -0
- package/src/pages/forms/json-list-input/DomJsonListInput.vue +206 -0
- package/src/pages/forms/listbox/DomListbox.vue +105 -0
- package/src/pages/forms/money-input/DomMoneyInput.vue +147 -0
- package/src/pages/forms/native-select/DomNativeSelect.vue +67 -0
- package/src/pages/forms/number-input/DomNumberInput.vue +47 -0
- package/src/pages/forms/password-input/DomPasswordInput.vue +218 -0
- package/src/pages/forms/password-input/pwnedPasswords.js +47 -0
- package/src/pages/forms/password-strength/DomPasswordStrength.vue +128 -0
- package/src/pages/forms/password-strength/passwordStrength.js +92 -0
- package/src/pages/forms/position-input/DomPositionInput.vue +83 -0
- package/src/pages/forms/radio-group/DomRadioGroup.vue +102 -0
- package/src/pages/forms/range-input/DomRangeInput.vue +296 -0
- package/src/pages/forms/select/DomSelect.vue +479 -0
- package/src/pages/forms/select-input/DomSelectInput.vue +155 -0
- package/src/pages/forms/tag-combobox/DomTagCombobox.vue +358 -0
- package/src/pages/forms/text-input/DomTextInput.vue +48 -0
- package/src/pages/forms/textarea-composer/DomTextareaComposer.vue +234 -0
- package/src/pages/forms/textarea-input/DomTextareaInput.vue +86 -0
- package/src/pages/forms/textarea-input/useTextareaAutosize.js +147 -0
- package/src/pages/forms/time-selector/DomTimeSelector.vue +389 -0
- package/src/pages/forms/time-selector/timeUtils.js +194 -0
- package/src/pages/forms/toggle/DomToggle.vue +158 -0
- package/src/pages/forms/toggle-button/DomToggleButton.vue +88 -0
- package/src/pages/forms/toggle-button-group/DomToggleButtonGroup.vue +340 -0
- package/src/pages/forms/url-input/DomUrlInput.vue +55 -0
- package/src/pages/forms/wysiwyg/DomWysiwyg.vue +204 -0
- package/src/pages/lib/css/color.js +245 -0
- package/src/pages/lib/headless/accordion.d.ts +7 -0
- package/src/pages/lib/headless/accordion.js +246 -0
- package/src/pages/lib/headless/autocomplete.d.ts +4 -0
- package/src/pages/lib/headless/autocomplete.js +33 -0
- package/src/pages/lib/headless/base.d.ts +25 -0
- package/src/pages/lib/headless/base.js +127 -0
- package/src/pages/lib/headless/checkbox.d.ts +19 -0
- package/src/pages/lib/headless/checkbox.js +99 -0
- package/src/pages/lib/headless/combobox.d.ts +7 -0
- package/src/pages/lib/headless/combobox.js +488 -0
- package/src/pages/lib/headless/dialog.d.ts +23 -0
- package/src/pages/lib/headless/dialog.js +384 -0
- package/src/pages/lib/headless/drawer.d.ts +21 -0
- package/src/pages/lib/headless/drawer.js +682 -0
- package/src/pages/lib/headless/dropdown.d.ts +19 -0
- package/src/pages/lib/headless/dropdown.js +412 -0
- package/src/pages/lib/headless/floating-effect.js +118 -0
- package/src/pages/lib/headless/floating.d.ts +34 -0
- package/src/pages/lib/headless/floating.js +382 -0
- package/src/pages/lib/headless/index.d.ts +19 -0
- package/src/pages/lib/headless/index.js +19 -0
- package/src/pages/lib/headless/listbox.d.ts +19 -0
- package/src/pages/lib/headless/listbox.js +126 -0
- package/src/pages/lib/headless/menu.d.ts +18 -0
- package/src/pages/lib/headless/menu.js +117 -0
- package/src/pages/lib/headless/popover-panel.d.ts +18 -0
- package/src/pages/lib/headless/popover-panel.js +309 -0
- package/src/pages/lib/headless/popover.d.ts +19 -0
- package/src/pages/lib/headless/popover.js +391 -0
- package/src/pages/lib/headless/radio-group.d.ts +19 -0
- package/src/pages/lib/headless/radio-group.js +100 -0
- package/src/pages/lib/headless/tabs.d.ts +18 -0
- package/src/pages/lib/headless/tabs.js +119 -0
- package/src/pages/lib/headless/toast.d.ts +13 -0
- package/src/pages/lib/headless/toast.js +65 -0
- package/src/pages/lib/headless/toggle.d.ts +19 -0
- package/src/pages/lib/headless/toggle.js +162 -0
- package/src/pages/lib/headless/tooltip-controller.d.ts +22 -0
- package/src/pages/lib/headless/tooltip-controller.js +549 -0
- package/src/pages/lib/headless/tooltip.d.ts +19 -0
- package/src/pages/lib/headless/tooltip.js +153 -0
- package/src/pages/lib/headless/transitions.d.ts +5 -0
- package/src/pages/lib/headless/transitions.js +89 -0
- package/src/pages/lib/vue/_internal/DomLiquidGlassSurface.vue +310 -0
- package/src/pages/lib/vue/_internal/liquidGlass.js +112 -0
- package/src/pages/lib/vue/_internal/liquidGlassConfig.js +10 -0
- package/src/pages/lib/vue/codeHighlighter.js +132 -0
- package/src/pages/lib/vue/floatingTeleportTarget.js +23 -0
- package/src/pages/lib/vue/index.d.ts +6806 -0
- package/src/pages/lib/vue/index.js +218 -0
- package/src/pages/lib/vue/useTheme.js +116 -0
- package/src/pages/mobile/action-sheet/DomActionSheet.vue +186 -0
- package/src/pages/mobile/bottom-nav/DomAppBottomNav.vue +94 -0
- package/src/pages/mobile/list-item/DomAppListItem.vue +90 -0
- package/src/pages/mobile/safe-area/DomSafeArea.vue +39 -0
- package/src/pages/mobile/shell/DomAppShell.vue +51 -0
- package/src/pages/mobile/stack/AppStackScreen.vue +63 -0
- package/src/pages/mobile/stack/DomAppStack.vue +711 -0
- package/src/pages/mobile/top-bar/DomAppTopBar.vue +67 -0
- package/src/pages/visual/alert/DomAlert.vue +363 -0
- package/src/pages/visual/avatar/DomAvatar.vue +110 -0
- package/src/pages/visual/avatar-stack/DomAvatarStack.vue +89 -0
- package/src/pages/visual/badge/DomBadge.vue +105 -0
- package/src/pages/visual/card/DomCard.vue +65 -0
- package/src/pages/visual/card-rail/DomCardRail.vue +99 -0
- package/src/pages/visual/empty-state/DomEmptyState.vue +88 -0
- package/src/pages/visual/progress/DomProgress.vue +212 -0
- package/src/pages/visual/progress/DomRadialProgress.vue +186 -0
- package/src/pages/visual/progress/DomSegmentedProgress.vue +350 -0
- package/src/pages/visual/skeleton/DomSkeleton.vue +174 -0
- package/src/pages/visual/spinner/DomSpinner.vue +122 -0
- package/src/pages/visual/status-pill/DomStatusPill.vue +75 -0
- package/src/pages/visual/text-diff/DomTextDiff.vue +577 -0
- package/src/pages/visual/text-diff/textDiff.js +300 -0
- package/src/style.css +446 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, nextTick, onMounted, ref, shallowRef, useId, watch } from 'vue';
|
|
3
|
+
import { resolveFloatingTeleportTarget } from '../../lib/vue/floatingTeleportTarget.js';
|
|
4
|
+
import { booleanProp, normalizeOption, searchableText } from '../_shared/options.js';
|
|
5
|
+
import DomField from '../field/DomField.vue';
|
|
6
|
+
import { fieldProps } from '../field/fieldProps.js';
|
|
7
|
+
import { useField } from '../field/useField.js';
|
|
8
|
+
|
|
9
|
+
const id = useId();
|
|
10
|
+
const panelId = `dom-select-panel-${id}`;
|
|
11
|
+
const listId = `dom-select-list-${id}`;
|
|
12
|
+
|
|
13
|
+
defineOptions({
|
|
14
|
+
__doc: {
|
|
15
|
+
name: 'Select',
|
|
16
|
+
tag: '<DomSelect>',
|
|
17
|
+
description: 'A styled select control with a closed trigger, floating listbox, keyboard navigation, and rich option slots.',
|
|
18
|
+
slots: [
|
|
19
|
+
{ name: 'value', payload: '{ option, value, label, placeholder }', description: 'Custom selected-value markup inside the trigger.' },
|
|
20
|
+
{ name: 'option', payload: '{ option, index, selected, optionId }', description: 'Custom option markup inside the floating list. optionId identifies the focusable option button for external floating UI.' },
|
|
21
|
+
{ name: 'group', payload: '{ group, options, index }', description: 'Custom heading markup for options that share a group.' },
|
|
22
|
+
],
|
|
23
|
+
events: [
|
|
24
|
+
{ name: 'update:modelValue', payload: '(value: string)', description: 'Emitted when the selected option changes.' },
|
|
25
|
+
{ name: 'select', payload: '({ option, value, label })', description: 'Emitted with the selected option record.' },
|
|
26
|
+
],
|
|
27
|
+
keyboard: [
|
|
28
|
+
{ keys: 'Enter / Space / ↓', action: 'Open the listbox.' },
|
|
29
|
+
{ keys: '↑ / ↓', action: 'Move active option.' },
|
|
30
|
+
{ keys: 'Type in search', action: 'Filter visible options when searchable is enabled.' },
|
|
31
|
+
{ keys: 'Enter / Space', action: 'Select active option.' },
|
|
32
|
+
{ keys: 'Esc / Tab', action: 'Close and return focus.' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const props = defineProps({
|
|
38
|
+
...fieldProps,
|
|
39
|
+
modelValue: {
|
|
40
|
+
type: [String, Number],
|
|
41
|
+
default: '',
|
|
42
|
+
_edit: { group: 'Control props', description: 'Selected option value.' },
|
|
43
|
+
},
|
|
44
|
+
options: {
|
|
45
|
+
type: Array,
|
|
46
|
+
required: true,
|
|
47
|
+
_edit: {
|
|
48
|
+
component: 'DomJsonListInput',
|
|
49
|
+
description: 'Available options. Set group for non-selectable section headings and use the option slot for rich rows.',
|
|
50
|
+
props: {
|
|
51
|
+
compact: true,
|
|
52
|
+
addLabel: '+ Add option',
|
|
53
|
+
schema: [
|
|
54
|
+
{ key: 'label', label: 'Label', placeholder: 'Option label', default: (index) => `Option ${index + 1}` },
|
|
55
|
+
{ key: 'value', label: 'Value', placeholder: 'option-value', default: (index) => `option-${index + 1}` },
|
|
56
|
+
{ key: 'description', label: 'Description', placeholder: 'Optional helper text' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
placeholder: {
|
|
62
|
+
type: String,
|
|
63
|
+
default: 'Select an option',
|
|
64
|
+
_edit: { group: 'Control props', description: 'Text shown when no option is selected.' },
|
|
65
|
+
},
|
|
66
|
+
placement: {
|
|
67
|
+
type: String,
|
|
68
|
+
default: 'bottom',
|
|
69
|
+
_edit: { group: 'Control props', options: ['bottom', 'top', 'right', 'left'], description: 'Preferred side before collision handling.' },
|
|
70
|
+
},
|
|
71
|
+
align: {
|
|
72
|
+
type: String,
|
|
73
|
+
default: 'left',
|
|
74
|
+
_edit: { group: 'Control props', options: ['left', 'right'], description: 'Horizontal panel alignment.' },
|
|
75
|
+
},
|
|
76
|
+
floatingMode: {
|
|
77
|
+
type: String,
|
|
78
|
+
default: 'viewport',
|
|
79
|
+
_edit: { group: 'Control props', options: ['viewport', 'anchor'], description: 'viewport keeps the list inside the browser; anchor keeps it attached while scrolling.' },
|
|
80
|
+
},
|
|
81
|
+
width: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: 'min-w-[14rem]',
|
|
84
|
+
_edit: { group: 'Control props', description: 'Tailwind width utility for the floating listbox.' },
|
|
85
|
+
},
|
|
86
|
+
optionTone: {
|
|
87
|
+
type: String,
|
|
88
|
+
default: 'accent',
|
|
89
|
+
_edit: { group: 'Control props', description: 'Theme token basename for hovered, focused, and selected options. For example, primary uses var(--primary) and var(--primary-fg).' },
|
|
90
|
+
},
|
|
91
|
+
searchable: {
|
|
92
|
+
type: [Boolean, String],
|
|
93
|
+
default: false,
|
|
94
|
+
_edit: { group: 'Control props', description: 'Show a search input inside the option panel while keeping select-style committed values.' },
|
|
95
|
+
},
|
|
96
|
+
searchPlaceholder: {
|
|
97
|
+
type: String,
|
|
98
|
+
default: 'Search options...',
|
|
99
|
+
_edit: { group: 'Control props', description: 'Placeholder shown inside the panel search input.' },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const emit = defineEmits(['update:modelValue', 'select', 'focus', 'blur']);
|
|
103
|
+
|
|
104
|
+
const root = ref(null);
|
|
105
|
+
const listbox = ref(null);
|
|
106
|
+
const searchInput = ref(null);
|
|
107
|
+
const searchQuery = ref('');
|
|
108
|
+
const isMounted = ref(false);
|
|
109
|
+
const teleportTarget = shallowRef(null);
|
|
110
|
+
const field = useField(props, emit, { idPrefix: 'dom-select' });
|
|
111
|
+
const isSearchable = computed(() => booleanProp(props.searchable, false));
|
|
112
|
+
|
|
113
|
+
const optionList = computed(() => props.options.map((option) => normalizeOption(option, {
|
|
114
|
+
stringifyValue: true,
|
|
115
|
+
allowEmptyValue: true,
|
|
116
|
+
})).filter(Boolean));
|
|
117
|
+
const filteredOptions = computed(() => {
|
|
118
|
+
if (!isSearchable.value) return optionList.value;
|
|
119
|
+
const needle = searchQuery.value.trim().toLowerCase();
|
|
120
|
+
if (!needle) return optionList.value;
|
|
121
|
+
return optionList.value.filter((option) => searchableText(option).includes(needle));
|
|
122
|
+
});
|
|
123
|
+
const optionSections = computed(buildOptionSections);
|
|
124
|
+
const hasOptionGroups = computed(() => optionSections.value.some((section) => section.group));
|
|
125
|
+
const selectedOption = computed(() => optionList.value.find((option) => String(option.value) === String(field.value.value)) || null);
|
|
126
|
+
const selectedLabel = computed(() => selectedOption.value?.label ?? '');
|
|
127
|
+
const selectedValue = computed(() => String(field.value.value ?? ''));
|
|
128
|
+
const triggerClasses = computed(() => [
|
|
129
|
+
'skin-input flex h-auto min-h-10 cursor-pointer items-center justify-between gap-3 py-2 pr-3 text-left',
|
|
130
|
+
(field.disabled.value || field.readOnly.value) && 'cursor-not-allowed opacity-60',
|
|
131
|
+
field.invalid.value && 'border-destructive focus:border-destructive focus:ring-destructive/25',
|
|
132
|
+
]);
|
|
133
|
+
const optionToneStyle = computed(() => {
|
|
134
|
+
const tone = normalizeThemeVariableName(props.optionTone);
|
|
135
|
+
return {
|
|
136
|
+
'--dom-select-option-active-bg': `var(--${tone}, var(--accent))`,
|
|
137
|
+
'--dom-select-option-active-fg': `var(--${tone}-fg, var(--accent-fg))`,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalize a theme token basename before using it to assemble CSS variable references.
|
|
143
|
+
*
|
|
144
|
+
* @param {string|null|undefined} value Theme token basename such as accent, primary, or --brand.
|
|
145
|
+
* @returns {string} Safe CSS custom property basename without the leading double dash.
|
|
146
|
+
*/
|
|
147
|
+
function normalizeThemeVariableName(value) {
|
|
148
|
+
const name = String(value || '').trim().replace(/^--/, '');
|
|
149
|
+
return /^[A-Za-z][A-Za-z0-9_-]*$/.test(name) ? name : 'accent';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build ordered option sections while preserving each option's filtered-list index.
|
|
154
|
+
*
|
|
155
|
+
* Options with the same non-empty group label share an accessible group. Ungrouped
|
|
156
|
+
* options remain in an unlabelled section and behave exactly like the flat list.
|
|
157
|
+
*
|
|
158
|
+
* @returns {Array<{ key: string, group: string|null, options: Array<Object>, entries: Array<{ option: Object, index: number }> }>} Ordered option sections.
|
|
159
|
+
*/
|
|
160
|
+
function buildOptionSections() {
|
|
161
|
+
const sections = [];
|
|
162
|
+
const sectionsByGroup = new Map();
|
|
163
|
+
|
|
164
|
+
filteredOptions.value.forEach((option, index) => {
|
|
165
|
+
const group = optionGroupLabel(option);
|
|
166
|
+
let section = sectionsByGroup.get(group);
|
|
167
|
+
|
|
168
|
+
if (!section) {
|
|
169
|
+
section = {
|
|
170
|
+
key: group === null ? 'ungrouped' : `group:${group}`,
|
|
171
|
+
group,
|
|
172
|
+
options: [],
|
|
173
|
+
entries: [],
|
|
174
|
+
};
|
|
175
|
+
sectionsByGroup.set(group, section);
|
|
176
|
+
sections.push(section);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
section.options.push(option);
|
|
180
|
+
section.entries.push({ option, index });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return sections;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Normalize an option's group into a visible heading label.
|
|
188
|
+
*
|
|
189
|
+
* @param {Object|null|undefined} option Normalized select option.
|
|
190
|
+
* @returns {string|null} Trimmed group label, or null for an ungrouped option.
|
|
191
|
+
*/
|
|
192
|
+
function optionGroupLabel(option) {
|
|
193
|
+
if (option?.group === undefined || option?.group === null) return null;
|
|
194
|
+
const group = String(option.group).trim();
|
|
195
|
+
return group || null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
onMounted(async () => {
|
|
199
|
+
teleportTarget.value = resolveFloatingTeleportTarget(root.value);
|
|
200
|
+
isMounted.value = true;
|
|
201
|
+
await nextTick();
|
|
202
|
+
await Promise.all([
|
|
203
|
+
import('../../lib/headless/dropdown.js'),
|
|
204
|
+
import('../../lib/headless/listbox.js'),
|
|
205
|
+
]);
|
|
206
|
+
listbox.value?.addEventListener('dom:select', onSelect);
|
|
207
|
+
root.value?.addEventListener('dom:open', onOpen);
|
|
208
|
+
root.value?.addEventListener('dom:close', resetSearch);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
watch(selectedValue, () => {
|
|
212
|
+
if (listbox.value && listbox.value.value !== selectedValue.value) listbox.value.value = selectedValue.value;
|
|
213
|
+
});
|
|
214
|
+
watch(isSearchable, () => {
|
|
215
|
+
if (!isSearchable.value) resetSearch();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Commit the selected option value and close the floating panel.
|
|
220
|
+
*
|
|
221
|
+
* @param {CustomEvent<{ value: string }>} event Selection event from the headless listbox.
|
|
222
|
+
* @returns {void}
|
|
223
|
+
*/
|
|
224
|
+
function onSelect(event) {
|
|
225
|
+
if (field.disabled.value || field.readOnly.value) return;
|
|
226
|
+
const value = event.detail.value;
|
|
227
|
+
const option = optionList.value.find((item) => String(item.value) === String(value)) || null;
|
|
228
|
+
field.onInput(value);
|
|
229
|
+
emit('select', {
|
|
230
|
+
option,
|
|
231
|
+
value,
|
|
232
|
+
label: option?.label ?? value,
|
|
233
|
+
});
|
|
234
|
+
close();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Reset transient search state when the select panel opens.
|
|
239
|
+
*
|
|
240
|
+
* @returns {void}
|
|
241
|
+
*/
|
|
242
|
+
function onOpen() {
|
|
243
|
+
resetSearch();
|
|
244
|
+
if (!isSearchable.value) return;
|
|
245
|
+
nextTick(() => searchInput.value?.focus());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Close the headless dropdown host.
|
|
250
|
+
*
|
|
251
|
+
* @returns {void}
|
|
252
|
+
*/
|
|
253
|
+
function close() {
|
|
254
|
+
if (root.value) root.value.open = false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear the panel search input and matching query state.
|
|
259
|
+
*
|
|
260
|
+
* @returns {void}
|
|
261
|
+
*/
|
|
262
|
+
function resetSearch() {
|
|
263
|
+
searchQuery.value = '';
|
|
264
|
+
if (searchInput.value) searchInput.value.value = '';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Select an option from keyboard-driven searchable interactions.
|
|
269
|
+
*
|
|
270
|
+
* @param {{ value: string, disabled?: boolean }|null|undefined} option Normalized option record.
|
|
271
|
+
* @returns {void}
|
|
272
|
+
*/
|
|
273
|
+
function selectOption(option) {
|
|
274
|
+
if (!option || option.disabled) return;
|
|
275
|
+
onSelect({
|
|
276
|
+
detail: { value: option.value },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Move focus to the first or last visible option in the listbox.
|
|
282
|
+
*
|
|
283
|
+
* @param {'first'|'last'} [position='first'] Position to focus.
|
|
284
|
+
* @returns {void}
|
|
285
|
+
*/
|
|
286
|
+
function focusVisibleOption(position = 'first') {
|
|
287
|
+
const options = Array.from(listbox.value?.querySelectorAll('[role="option"]:not([disabled])') || [])
|
|
288
|
+
.filter((option) => option.getAttribute('aria-disabled') !== 'true');
|
|
289
|
+
if (!options.length) return;
|
|
290
|
+
const option = position === 'last' ? options[options.length - 1] : options[0];
|
|
291
|
+
option.focus();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Handle search-box keyboard shortcuts without leaking navigation to the listbox wrapper.
|
|
296
|
+
*
|
|
297
|
+
* @param {KeyboardEvent} event Search input keydown event.
|
|
298
|
+
* @returns {void}
|
|
299
|
+
*/
|
|
300
|
+
function onSearchKeydown(event) {
|
|
301
|
+
if (event.key === 'ArrowDown') {
|
|
302
|
+
event.preventDefault();
|
|
303
|
+
event.stopPropagation();
|
|
304
|
+
focusVisibleOption('first');
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (event.key === 'ArrowUp') {
|
|
308
|
+
event.preventDefault();
|
|
309
|
+
event.stopPropagation();
|
|
310
|
+
focusVisibleOption('last');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (event.key === 'Enter') {
|
|
314
|
+
event.preventDefault();
|
|
315
|
+
event.stopPropagation();
|
|
316
|
+
selectOption(filteredOptions.value[0]);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (event.key === 'Home' || event.key === 'End') {
|
|
320
|
+
event.stopPropagation();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
</script>
|
|
324
|
+
|
|
325
|
+
<template>
|
|
326
|
+
<DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
|
|
327
|
+
<input
|
|
328
|
+
v-if="field.htmlName.value"
|
|
329
|
+
type="hidden"
|
|
330
|
+
:name="field.htmlName.value"
|
|
331
|
+
:value="field.value.value ?? ''"
|
|
332
|
+
/>
|
|
333
|
+
<dom-dropdown
|
|
334
|
+
ref="root"
|
|
335
|
+
:data-menu-id="panelId"
|
|
336
|
+
:align="align"
|
|
337
|
+
:placement="placement"
|
|
338
|
+
:floating-mode="floatingMode"
|
|
339
|
+
:offset="4"
|
|
340
|
+
:data-disabled="field.disabled.value || field.readOnly.value ? '' : null"
|
|
341
|
+
class="relative block"
|
|
342
|
+
>
|
|
343
|
+
<button
|
|
344
|
+
:id="field.id.value"
|
|
345
|
+
slot="trigger"
|
|
346
|
+
type="button"
|
|
347
|
+
:class="triggerClasses"
|
|
348
|
+
:disabled="field.disabled.value || field.readOnly.value || undefined"
|
|
349
|
+
:aria-label="label || placeholder"
|
|
350
|
+
:aria-describedby="field.describedBy.value || undefined"
|
|
351
|
+
:aria-invalid="field.invalid.value || undefined"
|
|
352
|
+
:aria-errormessage="field.errorId.value || undefined"
|
|
353
|
+
:aria-controls="panelId"
|
|
354
|
+
@focus="field.onFocus"
|
|
355
|
+
@blur="field.onBlur"
|
|
356
|
+
>
|
|
357
|
+
<span class="min-w-0 flex-1 truncate">
|
|
358
|
+
<slot
|
|
359
|
+
name="value"
|
|
360
|
+
:option="selectedOption"
|
|
361
|
+
:value="selectedValue"
|
|
362
|
+
:label="selectedLabel"
|
|
363
|
+
:placeholder="placeholder"
|
|
364
|
+
>
|
|
365
|
+
<span :class="selectedOption ? 'text-canvas-fg' : 'text-muted-fg'">
|
|
366
|
+
{{ selectedOption ? selectedLabel : placeholder }}
|
|
367
|
+
</span>
|
|
368
|
+
</slot>
|
|
369
|
+
</span>
|
|
370
|
+
<svg viewBox="0 0 20 20" class="size-4 shrink-0 text-muted-fg" fill="none" aria-hidden="true">
|
|
371
|
+
<path d="M6 8l4 4 4-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
|
|
372
|
+
</svg>
|
|
373
|
+
</button>
|
|
374
|
+
<Teleport :to="teleportTarget || 'body'" :disabled="!isMounted || !teleportTarget">
|
|
375
|
+
<div
|
|
376
|
+
:id="panelId"
|
|
377
|
+
popover="manual"
|
|
378
|
+
data-composite-panel="true"
|
|
379
|
+
data-haspopup="listbox"
|
|
380
|
+
class="dom-dropdown-menu dom-floating-transition skin-popover z-50 flex max-h-[min(20rem,var(--dom-floating-available-height))] flex-col overflow-hidden rounded-2xl p-0"
|
|
381
|
+
:class="width"
|
|
382
|
+
:style="optionToneStyle"
|
|
383
|
+
>
|
|
384
|
+
<div v-if="isSearchable" class="border-b border-border p-2">
|
|
385
|
+
<input
|
|
386
|
+
ref="searchInput"
|
|
387
|
+
v-model="searchQuery"
|
|
388
|
+
type="search"
|
|
389
|
+
data-autofocus
|
|
390
|
+
:placeholder="searchPlaceholder"
|
|
391
|
+
:aria-label="label ? `Search ${label}` : 'Search options'"
|
|
392
|
+
:aria-controls="listId"
|
|
393
|
+
class="skin-input h-9 w-full rounded-full px-3 text-sm focus:ring-ring/60"
|
|
394
|
+
@focus="field.onFocus"
|
|
395
|
+
@blur="field.onBlur"
|
|
396
|
+
@keydown="onSearchKeydown"
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
<dom-listbox
|
|
400
|
+
:id="listId"
|
|
401
|
+
ref="listbox"
|
|
402
|
+
:value="selectedValue"
|
|
403
|
+
class="min-h-0 overflow-auto"
|
|
404
|
+
:class="hasOptionGroups ? 'px-1 pb-1' : 'p-1'"
|
|
405
|
+
>
|
|
406
|
+
<div
|
|
407
|
+
v-for="(section, sectionIndex) in optionSections"
|
|
408
|
+
:key="section.key"
|
|
409
|
+
:role="section.group ? 'group' : null"
|
|
410
|
+
:aria-labelledby="section.group ? `${listId}-group-${sectionIndex}` : null"
|
|
411
|
+
:class="section.group ? 'pb-1 last:pb-0' : 'contents'"
|
|
412
|
+
>
|
|
413
|
+
<div
|
|
414
|
+
v-if="section.group"
|
|
415
|
+
class="sticky top-0 z-10 bg-[var(--skin-popover-bg,var(--canvas))]"
|
|
416
|
+
>
|
|
417
|
+
<div
|
|
418
|
+
:id="`${listId}-group-${sectionIndex}`"
|
|
419
|
+
class="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-[0.12em] text-muted-fg"
|
|
420
|
+
>
|
|
421
|
+
<slot name="group" :group="section.group" :options="section.options" :index="sectionIndex">
|
|
422
|
+
{{ section.group }}
|
|
423
|
+
</slot>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="mx-3 mb-1 border-t border-border" aria-hidden="true"></div>
|
|
426
|
+
</div>
|
|
427
|
+
<button
|
|
428
|
+
v-for="entry in section.entries"
|
|
429
|
+
:key="entry.option.value ?? entry.index"
|
|
430
|
+
:id="`${listId}-option-${entry.index}`"
|
|
431
|
+
role="option"
|
|
432
|
+
type="button"
|
|
433
|
+
:data-value="entry.option.value"
|
|
434
|
+
:disabled="entry.option.disabled || undefined"
|
|
435
|
+
class="dom-select-option flex w-full cursor-pointer items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm text-canvas-fg outline-none transition disabled:cursor-not-allowed disabled:opacity-50"
|
|
436
|
+
>
|
|
437
|
+
<span class="min-w-0 flex-1">
|
|
438
|
+
<slot
|
|
439
|
+
name="option"
|
|
440
|
+
:option="entry.option"
|
|
441
|
+
:index="entry.index"
|
|
442
|
+
:selected="String(entry.option.value) === selectedValue"
|
|
443
|
+
:option-id="`${listId}-option-${entry.index}`"
|
|
444
|
+
>
|
|
445
|
+
<span class="block font-medium">{{ entry.option.label }}</span>
|
|
446
|
+
<span v-if="entry.option.description" class="dom-select-option-description mt-0.5 block text-xs text-muted-fg">{{ entry.option.description }}</span>
|
|
447
|
+
</slot>
|
|
448
|
+
</span>
|
|
449
|
+
<svg
|
|
450
|
+
v-if="String(entry.option.value) === selectedValue"
|
|
451
|
+
viewBox="0 0 20 20"
|
|
452
|
+
class="size-4 shrink-0"
|
|
453
|
+
fill="none"
|
|
454
|
+
aria-hidden="true"
|
|
455
|
+
>
|
|
456
|
+
<path d="M4.5 10.5 8 14l7.5-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
457
|
+
</svg>
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
<div v-if="isSearchable && !filteredOptions.length" class="px-3 py-6 text-center text-sm text-muted-fg">
|
|
461
|
+
No options found
|
|
462
|
+
</div>
|
|
463
|
+
</dom-listbox>
|
|
464
|
+
</div>
|
|
465
|
+
</Teleport>
|
|
466
|
+
</dom-dropdown>
|
|
467
|
+
</DomField>
|
|
468
|
+
</template>
|
|
469
|
+
|
|
470
|
+
<style scoped>
|
|
471
|
+
.dom-select-option:not(:disabled):is(:hover, :focus, [aria-selected="true"]) {
|
|
472
|
+
background-color: var(--dom-select-option-active-bg, var(--accent));
|
|
473
|
+
color: var(--dom-select-option-active-fg, var(--accent-fg));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.dom-select-option:not(:disabled):is(:hover, :focus, [aria-selected="true"]) .dom-select-option-description {
|
|
477
|
+
color: var(--dom-select-option-active-fg, var(--accent-fg));
|
|
478
|
+
}
|
|
479
|
+
</style>
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
3
|
+
import DomField from '../field/DomField.vue';
|
|
4
|
+
import DomButton from '../../components/button/DomButton.vue';
|
|
5
|
+
import { fieldProps } from '../field/fieldProps.js';
|
|
6
|
+
import { useField } from '../field/useField.js';
|
|
7
|
+
|
|
8
|
+
const props = defineProps({
|
|
9
|
+
...fieldProps,
|
|
10
|
+
modelValue: {
|
|
11
|
+
default: null,
|
|
12
|
+
_edit: { group: 'Control props', description: 'Selected value.' },
|
|
13
|
+
},
|
|
14
|
+
options: {
|
|
15
|
+
type: Array,
|
|
16
|
+
default: () => [],
|
|
17
|
+
_edit: {
|
|
18
|
+
group: 'Control props',
|
|
19
|
+
component: 'DomJsonListInput',
|
|
20
|
+
description: 'Options to render as buttons.',
|
|
21
|
+
props: {
|
|
22
|
+
compact: true,
|
|
23
|
+
addLabel: '+ Add option',
|
|
24
|
+
schema: [
|
|
25
|
+
{ key: 'label', label: 'Label', placeholder: 'Option label', default: (index) => `Option ${index + 1}` },
|
|
26
|
+
{ key: 'value', label: 'Value', placeholder: 'option-value', default: (index) => `option-${index + 1}` },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const emit = defineEmits(['update:modelValue', 'focus', 'blur']);
|
|
33
|
+
const field = useField(props, emit, { idPrefix: 'dom-select-input' });
|
|
34
|
+
const groupElement = ref(null);
|
|
35
|
+
const wrapped = ref(false);
|
|
36
|
+
let resizeObserver;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolves the visible label for an option.
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} option Select option.
|
|
42
|
+
* @returns {unknown} Visible option label.
|
|
43
|
+
*/
|
|
44
|
+
function labelOf(option) {
|
|
45
|
+
return option && typeof option === 'object' ? (option.label ?? option.value) : option;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolves the emitted value for an option.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} option Select option.
|
|
52
|
+
* @returns {unknown} Stable option value.
|
|
53
|
+
*/
|
|
54
|
+
function valueOf(option) {
|
|
55
|
+
return option && typeof option === 'object' ? (option.value ?? option.label) : option;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Selects an enabled, writable option.
|
|
60
|
+
*
|
|
61
|
+
* @param {unknown} value Option value.
|
|
62
|
+
* @returns {void}
|
|
63
|
+
*/
|
|
64
|
+
function select(value) {
|
|
65
|
+
if (field.disabled.value || field.readOnly.value) return;
|
|
66
|
+
field.onInput(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Detects whether the rendered option buttons occupy more than one row.
|
|
71
|
+
*
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
function measureWrapping() {
|
|
75
|
+
const buttons = [...(groupElement.value?.children || [])];
|
|
76
|
+
const firstTop = buttons[0]?.offsetTop;
|
|
77
|
+
wrapped.value = firstTop != null && buttons.some((button) => button.offsetTop !== firstTop);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Starts observing the option group so its joined styling can adapt to wrapping.
|
|
82
|
+
*
|
|
83
|
+
* @returns {void}
|
|
84
|
+
*/
|
|
85
|
+
function startWrappingObserver() {
|
|
86
|
+
nextTick(measureWrapping);
|
|
87
|
+
if (typeof ResizeObserver === 'undefined' || !groupElement.value) return;
|
|
88
|
+
resizeObserver = new ResizeObserver(measureWrapping);
|
|
89
|
+
resizeObserver.observe(groupElement.value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Stops the option-group resize observer during component teardown.
|
|
94
|
+
*
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
function stopWrappingObserver() {
|
|
98
|
+
resizeObserver?.disconnect();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds option classes for joined and wrapped group layouts.
|
|
103
|
+
*
|
|
104
|
+
* @param {number} index Option index.
|
|
105
|
+
* @returns {Array<string>} Vue class list for the option button.
|
|
106
|
+
*/
|
|
107
|
+
function optionClasses(index) {
|
|
108
|
+
if (wrapped.value) return ['ml-0 rounded-full'];
|
|
109
|
+
return [
|
|
110
|
+
index > 0 ? '-ml-px' : '',
|
|
111
|
+
index === 0 ? 'rounded-l-full' : 'rounded-none',
|
|
112
|
+
index === props.options.length - 1 ? 'rounded-r-full' : 'rounded-none',
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onMounted(startWrappingObserver);
|
|
117
|
+
onBeforeUnmount(stopWrappingObserver);
|
|
118
|
+
</script>
|
|
119
|
+
|
|
120
|
+
<template>
|
|
121
|
+
<DomField v-bind="field.fieldAttrs.value" :chrome="chrome">
|
|
122
|
+
<input
|
|
123
|
+
v-if="field.htmlName.value"
|
|
124
|
+
type="hidden"
|
|
125
|
+
:name="field.htmlName.value"
|
|
126
|
+
:value="field.value.value ?? ''"
|
|
127
|
+
/>
|
|
128
|
+
<div
|
|
129
|
+
:id="field.id.value"
|
|
130
|
+
ref="groupElement"
|
|
131
|
+
role="group"
|
|
132
|
+
:aria-label="label || undefined"
|
|
133
|
+
:aria-describedby="field.describedBy.value || undefined"
|
|
134
|
+
:aria-invalid="field.invalid.value || undefined"
|
|
135
|
+
:aria-errormessage="field.errorId.value || undefined"
|
|
136
|
+
:data-wrapped="wrapped || undefined"
|
|
137
|
+
class="flex flex-wrap"
|
|
138
|
+
:class="wrapped ? 'gap-1' : 'gap-0'"
|
|
139
|
+
>
|
|
140
|
+
<DomButton
|
|
141
|
+
v-for="(opt, i) in options"
|
|
142
|
+
:key="valueOf(opt)"
|
|
143
|
+
type="button"
|
|
144
|
+
size="sm"
|
|
145
|
+
:variant="field.value.value === valueOf(opt) ? 'primary' : 'secondary'"
|
|
146
|
+
:disabled="field.disabled.value"
|
|
147
|
+
:aria-pressed="field.value.value === valueOf(opt)"
|
|
148
|
+
@click="select(valueOf(opt))"
|
|
149
|
+
@focus="field.onFocus"
|
|
150
|
+
@blur="field.onBlur"
|
|
151
|
+
:class="optionClasses(i)"
|
|
152
|
+
>{{ labelOf(opt) }}</DomButton>
|
|
153
|
+
</div>
|
|
154
|
+
</DomField>
|
|
155
|
+
</template>
|