@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,533 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {object} FilterBarOption
|
|
4
|
+
* @property {string} label Human-readable option label.
|
|
5
|
+
* @property {string|number|boolean} value Stable query value.
|
|
6
|
+
* @property {boolean} [disabled] Disable this option.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} FilterBarDefinition
|
|
11
|
+
* @property {string} key Stable filter and API query key.
|
|
12
|
+
* @property {string} [label] Human-readable filter label.
|
|
13
|
+
* @property {'text'|'select'|'date'|'number'|'boolean'} [type] Built-in control type.
|
|
14
|
+
* @property {string} [placeholder] Empty control label.
|
|
15
|
+
* @property {Array<FilterBarOption|string|number|boolean>} [options] Select options.
|
|
16
|
+
* @property {string} [width] CSS width applied to the built-in control.
|
|
17
|
+
* @property {boolean} [disabled] Disable this filter control.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} FilterBarQuery
|
|
22
|
+
* @property {string} search Normalized search text.
|
|
23
|
+
* @property {Record<string, unknown>} filters Active logical filter values.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const filterBarProps = {
|
|
27
|
+
definitions: {
|
|
28
|
+
type: Array,
|
|
29
|
+
default: () => [],
|
|
30
|
+
_edit: { typeRef: 'Array<FilterBarDefinition>', description: 'Logical filter definitions rendered as controlled query inputs.' },
|
|
31
|
+
},
|
|
32
|
+
filters: {
|
|
33
|
+
type: Object,
|
|
34
|
+
default: () => ({}),
|
|
35
|
+
_edit: { description: 'Controlled active filter values keyed by definition key.' },
|
|
36
|
+
},
|
|
37
|
+
search: {
|
|
38
|
+
type: String,
|
|
39
|
+
default: '',
|
|
40
|
+
_edit: { description: 'Controlled free-text search value.' },
|
|
41
|
+
},
|
|
42
|
+
searchable: {
|
|
43
|
+
type: Boolean,
|
|
44
|
+
default: true,
|
|
45
|
+
_edit: { description: 'Show the built-in search input.' },
|
|
46
|
+
},
|
|
47
|
+
searchPlaceholder: {
|
|
48
|
+
type: String,
|
|
49
|
+
default: 'Search records',
|
|
50
|
+
_edit: { group: 'Labels', description: 'Placeholder for the built-in search input.' },
|
|
51
|
+
},
|
|
52
|
+
clearLabel: {
|
|
53
|
+
type: String,
|
|
54
|
+
default: 'Clear filters',
|
|
55
|
+
_edit: { group: 'Labels', description: 'Label for clearing the complete query.' },
|
|
56
|
+
},
|
|
57
|
+
ariaLabel: {
|
|
58
|
+
type: String,
|
|
59
|
+
default: 'Filter records',
|
|
60
|
+
_edit: { group: 'Labels', description: 'Accessible label for the filter controls.' },
|
|
61
|
+
},
|
|
62
|
+
density: {
|
|
63
|
+
type: String,
|
|
64
|
+
default: 'comfortable',
|
|
65
|
+
_edit: { options: ['compact', 'comfortable'], group: 'Layout', description: 'Spacing and control density.' },
|
|
66
|
+
},
|
|
67
|
+
disabled: {
|
|
68
|
+
type: Boolean,
|
|
69
|
+
default: false,
|
|
70
|
+
_edit: { group: 'State', description: 'Disable all built-in query controls.' },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<script setup>
|
|
76
|
+
import { computed, ref, watch } from 'vue';
|
|
77
|
+
|
|
78
|
+
defineOptions({
|
|
79
|
+
__doc: {
|
|
80
|
+
name: 'Filter bar',
|
|
81
|
+
tag: '<DomFilterBar>',
|
|
82
|
+
description: 'A controlled, transport-free query bar for application-owned search and logical filter values.',
|
|
83
|
+
icon: 'M4 6h16M7 12h10M10 18h4M8 6v3M15 12v3M12 18v2',
|
|
84
|
+
events: [
|
|
85
|
+
{ name: 'update:filters', payload: 'Record<string, unknown>', description: 'Fired when one logical filter value changes or is cleared.' },
|
|
86
|
+
{ name: 'update:search', payload: 'string', description: 'Fired when the free-text search value changes.' },
|
|
87
|
+
{ name: 'query-change', payload: 'FilterBarQuery', typeRef: 'FilterBarQuery', description: 'Fired with the complete normalized query after any control changes.' },
|
|
88
|
+
{ name: 'clear', payload: 'FilterBarQuery', typeRef: 'FilterBarQuery', description: 'Fired after the complete query is cleared.' },
|
|
89
|
+
],
|
|
90
|
+
slots: [
|
|
91
|
+
{ name: 'leading', payload: '{ query }', description: 'Content before the built-in search and filters.' },
|
|
92
|
+
{ name: 'filter', payload: '{ definition, value, setValue, clear }', description: 'Replace the control for each filter definition.' },
|
|
93
|
+
{ name: 'trailing', payload: '{ query, clear }', description: 'Application actions after the built-in controls.' },
|
|
94
|
+
{ name: 'active-filter', payload: '{ definition, value, label, clear }', description: 'Replace an active filter chip.' },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const props = defineProps(filterBarProps);
|
|
100
|
+
const emit = defineEmits(['update:filters', 'update:search', 'query-change', 'clear']);
|
|
101
|
+
|
|
102
|
+
const internalFilters = ref(cloneFilters(props.filters));
|
|
103
|
+
const internalSearch = ref(String(props.search || ''));
|
|
104
|
+
const normalizedDefinitions = computed(getNormalizedDefinitions);
|
|
105
|
+
const query = computed(getQuery);
|
|
106
|
+
const activeEntries = computed(getActiveEntries);
|
|
107
|
+
const hasActiveQuery = computed(getHasActiveQuery);
|
|
108
|
+
const rootClass = computed(getRootClass);
|
|
109
|
+
|
|
110
|
+
watch(() => props.filters, syncFiltersFromProps, { deep: true });
|
|
111
|
+
watch(() => props.search, syncSearchFromProps);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Synchronizes controlled filter values from the parent.
|
|
115
|
+
*
|
|
116
|
+
* @param {Record<string, unknown>} value Controlled filter map.
|
|
117
|
+
* @returns {void}
|
|
118
|
+
*/
|
|
119
|
+
function syncFiltersFromProps(value) {
|
|
120
|
+
internalFilters.value = cloneFilters(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Synchronizes controlled search text from the parent.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} value Controlled search value.
|
|
127
|
+
* @returns {void}
|
|
128
|
+
*/
|
|
129
|
+
function syncSearchFromProps(value) {
|
|
130
|
+
internalSearch.value = String(value || '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalizes every logical filter definition.
|
|
135
|
+
*
|
|
136
|
+
* @returns {Array<FilterBarDefinition>} Safe filter definitions.
|
|
137
|
+
*/
|
|
138
|
+
function getNormalizedDefinitions() {
|
|
139
|
+
return (Array.isArray(props.definitions) ? props.definitions : [])
|
|
140
|
+
.filter(hasDefinitionKey)
|
|
141
|
+
.map(normalizeDefinition);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Reports whether a filter definition has a usable key.
|
|
146
|
+
*
|
|
147
|
+
* @param {FilterBarDefinition} definition Candidate filter definition.
|
|
148
|
+
* @returns {boolean} True when a logical key exists.
|
|
149
|
+
*/
|
|
150
|
+
function hasDefinitionKey(definition) {
|
|
151
|
+
return Boolean(definition && String(definition.key || '').trim());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Builds defaults for one filter definition.
|
|
156
|
+
*
|
|
157
|
+
* @param {FilterBarDefinition} definition Source definition.
|
|
158
|
+
* @returns {FilterBarDefinition} Normalized definition.
|
|
159
|
+
*/
|
|
160
|
+
function normalizeDefinition(definition) {
|
|
161
|
+
const key = String(definition.key).trim();
|
|
162
|
+
return {
|
|
163
|
+
...definition,
|
|
164
|
+
key,
|
|
165
|
+
label: definition.label || labelFromKey(key),
|
|
166
|
+
type: ['text', 'select', 'date', 'number', 'boolean'].includes(definition.type) ? definition.type : 'select',
|
|
167
|
+
options: normalizeOptions(definition.options),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Normalizes select options while preserving their original values.
|
|
173
|
+
*
|
|
174
|
+
* @param {Array<FilterBarOption|string|number|boolean>} options Source options.
|
|
175
|
+
* @returns {Array<FilterBarOption>} Normalized option rows.
|
|
176
|
+
*/
|
|
177
|
+
function normalizeOptions(options) {
|
|
178
|
+
return (Array.isArray(options) ? options : []).map(normalizeOption);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Normalizes one primitive or object option.
|
|
183
|
+
*
|
|
184
|
+
* @param {FilterBarOption|string|number|boolean} option Source option.
|
|
185
|
+
* @returns {FilterBarOption} Normalized option.
|
|
186
|
+
*/
|
|
187
|
+
function normalizeOption(option) {
|
|
188
|
+
if (option && typeof option === 'object') {
|
|
189
|
+
return {
|
|
190
|
+
...option,
|
|
191
|
+
label: option.label ?? String(option.value ?? ''),
|
|
192
|
+
value: option.value ?? option.label ?? '',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { label: String(option), value: option };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns the current complete query contract.
|
|
200
|
+
*
|
|
201
|
+
* @returns {FilterBarQuery} Normalized controlled query.
|
|
202
|
+
*/
|
|
203
|
+
function getQuery() {
|
|
204
|
+
return {
|
|
205
|
+
search: internalSearch.value.trim(),
|
|
206
|
+
filters: cloneFilters(internalFilters.value),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Returns active filter rows for removable summary chips.
|
|
212
|
+
*
|
|
213
|
+
* @returns {Array<{ definition: FilterBarDefinition, value: unknown, label: string }>} Active filter entries.
|
|
214
|
+
*/
|
|
215
|
+
function getActiveEntries() {
|
|
216
|
+
return normalizedDefinitions.value
|
|
217
|
+
.filter(isDefinitionActive)
|
|
218
|
+
.map(createActiveEntry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Reports whether a definition currently has an active value.
|
|
223
|
+
*
|
|
224
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
225
|
+
* @returns {boolean} True when the value should join the API query.
|
|
226
|
+
*/
|
|
227
|
+
function isDefinitionActive(definition) {
|
|
228
|
+
return isActiveValue(internalFilters.value[definition.key]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Creates an active filter summary record.
|
|
233
|
+
*
|
|
234
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
235
|
+
* @returns {{ definition: FilterBarDefinition, value: unknown, label: string }} Active entry.
|
|
236
|
+
*/
|
|
237
|
+
function createActiveEntry(definition) {
|
|
238
|
+
const value = internalFilters.value[definition.key];
|
|
239
|
+
return { definition, value, label: valueLabel(definition, value) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Reports whether search text or any logical filter is active.
|
|
244
|
+
*
|
|
245
|
+
* @returns {boolean} True when the query can be cleared.
|
|
246
|
+
*/
|
|
247
|
+
function getHasActiveQuery() {
|
|
248
|
+
return Boolean(internalSearch.value.trim() || activeEntries.value.length);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Builds density-aware container classes.
|
|
253
|
+
*
|
|
254
|
+
* @returns {string} Filter bar classes.
|
|
255
|
+
*/
|
|
256
|
+
function getRootClass() {
|
|
257
|
+
return props.density === 'compact' ? 'gap-2 p-2' : 'gap-3 p-3';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Updates the free-text search and emits the full query.
|
|
262
|
+
*
|
|
263
|
+
* @param {Event} event Native input event.
|
|
264
|
+
* @returns {void}
|
|
265
|
+
*/
|
|
266
|
+
function updateSearch(event) {
|
|
267
|
+
const value = String(event.target?.value || '');
|
|
268
|
+
internalSearch.value = value;
|
|
269
|
+
emit('update:search', value);
|
|
270
|
+
emitQueryChange();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Reads a native control value using its filter definition.
|
|
275
|
+
*
|
|
276
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
277
|
+
* @param {Event} event Native input or change event.
|
|
278
|
+
* @returns {void}
|
|
279
|
+
*/
|
|
280
|
+
function updateFromControl(definition, event) {
|
|
281
|
+
const rawValue = event.target?.value ?? '';
|
|
282
|
+
setFilterValue(definition, controlValue(definition, rawValue));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Converts native string input back into an API-facing logical value.
|
|
287
|
+
*
|
|
288
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
289
|
+
* @param {unknown} rawValue Native control value.
|
|
290
|
+
* @returns {unknown} Logical filter value.
|
|
291
|
+
*/
|
|
292
|
+
function controlValue(definition, rawValue) {
|
|
293
|
+
if (rawValue === '') return '';
|
|
294
|
+
if (definition.type === 'number') {
|
|
295
|
+
const number = Number(rawValue);
|
|
296
|
+
return Number.isFinite(number) ? number : rawValue;
|
|
297
|
+
}
|
|
298
|
+
if (definition.type === 'boolean') return rawValue === 'true';
|
|
299
|
+
if (definition.type === 'select') {
|
|
300
|
+
const option = matchingOption(definition.options, rawValue);
|
|
301
|
+
return option ? option.value : rawValue;
|
|
302
|
+
}
|
|
303
|
+
return rawValue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Finds the option matching a native control value.
|
|
308
|
+
*
|
|
309
|
+
* @param {Array<FilterBarOption>} options Normalized options.
|
|
310
|
+
* @param {unknown} value Candidate logical value.
|
|
311
|
+
* @returns {FilterBarOption|undefined} Matching option when one exists.
|
|
312
|
+
*/
|
|
313
|
+
function matchingOption(options, value) {
|
|
314
|
+
for (const option of options) {
|
|
315
|
+
if (String(option.value) === String(value)) return option;
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Updates one logical filter and emits controlled state.
|
|
322
|
+
*
|
|
323
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
324
|
+
* @param {unknown} value Next logical value.
|
|
325
|
+
* @returns {void}
|
|
326
|
+
*/
|
|
327
|
+
function setFilterValue(definition, value) {
|
|
328
|
+
const next = cloneFilters(internalFilters.value);
|
|
329
|
+
if (isActiveValue(value)) next[definition.key] = value;
|
|
330
|
+
else delete next[definition.key];
|
|
331
|
+
internalFilters.value = next;
|
|
332
|
+
emit('update:filters', cloneFilters(next));
|
|
333
|
+
emitQueryChange();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Clears one logical filter.
|
|
338
|
+
*
|
|
339
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
340
|
+
* @returns {void}
|
|
341
|
+
*/
|
|
342
|
+
function clearFilter(definition) {
|
|
343
|
+
setFilterValue(definition, '');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Clears search and every filter value.
|
|
348
|
+
*
|
|
349
|
+
* @returns {void}
|
|
350
|
+
*/
|
|
351
|
+
function clearQuery() {
|
|
352
|
+
internalSearch.value = '';
|
|
353
|
+
internalFilters.value = {};
|
|
354
|
+
emit('update:search', '');
|
|
355
|
+
emit('update:filters', {});
|
|
356
|
+
const cleared = { search: '', filters: {} };
|
|
357
|
+
emit('query-change', cleared);
|
|
358
|
+
emit('clear', cleared);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Emits a cloned query after local state has changed.
|
|
363
|
+
*
|
|
364
|
+
* @returns {void}
|
|
365
|
+
*/
|
|
366
|
+
function emitQueryChange() {
|
|
367
|
+
emit('query-change', getQuery());
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Returns a display label for one active filter value.
|
|
372
|
+
*
|
|
373
|
+
* @param {FilterBarDefinition} definition Filter definition.
|
|
374
|
+
* @param {unknown} value Active logical value.
|
|
375
|
+
* @returns {string} Human-readable value.
|
|
376
|
+
*/
|
|
377
|
+
function valueLabel(definition, value) {
|
|
378
|
+
if (definition.type === 'boolean') return value ? 'Yes' : 'No';
|
|
379
|
+
const option = matchingOption(definition.options, value);
|
|
380
|
+
return option?.label || String(value ?? '');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Converts a logical key into a readable label.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} key Logical filter key.
|
|
387
|
+
* @returns {string} Human-readable label.
|
|
388
|
+
*/
|
|
389
|
+
function labelFromKey(key) {
|
|
390
|
+
return String(key)
|
|
391
|
+
.replace(/[_-]+/g, ' ')
|
|
392
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
393
|
+
.replace(/\b\w/g, uppercaseCharacter);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Uppercases one matched word-leading character.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} character Matched character.
|
|
400
|
+
* @returns {string} Uppercase character.
|
|
401
|
+
*/
|
|
402
|
+
function uppercaseCharacter(character) {
|
|
403
|
+
return character.toUpperCase();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Reports whether a value should be included in the active filter map.
|
|
408
|
+
*
|
|
409
|
+
* @param {unknown} value Candidate filter value.
|
|
410
|
+
* @returns {boolean} True for meaningful values, including false and zero.
|
|
411
|
+
*/
|
|
412
|
+
function isActiveValue(value) {
|
|
413
|
+
if (value === null || value === undefined || value === '') return false;
|
|
414
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Clones a filter map so emitted state remains application-owned.
|
|
420
|
+
*
|
|
421
|
+
* @param {Record<string, unknown>} filters Source filter map.
|
|
422
|
+
* @returns {Record<string, unknown>} Cloned filter values.
|
|
423
|
+
*/
|
|
424
|
+
function cloneFilters(filters) {
|
|
425
|
+
const source = filters && typeof filters === 'object' ? filters : {};
|
|
426
|
+
return Object.fromEntries(Object.entries(source).map(cloneFilterEntry));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Clones one filter entry, including array values.
|
|
431
|
+
*
|
|
432
|
+
* @param {[string, unknown]} entry Filter key and value.
|
|
433
|
+
* @returns {[string, unknown]} Cloned filter entry.
|
|
434
|
+
*/
|
|
435
|
+
function cloneFilterEntry(entry) {
|
|
436
|
+
const [key, value] = entry;
|
|
437
|
+
return [key, Array.isArray(value) ? [...value] : value];
|
|
438
|
+
}
|
|
439
|
+
</script>
|
|
440
|
+
|
|
441
|
+
<template>
|
|
442
|
+
<section class="rounded-lg border border-border bg-canvas text-canvas-fg shadow-sm" role="search" :aria-label="ariaLabel">
|
|
443
|
+
<div class="flex min-w-0 flex-wrap items-end" :class="rootClass">
|
|
444
|
+
<slot name="leading" :query="query" />
|
|
445
|
+
|
|
446
|
+
<label v-if="searchable" class="min-w-[12rem] flex-1 sm:max-w-xs">
|
|
447
|
+
<span class="mb-1 block text-xs font-semibold text-muted-fg">Search</span>
|
|
448
|
+
<span class="relative block">
|
|
449
|
+
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-fg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
450
|
+
<path d="m21 21-4.3-4.3M10.8 18a7.2 7.2 0 1 1 0-14.4 7.2 7.2 0 0 1 0 14.4Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
|
451
|
+
</svg>
|
|
452
|
+
<input
|
|
453
|
+
:value="internalSearch"
|
|
454
|
+
type="search"
|
|
455
|
+
class="skin-input h-10 rounded-md pl-9"
|
|
456
|
+
:placeholder="searchPlaceholder"
|
|
457
|
+
:disabled="disabled"
|
|
458
|
+
@input="updateSearch"
|
|
459
|
+
/>
|
|
460
|
+
</span>
|
|
461
|
+
</label>
|
|
462
|
+
|
|
463
|
+
<div v-for="definition in normalizedDefinitions" :key="definition.key" class="min-w-[9rem]" :style="definition.width ? { width: definition.width } : undefined">
|
|
464
|
+
<slot
|
|
465
|
+
name="filter"
|
|
466
|
+
:definition="definition"
|
|
467
|
+
:value="internalFilters[definition.key]"
|
|
468
|
+
:set-value="(value) => setFilterValue(definition, value)"
|
|
469
|
+
:clear="() => clearFilter(definition)"
|
|
470
|
+
>
|
|
471
|
+
<label class="block">
|
|
472
|
+
<span class="mb-1 block text-xs font-semibold text-muted-fg">{{ definition.label }}</span>
|
|
473
|
+
<select
|
|
474
|
+
v-if="definition.type === 'select' || definition.type === 'boolean'"
|
|
475
|
+
v-model="internalFilters[definition.key]"
|
|
476
|
+
class="skin-input h-10 rounded-md"
|
|
477
|
+
:disabled="disabled || definition.disabled"
|
|
478
|
+
@change="updateFromControl(definition, $event)"
|
|
479
|
+
>
|
|
480
|
+
<option value="">{{ definition.placeholder || `Any ${definition.label.toLowerCase()}` }}</option>
|
|
481
|
+
<template v-if="definition.type === 'boolean'">
|
|
482
|
+
<option :value="true">Yes</option>
|
|
483
|
+
<option :value="false">No</option>
|
|
484
|
+
</template>
|
|
485
|
+
<template v-else>
|
|
486
|
+
<option v-for="option in definition.options" :key="String(option.value)" :value="option.value" :disabled="option.disabled">{{ option.label }}</option>
|
|
487
|
+
</template>
|
|
488
|
+
</select>
|
|
489
|
+
<input
|
|
490
|
+
v-else
|
|
491
|
+
v-model="internalFilters[definition.key]"
|
|
492
|
+
:type="definition.type"
|
|
493
|
+
class="skin-input h-10 rounded-md"
|
|
494
|
+
:placeholder="definition.placeholder || ''"
|
|
495
|
+
:disabled="disabled || definition.disabled"
|
|
496
|
+
@input="updateFromControl(definition, $event)"
|
|
497
|
+
/>
|
|
498
|
+
</label>
|
|
499
|
+
</slot>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<slot name="trailing" :query="query" :clear="clearQuery" />
|
|
503
|
+
|
|
504
|
+
<button
|
|
505
|
+
v-if="hasActiveQuery"
|
|
506
|
+
type="button"
|
|
507
|
+
class="skin-control inline-flex h-10 items-center gap-2 rounded-md px-3 text-xs font-semibold transition hover:brightness-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
508
|
+
:disabled="disabled"
|
|
509
|
+
@click="clearQuery"
|
|
510
|
+
>
|
|
511
|
+
<span aria-hidden="true">×</span>
|
|
512
|
+
{{ clearLabel }}
|
|
513
|
+
</button>
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
<div v-if="activeEntries.length" class="flex flex-wrap gap-2 border-t border-border px-3 py-2.5">
|
|
517
|
+
<slot
|
|
518
|
+
v-for="entry in activeEntries"
|
|
519
|
+
:key="entry.definition.key"
|
|
520
|
+
name="active-filter"
|
|
521
|
+
:definition="entry.definition"
|
|
522
|
+
:value="entry.value"
|
|
523
|
+
:label="entry.label"
|
|
524
|
+
:clear="() => clearFilter(entry.definition)"
|
|
525
|
+
>
|
|
526
|
+
<span class="inline-flex max-w-full items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
|
527
|
+
<span class="truncate">{{ entry.definition.label }}: {{ entry.label }}</span>
|
|
528
|
+
<button type="button" class="grid size-4 shrink-0 place-items-center rounded-full hover:bg-primary/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" :aria-label="`Clear ${entry.definition.label} filter`" @click="clearFilter(entry.definition)">×</button>
|
|
529
|
+
</span>
|
|
530
|
+
</slot>
|
|
531
|
+
</div>
|
|
532
|
+
</section>
|
|
533
|
+
</template>
|