@humanforest/nuxt-layer 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 +67 -0
- package/README.md +208 -0
- package/app.config.ts +10 -0
- package/assets/icons/forest/bike-asterisk.svg +21 -0
- package/assets/icons/forest/bike-down.svg +19 -0
- package/assets/icons/forest/bike-off.svg +20 -0
- package/assets/icons/forest/bike-up.svg +19 -0
- package/assets/icons/forest/bike-x.svg +19 -0
- package/assets/icons/forest/bike.svg +18 -0
- package/assets/icons/forest/mark-fill.svg +6 -0
- package/assets/icons/forest/mark.svg +6 -0
- package/assets/icons/forest/parking.svg +3 -0
- package/assets/icons/forest/star-fill.svg +4 -0
- package/assets/icons/forest/star.svg +4 -0
- package/assets/icons/forest/traffic-light-caution.svg +8 -0
- package/assets/icons/forest/traffic-light-go.svg +8 -0
- package/assets/icons/forest/traffic-light-lit.svg +8 -0
- package/assets/icons/forest/traffic-light-stop.svg +8 -0
- package/assets/icons/forest/traffic-light.svg +8 -0
- package/nuxt.config.ts +81 -0
- package/package.json +35 -0
- package/packages/charts/src/FBoroughShape.vue +52 -0
- package/packages/charts/src/FCalendarGrid.vue +332 -0
- package/packages/charts/src/FCellLegend.vue +54 -0
- package/packages/charts/src/FChartFrame.vue +201 -0
- package/packages/charts/src/FChartFrameSingle.vue +91 -0
- package/packages/charts/src/FChartLegend.vue +26 -0
- package/packages/charts/src/FDistributionBar.vue +224 -0
- package/packages/charts/src/FDottedMap.vue +335 -0
- package/packages/charts/src/FLondonMap.vue +399 -0
- package/packages/charts/src/FSparkline.vue +198 -0
- package/packages/charts/src/FStatusTrack.vue +151 -0
- package/packages/charts/src/cellSize.ts +46 -0
- package/packages/charts/src/distribution.ts +99 -0
- package/packages/charts/src/engine.ts +15 -0
- package/packages/charts/src/forestTooltip.ts +110 -0
- package/packages/charts/src/fromCategories.ts +43 -0
- package/packages/charts/src/index.ts +87 -0
- package/packages/charts/src/londonAreas.ts +75 -0
- package/packages/charts/src/londonGrid.ts +67 -0
- package/packages/charts/src/motionDuration.ts +49 -0
- package/packages/charts/src/presets.ts +143 -0
- package/packages/charts/src/resolveVar.ts +21 -0
- package/packages/charts/src/snapTooltip.ts +154 -0
- package/packages/charts/src/tooltipStandIn.ts +58 -0
- package/packages/charts/src/useCellScale.ts +155 -0
- package/packages/charts/src/useChartPalette.ts +61 -0
- package/packages/charts/src/useChartRepaintKey.ts +22 -0
- package/packages/charts/src/useThemeVersion.ts +17 -0
- package/packages/frames/src/FBrowserFrame.vue +53 -0
- package/packages/frames/src/FDeviceFrame.vue +242 -0
- package/packages/frames/src/FFitFrame.vue +83 -0
- package/packages/frames/src/art/iphone-11.svg +124 -0
- package/packages/frames/src/art/iphone-12.svg +601 -0
- package/packages/frames/src/art/iphone-13-pro.svg +820 -0
- package/packages/frames/src/art/iphone-13.svg +849 -0
- package/packages/frames/src/art/iphone-14.svg +681 -0
- package/packages/frames/src/art/iphone-15-pro.svg +633 -0
- package/packages/frames/src/art/iphone-15.svg +713 -0
- package/packages/frames/src/art/iphone-16-plus.svg +685 -0
- package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
- package/packages/frames/src/art/iphone-16-pro.svg +631 -0
- package/packages/frames/src/art/iphone-16.svg +713 -0
- package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
- package/packages/frames/src/art/iphone-17.svg +305 -0
- package/packages/frames/src/art/iphone-air.svg +389 -0
- package/packages/frames/src/art/iphone-se-3.svg +473 -0
- package/packages/frames/src/art/iphone-x.svg +652 -0
- package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-10-pro.svg +32 -0
- package/packages/frames/src/art/pixel-10.svg +32 -0
- package/packages/frames/src/art/pixel-8-pro.svg +208 -0
- package/packages/frames/src/art/pixel-8.svg +208 -0
- package/packages/frames/src/art/pixel-8a.svg +8 -0
- package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-9-pro.svg +34 -0
- package/packages/frames/src/art/pixel-9a.svg +25 -0
- package/packages/frames/src/art.ts +29 -0
- package/packages/frames/src/devices.ts +337 -0
- package/packages/frames/src/index.ts +15 -0
- package/packages/tokens/geo/london-boroughs.json +1 -0
- package/packages/tokens/geo/london-dots-coarse.json +1 -0
- package/packages/tokens/geo/london-dots-fine.json +1 -0
- package/packages/tokens/geo/london-dots-medium.json +1 -0
- package/packages/tokens/geo/london-dots-ultra.json +1 -0
- package/packages/tokens/geo/london-wards.json +1 -0
- package/packages/tokens/glyphs/glyphs.json +1 -0
- package/packages/tokens/logos/f-rot.svg +4 -0
- package/packages/tokens/logos/f.svg +4 -0
- package/packages/tokens/logos/favicon-c2w.svg +9 -0
- package/packages/tokens/logos/favicon-console.svg +9 -0
- package/packages/tokens/logos/favicon-ds.svg +9 -0
- package/packages/tokens/logos/favicon-fleet.svg +9 -0
- package/packages/tokens/logos/favicon-flex.svg +9 -0
- package/packages/tokens/logos/favicon-plus.svg +9 -0
- package/packages/tokens/logos/favicon-radar.svg +9 -0
- package/packages/tokens/logos/favicon-tower.svg +9 -0
- package/packages/tokens/logos/lockup-horizontal.svg +17 -0
- package/packages/tokens/logos/lockup-vertical.svg +17 -0
- package/packages/tokens/logos/mark-mono.svg +4 -0
- package/packages/tokens/logos/mark.svg +7 -0
- package/packages/tokens/logos/wordmark.svg +9 -0
- package/packages/tokens/src/colourEngine.ts +604 -0
- package/packages/tokens/src/glyphs.ts +127 -0
- package/packages/tokens/src/logo.ts +50 -0
- package/packages/tokens/src/subbrands.ts +44 -0
- package/packages/tokens/src/sublogo.ts +80 -0
- package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
- package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
- package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
- package/packages/ui/icons/forest/map/manifest.json +373 -0
- package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
- package/packages/ui/icons/forest/map/place-manifest.json +74 -0
- package/packages/ui/src/address/address.ts +125 -0
- package/packages/ui/src/address/autofill.ts +78 -0
- package/packages/ui/src/address/geocode.ts +55 -0
- package/packages/ui/src/address/index.ts +31 -0
- package/packages/ui/src/address/validate.ts +91 -0
- package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
- package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
- package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
- package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
- package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
- package/packages/ui/src/canvas/canvasSurface.ts +39 -0
- package/packages/ui/src/canvas/index.ts +17 -0
- package/packages/ui/src/contexts/ForestContext.vue +53 -0
- package/packages/ui/src/contexts/contexts.ts +153 -0
- package/packages/ui/src/contexts/index.ts +23 -0
- package/packages/ui/src/contexts/marketing-theme.ts +214 -0
- package/packages/ui/src/contexts/mobile-theme.ts +402 -0
- package/packages/ui/src/filter/FFilterBar.vue +72 -0
- package/packages/ui/src/filter/FFilterChip.vue +362 -0
- package/packages/ui/src/filter/FFilterClauses.vue +104 -0
- package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
- package/packages/ui/src/filter/filter.theme.ts +27 -0
- package/packages/ui/src/filter/filterClause.ts +265 -0
- package/packages/ui/src/filter/filterPredicate.ts +134 -0
- package/packages/ui/src/filter/filterSchema.ts +82 -0
- package/packages/ui/src/filter/index.ts +36 -0
- package/packages/ui/src/forest-preset.ts +68 -0
- package/packages/ui/src/icons.ts +79 -0
- package/packages/ui/src/index.ts +17 -0
- package/packages/ui/src/kpi/FKpi.vue +253 -0
- package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
- package/packages/ui/src/kpi/index.ts +20 -0
- package/packages/ui/src/kpi/kpi.theme.ts +168 -0
- package/packages/ui/src/kpi/kpiDelta.ts +82 -0
- package/packages/ui/src/kpi/kpiFormat.ts +72 -0
- package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
- package/packages/ui/src/logo/FLogo.vue +52 -0
- package/packages/ui/src/logo/FSubLogo.vue +214 -0
- package/packages/ui/src/logo/index.ts +8 -0
- package/packages/ui/src/map/FMap.vue +355 -0
- package/packages/ui/src/map/FMapCompass.vue +154 -0
- package/packages/ui/src/map/FMapCoordinates.vue +134 -0
- package/packages/ui/src/map/FMapDrawTools.vue +116 -0
- package/packages/ui/src/map/FMapFullscreen.vue +92 -0
- package/packages/ui/src/map/FMapGeolocate.vue +208 -0
- package/packages/ui/src/map/FMapLayers.vue +75 -0
- package/packages/ui/src/map/FMapLegend.vue +45 -0
- package/packages/ui/src/map/FMapPopup.vue +129 -0
- package/packages/ui/src/map/FMapRamp.vue +69 -0
- package/packages/ui/src/map/FMapRegion.vue +77 -0
- package/packages/ui/src/map/FMapScale.vue +107 -0
- package/packages/ui/src/map/FMapSearch.vue +362 -0
- package/packages/ui/src/map/FMapTilt.vue +87 -0
- package/packages/ui/src/map/FMapTools.vue +61 -0
- package/packages/ui/src/map/FMapZoom.vue +81 -0
- package/packages/ui/src/map/FMarker.vue +218 -0
- package/packages/ui/src/map/FTrackScrubber.vue +94 -0
- package/packages/ui/src/map/FVehicleLegend.vue +72 -0
- package/packages/ui/src/map/FVehicleMark.vue +166 -0
- package/packages/ui/src/map/bin.ts +196 -0
- package/packages/ui/src/map/coordinates.ts +83 -0
- package/packages/ui/src/map/density.ts +401 -0
- package/packages/ui/src/map/draw.ts +356 -0
- package/packages/ui/src/map/ease.ts +67 -0
- package/packages/ui/src/map/fullscreen.ts +59 -0
- package/packages/ui/src/map/heat.ts +160 -0
- package/packages/ui/src/map/index.ts +351 -0
- package/packages/ui/src/map/liveMarkers.ts +60 -0
- package/packages/ui/src/map/map.css +116 -0
- package/packages/ui/src/map/mapColor.ts +31 -0
- package/packages/ui/src/map/mapContext.ts +12 -0
- package/packages/ui/src/map/mapLayers.ts +55 -0
- package/packages/ui/src/map/mapLegend.ts +70 -0
- package/packages/ui/src/map/mapSearch.theme.ts +79 -0
- package/packages/ui/src/map/mapSearch.ts +279 -0
- package/packages/ui/src/map/mapUrl.ts +117 -0
- package/packages/ui/src/map/markerAsset.ts +3 -0
- package/packages/ui/src/map/padding.ts +39 -0
- package/packages/ui/src/map/place.ts +192 -0
- package/packages/ui/src/map/ramp.ts +95 -0
- package/packages/ui/src/map/regionSelect.ts +163 -0
- package/packages/ui/src/map/route.ts +167 -0
- package/packages/ui/src/map/scale.ts +329 -0
- package/packages/ui/src/map/scaleBar.ts +67 -0
- package/packages/ui/src/map/selectionModel.ts +50 -0
- package/packages/ui/src/map/sources/coordinates.ts +58 -0
- package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
- package/packages/ui/src/map/style.ts +14 -0
- package/packages/ui/src/map/track.ts +279 -0
- package/packages/ui/src/map/useFeatureSelect.ts +230 -0
- package/packages/ui/src/map/useLiveMarkers.ts +84 -0
- package/packages/ui/src/map/useMap.ts +150 -0
- package/packages/ui/src/map/useMapDraw.ts +776 -0
- package/packages/ui/src/map/useMapOverlay.ts +421 -0
- package/packages/ui/src/map/useMapTableSelection.ts +101 -0
- package/packages/ui/src/map/useMapUrlState.ts +155 -0
- package/packages/ui/src/map/useMarkerSource.ts +163 -0
- package/packages/ui/src/map/useRegionSelect.ts +309 -0
- package/packages/ui/src/map/useTokenMode.ts +61 -0
- package/packages/ui/src/map/useTrackPlayback.ts +131 -0
- package/packages/ui/src/map/useVehicleFilter.ts +51 -0
- package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
- package/packages/ui/src/map/vehicle.ts +807 -0
- package/packages/ui/src/map/vehicleFilter.ts +284 -0
- package/packages/ui/src/map/vehicleLegend.ts +155 -0
- package/packages/ui/src/map/visibleFeatures.ts +192 -0
- package/packages/ui/src/map/zone.ts +291 -0
- package/packages/ui/src/shell/FAppNavbar.vue +68 -0
- package/packages/ui/src/shell/FAppShell.vue +163 -0
- package/packages/ui/src/shell/FAppSidebar.vue +215 -0
- package/packages/ui/src/shell/FPageToolbar.vue +126 -0
- package/packages/ui/src/shell/FScrollShadow.vue +43 -0
- package/packages/ui/src/shell/FSidePanel.vue +258 -0
- package/packages/ui/src/shell/FToolbarActions.vue +32 -0
- package/packages/ui/src/shell/index.ts +33 -0
- package/packages/ui/src/shell/nav.ts +37 -0
- package/packages/ui/src/shell/pageToolbar.ts +69 -0
- package/packages/ui/src/shell/shell.ts +74 -0
- package/packages/ui/src/shell/sidePanel.ts +115 -0
- package/packages/ui/src/themes/accordion.ts +36 -0
- package/packages/ui/src/themes/alert.ts +16 -0
- package/packages/ui/src/themes/auth-form.ts +19 -0
- package/packages/ui/src/themes/avatar-group.ts +9 -0
- package/packages/ui/src/themes/avatar.ts +18 -0
- package/packages/ui/src/themes/badge.ts +51 -0
- package/packages/ui/src/themes/banner.ts +21 -0
- package/packages/ui/src/themes/breadcrumb.ts +10 -0
- package/packages/ui/src/themes/button.mechanics.ts +191 -0
- package/packages/ui/src/themes/button.ts +136 -0
- package/packages/ui/src/themes/calendar.ts +29 -0
- package/packages/ui/src/themes/card.ts +67 -0
- package/packages/ui/src/themes/carousel.ts +41 -0
- package/packages/ui/src/themes/checkbox-group.ts +20 -0
- package/packages/ui/src/themes/checkbox.ts +47 -0
- package/packages/ui/src/themes/chip.ts +50 -0
- package/packages/ui/src/themes/collapsible.ts +26 -0
- package/packages/ui/src/themes/color-picker.ts +13 -0
- package/packages/ui/src/themes/command-palette.ts +37 -0
- package/packages/ui/src/themes/container.ts +14 -0
- package/packages/ui/src/themes/context-menu.ts +38 -0
- package/packages/ui/src/themes/dashboard-group.ts +11 -0
- package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
- package/packages/ui/src/themes/dashboard-panel.ts +18 -0
- package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
- package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
- package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
- package/packages/ui/src/themes/drawer.ts +45 -0
- package/packages/ui/src/themes/dropdown-menu.ts +38 -0
- package/packages/ui/src/themes/empty.ts +43 -0
- package/packages/ui/src/themes/field-group.ts +52 -0
- package/packages/ui/src/themes/file-upload.ts +38 -0
- package/packages/ui/src/themes/footer-columns.ts +13 -0
- package/packages/ui/src/themes/footer.ts +9 -0
- package/packages/ui/src/themes/form-field.ts +25 -0
- package/packages/ui/src/themes/form.ts +22 -0
- package/packages/ui/src/themes/header.ts +12 -0
- package/packages/ui/src/themes/icon.ts +61 -0
- package/packages/ui/src/themes/index.ts +99 -0
- package/packages/ui/src/themes/input-date.ts +73 -0
- package/packages/ui/src/themes/input-menu.ts +66 -0
- package/packages/ui/src/themes/input-number.ts +58 -0
- package/packages/ui/src/themes/input-rating.ts +25 -0
- package/packages/ui/src/themes/input-tags.ts +63 -0
- package/packages/ui/src/themes/input-time.ts +70 -0
- package/packages/ui/src/themes/input.ts +148 -0
- package/packages/ui/src/themes/kbd.ts +27 -0
- package/packages/ui/src/themes/link.ts +27 -0
- package/packages/ui/src/themes/listbox.ts +48 -0
- package/packages/ui/src/themes/main.ts +6 -0
- package/packages/ui/src/themes/marquee.ts +45 -0
- package/packages/ui/src/themes/modal.ts +46 -0
- package/packages/ui/src/themes/navigation-menu.ts +80 -0
- package/packages/ui/src/themes/page-anchors.ts +7 -0
- package/packages/ui/src/themes/page-aside.ts +7 -0
- package/packages/ui/src/themes/page-card.ts +22 -0
- package/packages/ui/src/themes/page-cta.ts +18 -0
- package/packages/ui/src/themes/page-feature.ts +13 -0
- package/packages/ui/src/themes/page-header.ts +16 -0
- package/packages/ui/src/themes/page-hero.ts +15 -0
- package/packages/ui/src/themes/page-links.ts +12 -0
- package/packages/ui/src/themes/page-section.ts +14 -0
- package/packages/ui/src/themes/page.ts +7 -0
- package/packages/ui/src/themes/pagination.ts +17 -0
- package/packages/ui/src/themes/pin-input.ts +73 -0
- package/packages/ui/src/themes/popover.ts +30 -0
- package/packages/ui/src/themes/progress.ts +29 -0
- package/packages/ui/src/themes/prose.ts +45 -0
- package/packages/ui/src/themes/radio-group.ts +39 -0
- package/packages/ui/src/themes/scroll-area.ts +23 -0
- package/packages/ui/src/themes/select-menu.ts +71 -0
- package/packages/ui/src/themes/select.ts +72 -0
- package/packages/ui/src/themes/separator.ts +28 -0
- package/packages/ui/src/themes/sidebar.ts +127 -0
- package/packages/ui/src/themes/skeleton.ts +15 -0
- package/packages/ui/src/themes/slideover.ts +87 -0
- package/packages/ui/src/themes/slider.ts +48 -0
- package/packages/ui/src/themes/stepper.ts +41 -0
- package/packages/ui/src/themes/switch.ts +63 -0
- package/packages/ui/src/themes/table.ts +34 -0
- package/packages/ui/src/themes/tabs.ts +74 -0
- package/packages/ui/src/themes/textarea.ts +77 -0
- package/packages/ui/src/themes/timeline.ts +25 -0
- package/packages/ui/src/themes/toast.ts +29 -0
- package/packages/ui/src/themes/toaster.ts +66 -0
- package/packages/ui/src/themes/tooltip.ts +31 -0
- package/packages/ui/src/themes/tree.ts +41 -0
- package/packages/ui/src/themes/user.ts +28 -0
- package/styles.cdn.gen.css +2047 -0
- package/theme.gen.json +1 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One clause, as a joined segment bar: field · operator · value · dismiss.
|
|
3
|
+
//
|
|
4
|
+
// UFieldGroup's `-space-x-px` overlap is what makes this read as one slab — adjacent inset rings
|
|
5
|
+
// collapse to a single line, and the group pins its own outer corners to rounded-md in every context,
|
|
6
|
+
// so the mobile cut's rounded-full cannot dome the ends. Segments are neutral `subtle`: a soft grey
|
|
7
|
+
// resting chip that stays out of the data's way, its fills butting together at each seam.
|
|
8
|
+
//
|
|
9
|
+
// The chip owns no state. It emits a patch and the bar applies it through updateClause, which is
|
|
10
|
+
// where the is/is-any-of promotion lives.
|
|
11
|
+
import { computed, onMounted, ref, shallowRef } from 'vue';
|
|
12
|
+
import { CalendarDate } from '@internationalized/date';
|
|
13
|
+
import { filterTheme, type FilterSlot } from './filter.theme';
|
|
14
|
+
import { OPERATOR_LABEL, operatorsFor, type FilterSchema, type Operator } from './filterSchema';
|
|
15
|
+
import { isComplete, summariseClause, type Clause, type RelativeUnit } from './filterClause';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
clause: Clause;
|
|
19
|
+
schema: FilterSchema;
|
|
20
|
+
/** Fields the other chips already hold, so this one's field menu does not offer a duplicate. */
|
|
21
|
+
usedFields?: string[];
|
|
22
|
+
ui?: Partial<Record<FilterSlot, string>>;
|
|
23
|
+
}>();
|
|
24
|
+
|
|
25
|
+
const emit = defineEmits<{
|
|
26
|
+
update: [patch: Partial<Omit<Clause, 'id'>>];
|
|
27
|
+
remove: [];
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
const theme = computed(() => filterTheme());
|
|
31
|
+
|
|
32
|
+
const field = computed(() => props.schema[props.clause.field]);
|
|
33
|
+
const kind = computed(() => field.value?.kind);
|
|
34
|
+
const summary = computed(() => summariseClause(props.clause, props.schema));
|
|
35
|
+
|
|
36
|
+
/** A single-operator kind has nothing to choose, so the chip drops that segment entirely. */
|
|
37
|
+
const operators = computed<Operator[]>(() => (field.value ? [...operatorsFor(field.value)] : []));
|
|
38
|
+
const showOperator = computed(() => operators.value.length > 1);
|
|
39
|
+
|
|
40
|
+
const operatorItems = computed(() =>
|
|
41
|
+
operators.value.map((operator) => ({
|
|
42
|
+
label: OPERATOR_LABEL[operator],
|
|
43
|
+
onSelect: () => emit('update', { operator }),
|
|
44
|
+
})),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The field segment swaps the field the clause filters, which is what makes it worth pressing. It
|
|
49
|
+
* offers every field except this clause's own and the ones other chips already hold — two clauses on
|
|
50
|
+
* one field would AND to nothing on an equality operator, and every other path in the bar avoids it.
|
|
51
|
+
*/
|
|
52
|
+
const fieldItems = computed(() =>
|
|
53
|
+
Object.entries(props.schema)
|
|
54
|
+
.filter(([key]) => key !== props.clause.field && !(props.usedFields ?? []).includes(key))
|
|
55
|
+
.map(([key, declared]) => ({
|
|
56
|
+
label: declared.label,
|
|
57
|
+
icon: declared.icon,
|
|
58
|
+
onSelect: () => emit('update', { field: key }),
|
|
59
|
+
})),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const enumItems = computed(() =>
|
|
63
|
+
(field.value?.values ?? []).map((v) => ({ label: v.label, value: v.value, icon: v.icon, avatar: v.avatar })),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** USelectMenu speaks arrays of its own item objects; the clause stores the value keys. */
|
|
67
|
+
const selected = computed({
|
|
68
|
+
get: () => enumItems.value.filter((item) => (props.clause.value as string[]).includes(item.value)),
|
|
69
|
+
set: (items: { value: string }[]) => emit('update', { value: items.map((i) => i.value) }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// One computed per kind rather than one cast at the binding: `v-model="scalar as string"` is not
|
|
73
|
+
// valid template syntax, so the narrowing has to happen in the getter.
|
|
74
|
+
const textValue = computed({
|
|
75
|
+
get: () => (typeof props.clause.value === 'string' ? props.clause.value : ''),
|
|
76
|
+
set: (value: string) => emit('update', { value }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const numberValue = computed({
|
|
80
|
+
get: () => (typeof props.clause.value === 'number' ? props.clause.value : null),
|
|
81
|
+
set: (value: number | null) => emit('update', { value }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// UInputDate speaks CalendarDate and the clause holds an ISO string, so the conversion sits here —
|
|
85
|
+
// the UI boundary — rather than in the pure modules, which must not take a date dependency.
|
|
86
|
+
const isoToCalendar = (iso: unknown): CalendarDate | null => {
|
|
87
|
+
if (typeof iso !== 'string') return null;
|
|
88
|
+
const at = new Date(iso);
|
|
89
|
+
return Number.isNaN(at.getTime())
|
|
90
|
+
? null
|
|
91
|
+
: new CalendarDate(at.getUTCFullYear(), at.getUTCMonth() + 1, at.getUTCDate());
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const calendarToIso = (d: CalendarDate): string => d.toDate('UTC').toISOString();
|
|
95
|
+
|
|
96
|
+
const dateValue = computed({
|
|
97
|
+
get: () => isoToCalendar(props.clause.value),
|
|
98
|
+
set: (d: CalendarDate | null) => emit('update', { value: d ? calendarToIso(d) : null }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The value segment follows the OPERATOR, not just the kind: a range wants two ends, and a relative
|
|
102
|
+
// window wants a count and a unit. updateClause reshapes the stored value when the operator changes,
|
|
103
|
+
// so each of these reads whichever half of the pair it owns and writes the pair back whole.
|
|
104
|
+
const isRange = computed(() => props.clause.operator === 'isBetween');
|
|
105
|
+
const isWindow = computed(() => props.clause.operator === 'inTheLast');
|
|
106
|
+
|
|
107
|
+
const pair = computed<[unknown, unknown]>(() =>
|
|
108
|
+
Array.isArray(props.clause.value) ? [props.clause.value[0], props.clause.value[1]] : [null, null],
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const numberAt = (index: 0 | 1) =>
|
|
112
|
+
computed({
|
|
113
|
+
get: () => (typeof pair.value[index] === 'number' ? (pair.value[index] as number) : null),
|
|
114
|
+
set: (n: number | null) => {
|
|
115
|
+
const ends = [...pair.value];
|
|
116
|
+
ends[index] = n;
|
|
117
|
+
emit('update', { value: ends as [number | null, number | null] });
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const numberLo = numberAt(0);
|
|
122
|
+
const numberHi = numberAt(1);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* UCalendar speaks a { start, end } of CalendarDates; the clause stores a pair of ISO strings.
|
|
126
|
+
*
|
|
127
|
+
* Picking a range takes two clicks, and the half-picked state stays LOCAL: the calendar runs its own
|
|
128
|
+
* selection while the popover is open, and only a complete range reaches the clause. Feeding the
|
|
129
|
+
* half-picked value back through the model would reset the calendar's own selection between the two
|
|
130
|
+
* clicks — the range would restart on every second click and the applied filter would clear on the
|
|
131
|
+
* first. The draft is re-seeded from the clause each time the popover opens, so abandoning a pick
|
|
132
|
+
* leaves the applied range untouched.
|
|
133
|
+
*/
|
|
134
|
+
const dateOpen = ref(false);
|
|
135
|
+
|
|
136
|
+
/** Seeds the calendar when it mounts. The `dateKey` remount re-reads it on every open. */
|
|
137
|
+
const clauseRange = computed(() => {
|
|
138
|
+
const start = isoToCalendar(pair.value[0]);
|
|
139
|
+
const end = isoToCalendar(pair.value[1]);
|
|
140
|
+
return start ? { start, end: end ?? undefined } : undefined;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// A fresh calendar per opening: seeded from the clause, then left alone until it hands back a
|
|
144
|
+
// finished selection. Binding the half-picked value back would reset the calendar's own selection
|
|
145
|
+
// between the two clicks of a range, so the second click restarts the range instead of closing it.
|
|
146
|
+
const dateKey = ref(0);
|
|
147
|
+
const onDateOpen = (open: boolean) => {
|
|
148
|
+
dateOpen.value = open;
|
|
149
|
+
if (open) dateKey.value += 1;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const onRangePicked = (range: { start?: CalendarDate; end?: CalendarDate } | undefined) => {
|
|
153
|
+
if (!range?.start || !range.end) return;
|
|
154
|
+
emit('update', {
|
|
155
|
+
value: [calendarToIso(range.start), calendarToIso(range.end)] as [string | null, string | null],
|
|
156
|
+
});
|
|
157
|
+
dateOpen.value = false;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** A single date is complete the moment it is picked, so the calendar closes behind it. */
|
|
161
|
+
const onDatePicked = (picked: CalendarDate | undefined) => {
|
|
162
|
+
if (!picked) return;
|
|
163
|
+
emit('update', { value: calendarToIso(picked) });
|
|
164
|
+
dateOpen.value = false;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const RELATIVE_UNITS = [
|
|
168
|
+
{ label: 'days', value: 'day' },
|
|
169
|
+
{ label: 'weeks', value: 'week' },
|
|
170
|
+
{ label: 'months', value: 'month' },
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const windowValue = computed(() => {
|
|
174
|
+
const { value } = props.clause;
|
|
175
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
176
|
+
? (value as { n: number; unit: RelativeUnit })
|
|
177
|
+
: null;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const windowCount = computed({
|
|
181
|
+
get: () => (windowValue.value && windowValue.value.n > 0 ? windowValue.value.n : null),
|
|
182
|
+
set: (n: number | null) =>
|
|
183
|
+
emit('update', { value: { n: n ?? 0, unit: windowValue.value?.unit ?? 'day' } }),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const windowUnit = computed({
|
|
187
|
+
get: () => windowValue.value?.unit ?? 'day',
|
|
188
|
+
set: (unit: RelativeUnit) => emit('update', { value: { n: windowValue.value?.n ?? 0, unit } }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/** What the date trigger reads before a date is picked. */
|
|
192
|
+
const datePlaceholder = computed(() => (isRange.value ? 'Pick dates…' : 'Pick a date…'));
|
|
193
|
+
|
|
194
|
+
const valueRef = ref<{ $el?: HTMLElement } | null>(null);
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Put the caret in the value segment. Called from outside when an author picks a field that is
|
|
198
|
+
* already filtered, and by the chip itself when it appears empty.
|
|
199
|
+
*
|
|
200
|
+
* The segment is either a control in its own right — a date or boolean button — or a wrapper around
|
|
201
|
+
* one, so try the root element before looking inside it.
|
|
202
|
+
*/
|
|
203
|
+
const openValues = () => {
|
|
204
|
+
const el = valueRef.value?.$el ?? (valueRef.value as unknown as HTMLElement | null);
|
|
205
|
+
const control = el?.matches?.('button, input') ? el : el?.querySelector<HTMLElement>('button, input');
|
|
206
|
+
control?.focus();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// A chip that appears EMPTY takes the caret itself.
|
|
210
|
+
//
|
|
211
|
+
// An empty clause is inert — the predicate skips it — so a scalar field picked from the menu filters
|
|
212
|
+
// nothing until someone types a value. The chip is the only thing that knows it has appeared with
|
|
213
|
+
// nothing in it, and it is the thing being focused, so it claims the caret rather than waiting to be
|
|
214
|
+
// handed it. That holds however the clause was created: from a trigger in this bar, from one in a
|
|
215
|
+
// different bar, or from a consumer's own code.
|
|
216
|
+
//
|
|
217
|
+
// Only on APPEARANCE. An existing chip emptied by clearing its input must not snatch focus back while
|
|
218
|
+
// someone is deleting characters.
|
|
219
|
+
// Deferred, and that is not a guess about timing. A chip picked from the field menu mounts while the
|
|
220
|
+
// menu is STILL OPEN and still trapping focus, so a caret placed during mount is pulled straight back
|
|
221
|
+
// inside the menu and lost when it unmounts. Claiming on the next task puts it after the menu has
|
|
222
|
+
// gone. (<FFilterTrigger> stops the menu handing the caret back to itself; this is the other half.)
|
|
223
|
+
onMounted(() => {
|
|
224
|
+
if (isComplete(props.clause)) return;
|
|
225
|
+
setTimeout(openValues, 0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
defineExpose({ openValues });
|
|
229
|
+
</script>
|
|
230
|
+
|
|
231
|
+
<template>
|
|
232
|
+
<UFieldGroup v-if="field" size="sm">
|
|
233
|
+
<UDropdownMenu :items="fieldItems" :disabled="!fieldItems.length" :content="{ align: 'start' }">
|
|
234
|
+
<UButton color="neutral" variant="subtle" :class="theme.field({ class: props.ui?.field })">
|
|
235
|
+
<UIcon v-if="field.icon" :name="field.icon" />
|
|
236
|
+
{{ field.label }}
|
|
237
|
+
</UButton>
|
|
238
|
+
</UDropdownMenu>
|
|
239
|
+
|
|
240
|
+
<UDropdownMenu v-if="showOperator" :items="operatorItems" :content="{ align: 'start' }">
|
|
241
|
+
<UButton color="neutral" variant="subtle" :label="OPERATOR_LABEL[props.clause.operator]" />
|
|
242
|
+
</UDropdownMenu>
|
|
243
|
+
|
|
244
|
+
<USelectMenu
|
|
245
|
+
v-if="kind === 'enum'"
|
|
246
|
+
ref="valueRef"
|
|
247
|
+
v-model="selected"
|
|
248
|
+
multiple
|
|
249
|
+
:items="enumItems"
|
|
250
|
+
color="neutral" variant="outline"
|
|
251
|
+
:content="{ align: 'start' }"
|
|
252
|
+
:class="theme.count({ class: props.ui?.count })"
|
|
253
|
+
>
|
|
254
|
+
<span v-if="summary" :class="theme.value()">{{ summary }}</span>
|
|
255
|
+
<span v-else :class="[theme.value(), theme.placeholder({ class: props.ui?.placeholder })]">Select…</span>
|
|
256
|
+
</USelectMenu>
|
|
257
|
+
|
|
258
|
+
<UInput
|
|
259
|
+
v-else-if="kind === 'text'"
|
|
260
|
+
ref="valueRef"
|
|
261
|
+
v-model="textValue"
|
|
262
|
+
color="neutral" variant="outline"
|
|
263
|
+
placeholder="Type…"
|
|
264
|
+
/>
|
|
265
|
+
|
|
266
|
+
<!-- A number range is two ends, so the segment splits in two rather than asking for a pair in
|
|
267
|
+
one field. Either end alone leaves the clause inert. -->
|
|
268
|
+
<template v-else-if="kind === 'number' && isRange">
|
|
269
|
+
<UInputNumber
|
|
270
|
+
ref="valueRef"
|
|
271
|
+
v-model="numberLo"
|
|
272
|
+
color="neutral" variant="outline"
|
|
273
|
+
class="w-20"
|
|
274
|
+
:placeholder="field.unit ? `From ${field.unit}` : 'From'"
|
|
275
|
+
/>
|
|
276
|
+
<UInputNumber
|
|
277
|
+
v-model="numberHi"
|
|
278
|
+
color="neutral" variant="outline"
|
|
279
|
+
class="w-20"
|
|
280
|
+
:placeholder="field.unit ? `To ${field.unit}` : 'To'"
|
|
281
|
+
/>
|
|
282
|
+
</template>
|
|
283
|
+
|
|
284
|
+
<UInputNumber
|
|
285
|
+
v-else-if="kind === 'number'"
|
|
286
|
+
ref="valueRef"
|
|
287
|
+
v-model="numberValue"
|
|
288
|
+
color="neutral" variant="outline"
|
|
289
|
+
class="w-24"
|
|
290
|
+
:placeholder="field.unit ?? 'Value'"
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<!-- "in the last 7 days" — the operator segment already says "in the last", so the value is the
|
|
294
|
+
count and its unit. -->
|
|
295
|
+
<template v-else-if="kind === 'date' && isWindow">
|
|
296
|
+
<UInputNumber
|
|
297
|
+
ref="valueRef"
|
|
298
|
+
v-model="windowCount"
|
|
299
|
+
:min="1"
|
|
300
|
+
color="neutral" variant="outline"
|
|
301
|
+
class="w-20"
|
|
302
|
+
placeholder="7"
|
|
303
|
+
/>
|
|
304
|
+
<USelect
|
|
305
|
+
v-model="windowUnit"
|
|
306
|
+
:items="RELATIVE_UNITS"
|
|
307
|
+
value-key="value"
|
|
308
|
+
color="neutral" variant="outline"
|
|
309
|
+
/>
|
|
310
|
+
</template>
|
|
311
|
+
|
|
312
|
+
<!-- A date is picked on a calendar, not typed: one popover, a range calendar or a single one. -->
|
|
313
|
+
<UPopover
|
|
314
|
+
v-else-if="kind === 'date'"
|
|
315
|
+
:open="dateOpen"
|
|
316
|
+
:content="{ align: 'start' }"
|
|
317
|
+
@update:open="onDateOpen"
|
|
318
|
+
>
|
|
319
|
+
<UButton
|
|
320
|
+
ref="valueRef"
|
|
321
|
+
color="neutral" variant="outline"
|
|
322
|
+
icon="i-lucide-calendar"
|
|
323
|
+
:label="summary || datePlaceholder"
|
|
324
|
+
:class="summary ? undefined : theme.placeholder({ class: props.ui?.placeholder })"
|
|
325
|
+
/>
|
|
326
|
+
<template #content>
|
|
327
|
+
<UCalendar
|
|
328
|
+
v-if="isRange"
|
|
329
|
+
:key="`range-${dateKey}`"
|
|
330
|
+
range
|
|
331
|
+
:default-value="clauseRange"
|
|
332
|
+
:number-of-months="2"
|
|
333
|
+
class="p-2"
|
|
334
|
+
@update:model-value="onRangePicked"
|
|
335
|
+
/>
|
|
336
|
+
<UCalendar
|
|
337
|
+
v-else
|
|
338
|
+
:key="`single-${dateKey}`"
|
|
339
|
+
:default-value="dateValue ?? undefined"
|
|
340
|
+
class="p-2"
|
|
341
|
+
@update:model-value="onDatePicked"
|
|
342
|
+
/>
|
|
343
|
+
</template>
|
|
344
|
+
</UPopover>
|
|
345
|
+
|
|
346
|
+
<UButton
|
|
347
|
+
v-else-if="kind === 'boolean'"
|
|
348
|
+
ref="valueRef"
|
|
349
|
+
color="neutral" variant="outline"
|
|
350
|
+
:label="props.clause.value ? 'Yes' : 'No'"
|
|
351
|
+
@click="emit('update', { value: !props.clause.value })"
|
|
352
|
+
/>
|
|
353
|
+
|
|
354
|
+
<UButton
|
|
355
|
+
color="neutral"
|
|
356
|
+
variant="subtle"
|
|
357
|
+
icon="i-lucide-x"
|
|
358
|
+
:aria-label="`Remove ${field.label} filter`"
|
|
359
|
+
@click="emit('remove')"
|
|
360
|
+
/>
|
|
361
|
+
</UFieldGroup>
|
|
362
|
+
</template>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The applied clauses: a wrapping row of chips, plus clear-all once there is more than one.
|
|
3
|
+
//
|
|
4
|
+
// Split out of <FFilterBar> so it can sit in a row of its own. The row WRAPS rather than scrolls — a
|
|
5
|
+
// horizontal scroller hides applied filters, which is the worse failure — and that is exactly why it
|
|
6
|
+
// cannot share a row with navigation. Given its own bar it can wrap as far as it needs to.
|
|
7
|
+
//
|
|
8
|
+
// Controlled: the whole contract is a Clause[] in and out, so this and <FFilterTrigger> stay in step
|
|
9
|
+
// through the same v-model with nothing passed between them.
|
|
10
|
+
import { computed, useTemplateRef } from 'vue';
|
|
11
|
+
import FFilterChip from './FFilterChip.vue';
|
|
12
|
+
import { filterTheme, type FilterSlot } from './filter.theme';
|
|
13
|
+
import type { FilterSchema } from './filterSchema';
|
|
14
|
+
import { removeClause, updateClause, type Clause } from './filterClause';
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
modelValue: Clause[];
|
|
18
|
+
schema: FilterSchema;
|
|
19
|
+
/**
|
|
20
|
+
* The <FFilterTrigger> that creates these clauses, so focus can return to it when the last chip
|
|
21
|
+
* goes. Optional — without it, dismissing the last chip leaves focus on the document, which is
|
|
22
|
+
* survivable but not good. <FFilterBar> wires this for the single-row case.
|
|
23
|
+
*/
|
|
24
|
+
trigger?: { focus: () => void } | null;
|
|
25
|
+
ui?: Partial<Record<FilterSlot, string>>;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{ 'update:modelValue': [clauses: Clause[]] }>();
|
|
29
|
+
|
|
30
|
+
const theme = computed(() => filterTheme());
|
|
31
|
+
|
|
32
|
+
const chips = useTemplateRef<InstanceType<typeof FFilterChip>[]>('chips');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Put the caret in a clause's value control. The handle <FFilterTrigger> calls through when an
|
|
36
|
+
* author picks a field that is already filtered AND already filled — the only case where no chip
|
|
37
|
+
* appears, so nothing claims the caret on its own.
|
|
38
|
+
*/
|
|
39
|
+
const openClause = (id: string) => {
|
|
40
|
+
// Read the index inside the frame: the id may have only just been emitted, so props.modelValue
|
|
41
|
+
// (and the chip refs) reflect it only after the update flushes.
|
|
42
|
+
requestAnimationFrame(() => {
|
|
43
|
+
const index = props.modelValue.findIndex((c) => c.id === id);
|
|
44
|
+
chips.value?.[index]?.openValues();
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const patch = (id: string, p: Partial<Omit<Clause, 'id'>>) =>
|
|
49
|
+
emit('update:modelValue', updateClause(props.modelValue, id, p, props.schema));
|
|
50
|
+
|
|
51
|
+
/** What every OTHER chip is filtering, so a chip's field menu cannot land on a field twice. */
|
|
52
|
+
const usedFieldsExcept = (id: string) =>
|
|
53
|
+
props.modelValue.filter((clause) => clause.id !== id).map((clause) => clause.field);
|
|
54
|
+
|
|
55
|
+
/** After a dismissal, focus lands on the next chip, or back on the trigger if that was the last. */
|
|
56
|
+
const drop = (id: string) => {
|
|
57
|
+
const index = props.modelValue.findIndex((c) => c.id === id);
|
|
58
|
+
const next = removeClause(props.modelValue, id);
|
|
59
|
+
emit('update:modelValue', next);
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
if (!next.length) props.trigger?.focus();
|
|
62
|
+
else chips.value?.[Math.min(index, next.length - 1)]?.openValues();
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const count = computed(() => props.modelValue.length);
|
|
67
|
+
|
|
68
|
+
defineExpose({ openClause });
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div role="group" aria-label="Filters" :class="theme.bar({ class: props.ui?.bar })">
|
|
73
|
+
<!-- Where <FFilterBar> puts the trigger, so the one-row case is a single flex flow: trigger and
|
|
74
|
+
chips wrap together rather than the chips wrapping inside a box of their own. Empty when the
|
|
75
|
+
trigger lives in another bar. -->
|
|
76
|
+
<slot name="trigger" />
|
|
77
|
+
|
|
78
|
+
<FFilterChip
|
|
79
|
+
v-for="clause in props.modelValue"
|
|
80
|
+
ref="chips"
|
|
81
|
+
:key="clause.id"
|
|
82
|
+
:clause="clause"
|
|
83
|
+
:schema="props.schema"
|
|
84
|
+
:used-fields="usedFieldsExcept(clause.id)"
|
|
85
|
+
:ui="props.ui"
|
|
86
|
+
@update="patch(clause.id, $event)"
|
|
87
|
+
@remove="drop(clause.id)"
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
<UButton
|
|
91
|
+
v-if="count > 1"
|
|
92
|
+
color="error"
|
|
93
|
+
variant="ghost"
|
|
94
|
+
size="sm"
|
|
95
|
+
label="Clear all"
|
|
96
|
+
:class="theme.clear({ class: props.ui?.clear })"
|
|
97
|
+
@click="emit('update:modelValue', [])"
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
<span class="sr-only" aria-live="polite">
|
|
101
|
+
{{ count ? `${count} ${count === 1 ? 'filter' : 'filters'} applied` : '' }}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The add-filter control: one dropdown of the schema's fields.
|
|
3
|
+
//
|
|
4
|
+
// Split out of <FFilterBar> so it can sit in a different row from the clauses it creates. The clause
|
|
5
|
+
// row wraps rather than scrolls, which is why it cannot share a row with navigation; this button is
|
|
6
|
+
// one control and never wraps, so it can. A console puts this in the navigation row and gives the
|
|
7
|
+
// clauses a row of their own.
|
|
8
|
+
//
|
|
9
|
+
// Controlled, like everything else here: the whole contract is a Clause[] in and out. Two components
|
|
10
|
+
// bound to the same v-model already share state, so nothing is teleported or injected between them.
|
|
11
|
+
//
|
|
12
|
+
// Fields are the top level; each enum field opens a submenu of its values as checkbox items
|
|
13
|
+
// (multi-select, the menu stays open while toggling). A scalar field has no values to enumerate, so
|
|
14
|
+
// selecting it creates the clause and hands the value to its chip.
|
|
15
|
+
import { computed, ref, useId } from 'vue';
|
|
16
|
+
import { filterTheme, type FilterSlot } from './filter.theme';
|
|
17
|
+
import type { FilterSchema } from './filterSchema';
|
|
18
|
+
import { addClause, findClauseByField, updateClause, type Clause } from './filterClause';
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
modelValue: Clause[];
|
|
22
|
+
schema: FilterSchema;
|
|
23
|
+
/**
|
|
24
|
+
* The clause row these clauses land in.
|
|
25
|
+
*
|
|
26
|
+
* Optional, and worth understanding what it does and does not buy. A NEW scalar clause opens its
|
|
27
|
+
* value control either way — the row opens any clause that arrives incomplete, which needs no
|
|
28
|
+
* wiring. This covers the other path: picking a field whose clause already exists AND is already
|
|
29
|
+
* filled, where nothing about the clause list changes and the row has nothing to react to.
|
|
30
|
+
*/
|
|
31
|
+
clauses?: { openClause: (id: string) => void } | null;
|
|
32
|
+
/**
|
|
33
|
+
* Text beside the icon. Off by default — the control reads as a funnel wherever it sits, and this
|
|
34
|
+
* one is usually sharing a navigation row where a word competes with the tabs. Pass a label on a
|
|
35
|
+
* surface where filtering is the page's whole job and the extra affordance is worth the width.
|
|
36
|
+
*/
|
|
37
|
+
label?: string;
|
|
38
|
+
ui?: Partial<Record<FilterSlot, string>>;
|
|
39
|
+
}>();
|
|
40
|
+
|
|
41
|
+
const emit = defineEmits<{ 'update:modelValue': [clauses: Clause[]] }>();
|
|
42
|
+
|
|
43
|
+
const theme = computed(() => filterTheme());
|
|
44
|
+
|
|
45
|
+
const open = ref(false);
|
|
46
|
+
|
|
47
|
+
// A UButton, so the ref is a component instance and focus goes through its root element.
|
|
48
|
+
const triggerRef = ref<{ $el?: HTMLElement } | null>(null);
|
|
49
|
+
const focus = () =>
|
|
50
|
+
triggerRef.value?.$el?.querySelector?.('button')?.focus() ?? triggerRef.value?.$el?.focus?.();
|
|
51
|
+
|
|
52
|
+
// useId() may only run during setup, never inside a handler — so this takes one prefix and counts off
|
|
53
|
+
// it. Ids only have to be unique within one filter's clause list.
|
|
54
|
+
const idPrefix = useId();
|
|
55
|
+
let seq = 0;
|
|
56
|
+
const nextId = () => `${idPrefix}-${++seq}`;
|
|
57
|
+
|
|
58
|
+
const fields = computed(() => Object.entries(props.schema));
|
|
59
|
+
|
|
60
|
+
const clauseFor = (fieldKey: string) => findClauseByField(props.modelValue, fieldKey);
|
|
61
|
+
|
|
62
|
+
const isValueSelected = (fieldKey: string, value: string) => {
|
|
63
|
+
const held = clauseFor(fieldKey)?.value;
|
|
64
|
+
return Array.isArray(held) && (held as string[]).includes(value);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// One dropdown item per field. Enum fields carry a submenu of checkbox items; scalar fields select
|
|
68
|
+
// straight through to a new clause. `checked` reads off the clauses so the ticks reflect current state.
|
|
69
|
+
const menuItems = computed(() =>
|
|
70
|
+
fields.value.map(([key, field]) => {
|
|
71
|
+
if (field.kind === 'enum') {
|
|
72
|
+
return {
|
|
73
|
+
label: field.label,
|
|
74
|
+
icon: field.icon,
|
|
75
|
+
children: (field.values ?? []).map((v) => ({
|
|
76
|
+
label: v.label,
|
|
77
|
+
icon: v.icon,
|
|
78
|
+
avatar: v.avatar,
|
|
79
|
+
type: 'checkbox' as const,
|
|
80
|
+
checked: isValueSelected(key, v.value),
|
|
81
|
+
// Keep the menu open so several values can be ticked in one visit.
|
|
82
|
+
onSelect: (event: Event) => event.preventDefault(),
|
|
83
|
+
onUpdateChecked: () => toggleValue(key, v.value),
|
|
84
|
+
})),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
label: field.label,
|
|
89
|
+
icon: field.icon,
|
|
90
|
+
onSelect: () => pickScalarField(key),
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Set for the one close that hands the caret to a chip. See `content` below.
|
|
96
|
+
let handingOver = false;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A scalar field (text/number/date/boolean): edit the existing chip, or create one — a new one
|
|
100
|
+
* appears empty and takes the caret itself, which is why nothing here focuses anything.
|
|
101
|
+
*/
|
|
102
|
+
const pickScalarField = (fieldKey: string) => {
|
|
103
|
+
const existing = clauseFor(fieldKey);
|
|
104
|
+
if (existing) {
|
|
105
|
+
// Already filtered AND already filled, so no chip appears and nothing self-focuses. Only claim
|
|
106
|
+
// the hand-off when there is something on the other end to receive it: with no clause row wired
|
|
107
|
+
// up, suppressing the menu's restore would drop the caret on the floor.
|
|
108
|
+
if (props.clauses) {
|
|
109
|
+
handingOver = true;
|
|
110
|
+
props.clauses.openClause(existing.id);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
handingOver = true;
|
|
115
|
+
emit('update:modelValue', addClause(props.modelValue, fieldKey, props.schema, nextId()));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Aligned to the trigger's leading edge, not centred on it: the menu is far wider than the icon it
|
|
119
|
+
// hangs off, and a centred panel drifts left of the bar it belongs to.
|
|
120
|
+
const content = {
|
|
121
|
+
align: 'start' as const,
|
|
122
|
+
// A dismissable layer returns the caret to its own trigger when it closes, and that runs AFTER the
|
|
123
|
+
// clause is created — so the empty chip is focused and then instantly unfocused, leaving an inert
|
|
124
|
+
// filter behind with the caret back on this button. Suppress the restore for that close only.
|
|
125
|
+
//
|
|
126
|
+
// Every other close keeps it: Escape, clicking away, or finishing with enum checkboxes all return
|
|
127
|
+
// the caret here, which is what a keyboard user needs when no chip has claimed it.
|
|
128
|
+
onCloseAutoFocus: (event: Event) => {
|
|
129
|
+
if (handingOver) event.preventDefault();
|
|
130
|
+
handingOver = false;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const toggleValue = (fieldKey: string, value: string) => {
|
|
135
|
+
const existing = clauseFor(fieldKey);
|
|
136
|
+
const id = existing?.id ?? nextId();
|
|
137
|
+
const base = existing ? props.modelValue : addClause(props.modelValue, fieldKey, props.schema, id);
|
|
138
|
+
const held = (existing?.value as string[]) ?? [];
|
|
139
|
+
const next = held.includes(value) ? held.filter((v) => v !== value) : [...held, value];
|
|
140
|
+
emit('update:modelValue', updateClause(base, id, { value: next }, props.schema));
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const count = computed(() => props.modelValue.length);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Carries the count, because the dot cannot.
|
|
147
|
+
*
|
|
148
|
+
* The applied clauses usually sit in plain sight beside or below this button, but not always — a
|
|
149
|
+
* clause row rendered behind a `v-if` on a narrow surface leaves the dot as the only sign anything
|
|
150
|
+
* is filtered, and a dot is invisible to a screen reader. Naming the count here means the control
|
|
151
|
+
* describes its own state wherever it is, without depending on the clause row being mounted.
|
|
152
|
+
*/
|
|
153
|
+
const ariaLabel = computed(() =>
|
|
154
|
+
count.value ? `Filter — ${count.value} applied` : 'Filter',
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
defineExpose({ open: () => { open.value = true; }, focus });
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<template>
|
|
161
|
+
<!-- A dot rather than a number: how MANY filters are applied is already legible from the chips
|
|
162
|
+
themselves, and duplicating it here would go stale in the corner of the eye. What the chips
|
|
163
|
+
cannot say is that something is applied when they are not on screen — which is the whole job of
|
|
164
|
+
the dot, and why it appears with the FIRST clause rather than at some threshold. `inset` pulls
|
|
165
|
+
it inside the button's corner so it does not enlarge the control's box.
|
|
166
|
+
OUTSIDE the menu, not between it and the button: the menu's trigger is `asChild`, so it merges
|
|
167
|
+
its click handling and aria-expanded into whatever element it wraps — put the chip in between
|
|
168
|
+
and all of that lands on the chip's positioning span instead of the button. -->
|
|
169
|
+
<UChip :show="count > 0" color="primary" size="sm" inset>
|
|
170
|
+
<UDropdownMenu v-model:open="open" :items="menuItems" :content="content">
|
|
171
|
+
<UButton
|
|
172
|
+
ref="triggerRef"
|
|
173
|
+
color="neutral"
|
|
174
|
+
variant="subtle"
|
|
175
|
+
size="sm"
|
|
176
|
+
icon="i-lucide-filter"
|
|
177
|
+
:label="props.label"
|
|
178
|
+
:aria-label="ariaLabel"
|
|
179
|
+
:class="theme.trigger({ class: props.ui?.trigger })"
|
|
180
|
+
/>
|
|
181
|
+
</UDropdownMenu>
|
|
182
|
+
</UChip>
|
|
183
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Presentation for FFilterBar and FFilterChip. Layout only — colour, radius, ring, halo and the
|
|
2
|
+
// coarse-pointer step-up all arrive with UButton and USelectMenu, so a chip follows --ui-radius and
|
|
3
|
+
// the control tokens without restating them.
|
|
4
|
+
import { tv } from 'tailwind-variants';
|
|
5
|
+
|
|
6
|
+
export const filterTheme = tv({
|
|
7
|
+
slots: {
|
|
8
|
+
// Wraps rather than scrolls: a horizontal scroller hides applied filters, which is the worse
|
|
9
|
+
// failure. Five clauses on a phone is a three-line bar.
|
|
10
|
+
bar: 'flex flex-wrap items-center gap-2',
|
|
11
|
+
// The chip's first segment: icon and field name.
|
|
12
|
+
field: 'inline-flex items-center gap-1.5',
|
|
13
|
+
// The enum value segment, which sizes to whatever its summary reads ("Active", "3 statuses").
|
|
14
|
+
// A floor keeps the chip still as the summary shortens and lengthens under it; a ceiling gives
|
|
15
|
+
// the summary something to truncate against, so one long label cannot wrap the segment to three
|
|
16
|
+
// lines and drag the whole chip out of shape.
|
|
17
|
+
count: 'min-w-24 max-w-40 whitespace-nowrap',
|
|
18
|
+
// A value that outgrows its segment is clipped to one line rather than wrapped.
|
|
19
|
+
value: 'min-w-0 truncate',
|
|
20
|
+
trigger: '',
|
|
21
|
+
clear: 'ms-auto',
|
|
22
|
+
// Stands in for the value summary while a clause is incomplete.
|
|
23
|
+
placeholder: 'text-dimmed',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type FilterSlot = keyof typeof filterTheme.slots;
|