FE2 Viewer - Add Measure Mode (#1889)

* RadioGroup & Initial UI for Measure

* Add option to Panel to allow actions to move to bottom

* Typo

* Add count to precision

* Add enable, snap and type api integrations

* Update Units WIP

* Add precision update

* Update v-tippy name

* Updates

* New design

* Better darkmode radio. Keystrokes.

* Styling fixes. Fix select mount-menu-on-body

* Fix ts bug

* Show label in Select for units

* Update shortcut to D

* Small design changes

* Small tidy ups

* WIP New Measurements Helper State

* Fix build erros

* Remove viewer import from shared

* Delete WIP

* Fix delete

* Fix close button on measure mode

* Measurement nullable

* Updates from PR

* Seperate measurements into measurementsEnabled & measurementOptions

* Update state.ts

* Update ts bugs

* Updates to RadioGroup

* Use ctx.updateArgs

* Replace RadioGroup with Radio - More consistent with existing inputs

* Update FE2 to use new Radio

* Fix circleci fail

* Fix build

* Fix wrong initial state for vertexSnap

* Adjust type to measurement

* Use Lodash isEqual

* Fix bug where units don't update

* Remove double input

* Fix server error in data.ts

* Revert change around useEqual
This commit is contained in:
andrewwallacespeckle
2023-12-06 09:56:22 +00:00
committed by GitHub
parent a6b7266b85
commit c8bdf01cdd
17 changed files with 1030 additions and 14 deletions
@@ -0,0 +1,90 @@
<template>
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1.5 23.9655V3.00003L10.5 10.8621V31.5L1.5 23.9655Z"
stroke="#CBD5E1"
stroke-linejoin="round"
/>
<path
d="M1.5 3.00003L22.5 1.50003"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1.5 24L22.5 22.5"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="2 2"
/>
<path
d="M10.5 31.5L31.5 30"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M10.5 11.25L31.5 9.75003"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M22.5 1.50003L31.5 9.34814V30"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M22.5 1.50003V22.5"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="2 2"
/>
<path
d="M22.5 22.5L31.5 30"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="2 2"
/>
<path
d="M6.04926 17.378L27.0493 15.878"
stroke="#3B82F6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.37823 19.3902L7.10927 15.3896"
stroke="#3B82F6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M25.6345 17.8902L28.3656 13.8896"
stroke="#3B82F6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.96887 14.093L7.03114 20.663"
stroke="#3B82F6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M26.2251 12.593L28.2874 19.163"
stroke="#3B82F6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
@@ -0,0 +1,68 @@
<template>
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 25.4655V4.50003L12 12.3621V33L3 25.4655Z"
stroke="#CBD5E1"
stroke-linejoin="round"
/>
<path
d="M3 4.50003L24 3.00003"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3 25.5L24 24"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="2 2"
/>
<path
d="M12 33L33 31.5"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 12.75L33 11.25"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M24 3.00003L33 10.8481V31.5"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M24 3.00003V24"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="2 2"
/>
<path
d="M24 24L33 31.5"
stroke="#CBD5E1"
stroke-linecap="round"
stroke-linejoin="round"
stroke-dasharray="2 2"
/>
<path
d="M3 25.5L33 10.5"
stroke="#3B82F6"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="3" cy="25.5" r="2.25" fill="#3B82F6" />
<circle cx="33" cy="10.5" r="2.25" fill="#3B82F6" />
</svg>
</template>
@@ -0,0 +1,27 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon icon-tabler icon-tabler-ruler-measure"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M19.875 12c.621 0 1.125 .512 1.125 1.143v5.714c0 .631 -.504 1.143 -1.125 1.143h-15.875a1 1 0 0 1 -1 -1v-5.857c0 -.631 .504 -1.143 1.125 -1.143h15.75z"
/>
<path d="M9 12v2" />
<path d="M6 12v3" />
<path d="M12 12v3" />
<path d="M18 12v3" />
<path d="M15 12v2" />
<path d="M3 3v4" />
<path d="M3 5h18" />
<path d="M21 3v4" />
</svg>
</template>
@@ -3,6 +3,7 @@
<div
class="absolute z-20 flex h-[100dvh] flex-col space-y-2 bg-green-300/0 px-2 pt-[4.2rem]"
>
<!-- Models -->
<ViewerControlsButtonToggle
v-tippy="modelsShortcut"
:active="activeControl === 'models'"
@@ -10,6 +11,8 @@
>
<CubeIcon class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Explorer -->
<ViewerControlsButtonToggle
v-tippy="explorerShortcut"
:active="activeControl === 'explorer'"
@@ -18,6 +21,15 @@
<IconFileExplorer class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- Measurements -->
<ViewerControlsButtonToggle
v-tippy="measureShortcut"
:active="activeControl === 'measurements'"
@click="toggleMeasurements"
>
<IconMeasurements class="h-5 w-5" />
</ViewerControlsButtonToggle>
<!-- TODO -->
<!-- <ViewerControlsButtonToggle
:active="activeControl === 'filters'"
@@ -107,6 +119,11 @@
: '-translate-x-[100%] opacity-0'
}`"
>
<div v-show="activeControl.length !== 0 && activeControl === 'measurements'">
<KeepAlive>
<div><ViewerMeasurementsOptions @close="toggleMeasurements" /></div>
</KeepAlive>
</div>
<div v-show="resourceItems.length !== 0 && activeControl === 'models'">
<KeepAlive>
<div>
@@ -173,7 +190,8 @@ import {
import type { Nullable } from '@speckle/shared'
import {
useCameraUtilities,
useSectionBoxUtilities
useSectionBoxUtilities,
useMeasurementUtilities
} from '~~/lib/viewer/composables/ui'
import {
onKeyboardShortcut,
@@ -200,6 +218,8 @@ const { resourceItems, modelsAndVersionIds } = useInjectedViewerLoadedResources(
const { toggleSectionBox, isSectionBoxEnabled } = useSectionBoxUtilities()
const { enableMeasurements } = useMeasurementUtilities()
const allAutomationRuns = computed(() => {
const allAutomationStatuses = modelsAndVersionIds.value
.map((model) => model.model.loadedVersion.items[0].automationStatus)
@@ -269,6 +289,7 @@ type ActiveControl =
| 'filters'
| 'discussions'
| 'automate'
| 'measurements'
const openAddModel = ref(false)
@@ -297,6 +318,9 @@ const projectionShortcut = ref(
const sectionBoxShortcut = ref(
`Section Box (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'b'])})`
)
const measureShortcut = ref(
`Measure Mode (${getKeyboardShortcutTitle([ModifierKeys.AltOrOpt, 'd'])})`
)
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
@@ -317,6 +341,9 @@ onKeyboardShortcut([ModifierKeys.AltOrOpt], 'f', () => {
onKeyboardShortcut([ModifierKeys.AltOrOpt], ['t'], () => {
toggleActiveControl('discussions')
})
onKeyboardShortcut([ModifierKeys.AltOrOpt], 'd', () => {
toggleActiveControl('measurements')
})
// Viewer actions kbd shortcuts
onKeyboardShortcut([ModifierKeys.AltOrOpt], ' ', () => {
@@ -362,6 +389,12 @@ const scrollControlsToBottom = () => {
// scrollToBottom(scrollableControlsContainer.value)
}
const toggleMeasurements = () => {
const isMeasurementsActive = activeControl.value === 'measurements'
enableMeasurements(!isMeasurementsActive)
activeControl.value = isMeasurementsActive ? 'none' : 'measurements'
}
onMounted(() => {
activeControl.value = isSmallerOrEqualSm.value ? 'none' : 'models'
})
@@ -1,6 +1,6 @@
<template>
<div class="bg-foundation sm:rounded-lg overflow-hidden shadow">
<div class="sticky top-0 z-50 flex flex-col bg-foundation shadow-md">
<div class="bg-foundation sm:rounded-lg overflow-hidden shadow flex flex-col">
<div class="sticky top-0 z-50 flex flex-col bg-foundation">
<div v-if="!hideClose" class="absolute top-2 right-2 sm:right-0 z-10">
<FormButton size="sm" color="secondary" text @click="$emit('close')">
<XMarkIcon class="h-4 w-4 sm:h-3 sm:w-3 text-primary sm:text-foreground" />
@@ -8,7 +8,7 @@
</div>
<div
v-if="$slots.title"
class="flex items-center h-10 px-3 border-b border-outline dark:border-foundation-2 bg-foundation rounded-t"
class="flex items-center h-10 px-3 border-b border-outline-3 dark:border-foundation-2 bg-foundation rounded-t"
>
<div
class="flex items-center h-full w-full pr-8 font-semibold sm:font-bold text-sm text-primary"
@@ -18,11 +18,19 @@
</span>
</div>
</div>
<div v-if="$slots.actions" class="flex items-center h-8 sm:h-10 gap-2 px-2">
<slot name="actions"></slot>
</div>
</div>
<div>
<div
v-if="$slots.actions"
class="flex items-center h-8 sm:h-10 gap-2 px-2"
:class="
moveActionsToBottom
? 'order-3 border-t border-outline-3 mt-2'
: 'order-2 shadow-md'
"
>
<slot name="actions"></slot>
</div>
<div :class="moveActionsToBottom ? 'order-2' : 'order-3'">
<slot></slot>
</div>
</div>
@@ -34,6 +42,10 @@ defineProps({
hideClose: {
type: Boolean,
default: false
},
moveActionsToBottom: {
type: Boolean,
default: false
}
})
</script>
@@ -0,0 +1,144 @@
<template>
<ViewerLayoutPanel move-actions-to-bottom @close="$emit('close')">
<template #title>Measure Mode</template>
<div
class="flex items-center gap-2 text-sm px-3 py-2 border-b border-outline-3 text-foreground-2"
>
<InformationCircleIcon class="h-6 h-6 shrink-0" />
<span class="max-w-[210px]">
Reloading the model will delete all measurements.
</span>
</div>
<template #actions>
<FormButton
size="sm"
text
color="danger"
:icon-left="TrashIcon"
class="font-normal py-1"
@click="() => removeMeasurement()"
>
Delete Selected
</FormButton>
</template>
<div class="p-3 flex flex-col gap-3 border-b border-outline-3">
<div>
<h6 class="font-semibold text-sm mb-2">Measurement Type</h6>
<FormRadio
v-for="option in measurementTypeOptions"
:key="option.value"
:label="option.title"
:value="option.value.toString()"
name="measurementType"
:icon="option.icon"
:checked="measurementParams.type === option.value"
@change="updateMeasurementsType(option)"
/>
</div>
</div>
<div class="p-3 flex items-center border-b border-outline-3">
<FormCheckbox
name="Snap to Objects"
hide-label
:model-value="measurementParams.vertexSnap"
@update:model-value="() => toggleMeasurementsSnap()"
/>
<span class="font-normal text-sm">Snap to Vertices</span>
</div>
<div class="p-3 flex flex-col gap-3">
<div class="flex flex-col gap-2">
<h6 class="font-semibold text-sm">Units</h6>
<ViewerMeasurementsUnitSelect
v-model="selectedUnit"
mount-menu-on-body
@update:model-value="onChangeMeasurementUnits"
/>
</div>
<div class="flex flex-col gap-3">
<label class="font-semibold text-sm" for="precision">Precision</label>
<div class="flex gap-2 items-center">
<input
id="precision"
v-model="measurementPrecision"
class="h-2 mr-2 flex-1"
type="range"
min="1"
max="5"
step="1"
:onchange="onChangeMeasurementPrecision"
/>
<span class="text-xs w-4">{{ measurementPrecision }}</span>
</div>
</div>
</div>
</ViewerLayoutPanel>
</template>
<script setup lang="ts">
import { InformationCircleIcon, TrashIcon } from '@heroicons/vue/24/outline'
import { FormRadio } from '@speckle/ui-components'
import { MeasurementType } from '@speckle/viewer'
import { useMeasurementUtilities } from '~~/lib/viewer/composables/ui'
import { resolveComponent } from 'vue'
import type { ConcreteComponent } from 'vue'
interface MeasurementTypeOption {
title: string
value: MeasurementType
}
defineEmits(['close'])
const measurementPrecision = ref(2)
const selectedUnit = ref('m')
const measurementParams = ref({
visible: true,
type: MeasurementType.POINTTOPOINT,
vertexSnap: true,
units: selectedUnit.value,
precision: measurementPrecision.value
})
const { setMeasurementOptions, removeMeasurement } = useMeasurementUtilities()
const updateMeasurementsType = (selectedOption: MeasurementTypeOption) => {
measurementParams.value.type = selectedOption.value
setMeasurementOptions(measurementParams.value)
}
const onChangeMeasurementUnits = (newUnit: string) => {
selectedUnit.value = newUnit
measurementParams.value.units = newUnit
setMeasurementOptions(measurementParams.value)
}
const toggleMeasurementsSnap = () => {
measurementParams.value.vertexSnap = !measurementParams.value.vertexSnap
setMeasurementOptions(measurementParams.value)
}
const onChangeMeasurementPrecision = () => {
measurementParams.value.precision = measurementPrecision.value
setMeasurementOptions(measurementParams.value)
}
const IconPointToPoint = resolveComponent(
'IconMeasurePointToPoint'
) as ConcreteComponent
const IconPerpendicular = resolveComponent(
'IconMeasurePerpendicular'
) as ConcreteComponent
const measurementTypeOptions = [
{
title: 'Point to Point',
icon: IconPointToPoint,
value: MeasurementType.POINTTOPOINT
},
{
title: 'Perpendicular',
icon: IconPerpendicular,
value: MeasurementType.PERPENDICULAR
}
]
</script>
@@ -0,0 +1,59 @@
<template>
<FormSelectBase
v-model="selectedUnit"
:items="units"
label="Unit"
:show-label="false"
:name="name || 'units'"
:allow-unset="false"
>
<template #something-selected>
<div>{{ fullUnitName }}</div>
</template>
<template #option="{ item }">
<div class="flex flex-col">
<div class="label text-xs">{{ unitDisplayNames[item] || item }}</div>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
type UnitDisplayNames = {
[unit: string]: string
}
const emit = defineEmits(['update:modelValue'])
const props = defineProps<{
modelValue: string
name?: string
}>()
// Use a ref for unitDisplayNames
const unitDisplayNames = ref<UnitDisplayNames>({
mm: 'Millimeters',
cm: 'Centimeters',
m: 'Meters',
km: 'Kilometers',
in: 'Inches',
ft: 'Feet',
yd: 'Yards',
mi: 'Miles'
})
function getFullUnitName(unit: string): string {
return unitDisplayNames.value[unit] || unit
}
const fullUnitName = computed(() => getFullUnitName(props.modelValue))
const units = computed(() => Object.keys(unitDisplayNames.value))
const selectedUnit = computed({
get: () => props.modelValue,
set: (newVal) => emit('update:modelValue', newVal)
})
</script>
@@ -115,7 +115,11 @@ export function useStateSerialization() {
: null,
lightConfig: { ...state.ui.lightConfig.value },
explodeFactor: state.ui.explodeFactor.value,
selection: state.ui.selection.value?.toArray() || null
selection: state.ui.selection.value?.toArray() || null,
measurement: {
enabled: state.ui.measurement.enabled.value,
options: state.ui.measurement.options.value
}
}
}
return ret
@@ -5,13 +5,15 @@ import {
WorldTree,
ViewerEvent,
DefaultLightConfiguration,
VisualDiffMode
VisualDiffMode,
MeasurementType
} from '@speckle/viewer'
import type {
FilteringState,
PropertyInfo,
SunLightConfiguration,
SpeckleView,
MeasurementOptions,
DiffResult
} from '@speckle/viewer'
import type { MaybeRef } from '@vueuse/shared'
@@ -251,6 +253,10 @@ export type InjectableViewerState = Readonly<{
explodeFactor: Ref<number>
viewerBusy: WritableComputedRef<boolean>
selection: Ref<Nullable<Vector3>>
measurement: {
enabled: Ref<boolean>
options: Ref<MeasurementOptions>
}
}
/**
* State stored in the anchor string of the URL
@@ -874,7 +880,17 @@ function setupInterfaceState(
},
hasAnyFiltersApplied
},
highlightedObjectIds
highlightedObjectIds,
measurement: {
enabled: ref(false),
options: ref<MeasurementOptions>({
visible: true,
type: MeasurementType.POINTTOPOINT,
units: 'm',
vertexSnap: true,
precision: 2
})
}
}
}
}
@@ -717,6 +717,33 @@ function useDiffingIntegration() {
})
}
function useViewerMeasurementIntegration() {
const {
ui: { measurement },
viewer: { instance }
} = useInjectedViewerState()
watch(
() => measurement.enabled.value,
(newVal, oldVal) => {
if (newVal !== oldVal) {
instance.enableMeasurements(newVal)
}
},
{ immediate: true }
)
watch(
() => ({ ...measurement.options.value }),
(newMeasurementState) => {
if (newMeasurementState) {
instance.setMeasurementOptions(newMeasurementState)
}
},
{ immediate: true, deep: true }
)
}
export function useViewerPostSetup() {
if (process.server) return
useViewerObjectAutoLoading()
@@ -731,5 +758,6 @@ export function useViewerPostSetup() {
useLightConfigIntegration()
useExplodeFactorIntegration()
useDiffingIntegration()
useViewerMeasurementIntegration()
setupDebugMode()
}
@@ -1,5 +1,5 @@
import { SpeckleViewer, timeoutAt } from '@speckle/shared'
import type { PropertyInfo } from '@speckle/viewer'
import type { MeasurementOptions, PropertyInfo } from '@speckle/viewer'
import { until } from '@vueuse/shared'
import { difference, isString, uniq } from 'lodash-es'
import type { SpeckleObject } from '~~/lib/common/helpers/sceneExplorer'
@@ -339,3 +339,27 @@ export function useThreadUtilities() {
return { closeAllThreads, open, isOpenThread }
}
export function useMeasurementUtilities() {
const state = useInjectedViewerState()
const enableMeasurements = (enabled: boolean) => {
state.ui.measurement.enabled.value = enabled
}
const setMeasurementOptions = (options: MeasurementOptions) => {
state.ui.measurement.options.value = options
}
const removeMeasurement = () => {
if (state.viewer.instance?.removeMeasurement) {
state.viewer.instance.removeMeasurement()
}
}
return {
enableMeasurements,
setMeasurementOptions,
removeMeasurement
}
}
@@ -167,6 +167,10 @@ export async function convertLegacyDataToState(
command: null,
mode: 1,
time: 0.5
},
measurement: {
enabled: false,
options: null
}
}
}
+35 -1
View File
@@ -3,6 +3,19 @@ import type { MaybeNullOrUndefined, Nullable } from '../../core/helpers/utilityT
import type { PartialDeep } from 'type-fest'
import { UnformattableSerializedViewerStateError } from '../errors'
enum MeasurementType {
PERPENDICULAR = 0,
POINTTOPOINT = 1
}
interface MeasurementOptions {
visible: boolean
type?: MeasurementType
vertexSnap?: boolean
units?: string
precision?: number
}
/**
* v1 -> v1.1
* - ui.filters.propertyFilter.isApplied field added
@@ -73,6 +86,10 @@ export type SerializedViewerState = {
}
explodeFactor: number
selection: Nullable<number[]>
measurement: {
enabled: boolean
options: Nullable<MeasurementOptions>
}
}
}
@@ -106,6 +123,19 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
)
}
const defaultMeasurementOptions: MeasurementOptions = {
visible: false,
type: MeasurementType.POINTTOPOINT,
vertexSnap: false,
units: 'm',
precision: 2
}
const measurementOptions = {
...defaultMeasurementOptions,
...state.ui?.measurement?.options
}
return {
projectId: state.projectId || throwInvalidError('projectId'),
sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`,
@@ -183,7 +213,11 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
azimuth: state.ui?.lightConfig?.azimuth
},
explodeFactor: state.ui?.explodeFactor || 0,
selection: state.ui?.selection || null
selection: state.ui?.selection || null,
measurement: {
enabled: state.ui?.measurement?.enabled ?? false,
options: measurementOptions
}
}
}
}
@@ -0,0 +1,232 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Radio from '~~/src/components/form/Radio.vue'
import FormButton from '~~/src/components/form/Button.vue'
import type { Meta, StoryObj } from '@storybook/vue3'
import { action } from '@storybook/addon-actions'
import type { RuleExpression, SubmissionHandler } from 'vee-validate'
import { Form } from 'vee-validate'
import { userEvent, within } from '@storybook/testing-library'
import { wait } from '@speckle/shared'
import type { Optional } from '@speckle/shared'
import { expect } from '@storybook/jest'
import type { VuePlayFunction } from '~~/src/stories/helpers/storybook'
import { computed, type ConcreteComponent } from 'vue'
import { ArrowRightIcon } from '@heroicons/vue/20/solid'
export default {
component: Radio,
parameters: {
docs: {
description: {
component:
'A radio, integrated with vee-validate for validation. Feed in rules through the `rules` prop. A radio can exist on its own or as part of a group. Radios are grouped if they have the same name and have a parent form. The value structure differs between grouped and ungrouped radios. If a radio is grouped, its v-model value will be an array of all values of all checked radios in the group. Otherwise, its v-model value will either be its value if its checked or undefined if it isnt.'
}
}
},
argTypes: {
value: {
type: 'string'
},
rules: {
type: 'function'
},
'update:modelValue': {
type: 'function',
action: 'v-model'
},
id: {
type: 'string'
}
}
} as Meta
const toggleRadioPlayFunction: VuePlayFunction = async (params) => {
const canvas = within(params.canvasElement)
const radio = canvas.getByRole('radio') as HTMLInputElement
userEvent.click(radio)
// expect(radio.checked).toBeTruthy()
await wait(1000)
userEvent.click(radio)
// expect(radio.checked).toBeFalsy()
}
const defaultArgs = {
name: 'test1',
label: 'Example Radio',
description: 'Some help description here',
showRequired: false,
validateOnMount: false,
inlineDescription: false,
value: 'test1' as string | true,
disabled: false,
modelValue: undefined as Optional<string | true>,
rules: undefined as Optional<RuleExpression<any>[]>,
icon: undefined as Optional<ConcreteComponent>
}
export const Default: StoryObj<typeof defaultArgs> = {
play: toggleRadioPlayFunction,
render: (args, ctx) => ({
components: { Radio },
setup() {
const vModelAction = action('v-model')
const modelValue = computed({
get: () => args.modelValue,
set: (newVal) => {
ctx.updateArgs({ ...args, modelValue: newVal })
vModelAction(newVal)
}
})
return { args, modelValue }
},
template: `<Radio v-bind="args" v-model="modelValue" />`
}),
parameters: {
docs: {
source: {
code: `<Radio name="group-name" value="radio-id" v-model="val" />`
}
}
},
args: {
name: 'test1',
label: 'Example Radio',
description: 'Some help description here',
showRequired: false,
validateOnMount: false,
inlineDescription: false,
value: 'test1',
disabled: false,
modelValue: undefined
}
}
export const Group: StoryObj<typeof defaultArgs> = {
render: (args) => ({
components: { Radio, Form, FormButton },
setup() {
const fooModelValueHandler = action('foo@update:modelValue')
const barModelValueHandler = action('bar@update:modelValue')
const onSubmit: SubmissionHandler = (values) => action('onSubmit')(values)
return { fooModelValueHandler, barModelValueHandler, onSubmit, args }
},
template: `
<Form @submit="onSubmit">
<Radio name="group1" value="foo" label="foo" @update:modelValue="fooModelValueHandler" alt="foo"/>
<Radio name="group1" value="bar" label="bar" @update:modelValue="barModelValueHandler" alt="bar"/>
<FormButton submit class="mt-4">Submit</FormButton>
</Form>
`
}),
play: async (params) => {
const smallDelay = 500
const bigDelay = 1000
const canvas = within(params.canvasElement)
const fooRadio = canvas.getByAltText('foo')
const barRadio = canvas.getByAltText('bar')
const button = canvas.getByRole('button')
userEvent.click(fooRadio)
await wait(smallDelay)
userEvent.click(button)
await wait(bigDelay)
userEvent.click(barRadio)
await wait(smallDelay)
userEvent.click(button)
await wait(bigDelay)
userEvent.click(barRadio)
await wait(smallDelay)
userEvent.click(barRadio)
await wait(smallDelay)
userEvent.click(button)
},
parameters: {
docs: {
description: {
story:
'Radios with the same name are part of a group and on form submit the value will be an array of all selected values. Check actions of this story for an example!'
}
}
}
}
export const InlineDescription: StoryObj<typeof defaultArgs> = {
...Default,
args: {
name: 'inline1',
value: 'inline1-value',
inlineDescription: true,
label: 'Example radio',
description: 'This is an inline description'
}
}
export const WithIcon: StoryObj<typeof defaultArgs> = {
...Default,
args: {
name: 'withIcon',
label: 'Example radio with Icon',
icon: ArrowRightIcon
}
}
export const Disabled: StoryObj<typeof defaultArgs> = {
...Default,
play: (params) => {
const canvas = within(params.canvasElement)
const radio = canvas.getByRole('radio')
const isChecked = (radio as HTMLInputElement).checked
// click and assert that radio checked state hasn't changed
userEvent.click(radio)
const newIsChecked = (radio as HTMLInputElement).checked
expect(isChecked).toBe(newIsChecked)
},
args: {
name: 'disabled1',
value: 'disabled1-value',
label: 'Disabled radio',
disabled: true
}
}
export const Required: StoryObj<typeof defaultArgs> = {
...Default,
args: {
name: 'required1',
value: 'required1-value',
label: 'Required radio',
showRequired: true,
rules: [(val: string | string[]) => (val ? true : 'This field is required')],
validateOnMount: true
}
}
export const Single: StoryObj<typeof defaultArgs> = {
...Default,
args: {
...Default.args,
label: 'Single radio',
name: 'single1',
value: true
},
parameters: {
docs: {
description: {
story:
'Set `value` to `true` for non-grouped radios. That way v-model will be `undefined` if unchecked or `true` if checked.'
}
}
}
}
@@ -0,0 +1,228 @@
<template>
<div
class="relative flex gap-2 mb-2 last:mb-0"
:class="description ? 'items-start' : 'items-center'"
>
<div class="flex h-6 items-center">
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -->
<input
:id="finalId"
:checked="coreChecked"
:aria-describedby="descriptionId"
:name="name"
:disabled="disabled"
:value="radioValue"
type="radio"
class="h-4 w-4 rounded-full text-primary focus:ring-primary bg-foundation disabled:cursor-not-allowed disabled:bg-disabled disabled:text-disabled-2"
:class="computedClasses"
v-bind="$attrs"
@change="onChange"
/>
</div>
<div v-if="icon" class="text-sm">
<component :is="icon" class="h-10 w-10"></component>
</div>
<div class="text-sm">
<label :for="finalId" class="text-foreground" :class="{ 'sr-only': hideLabel }">
<span>{{ title }}</span>
<span v-if="showRequired" class="text-danger ml-1">*</span>
</label>
<p v-if="descriptionText" :id="descriptionId" :class="descriptionClasses">
{{ descriptionText }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { useField } from 'vee-validate'
import type { RuleExpression } from 'vee-validate'
import { computed, onMounted, ref } from 'vue'
import type { PropType, ConcreteComponent } from 'vue'
import type { Optional } from '@speckle/shared'
import { nanoid } from 'nanoid'
/**
* Troubleshooting:
* - If clicking on the radio doesn't do anything, check if any of its ancestor elements
* have a @click.prevent on them anywhere.
* - If you're not using the radio in a group, it's suggested that you set :value="true",
* so that a v-model attached to the radio will be either 'true' or 'undefined' depending on the
* checked state
*/
type ValueType = Optional<string | true> | string[]
defineOptions({
inheritAttrs: false
})
const props = defineProps({
/**
* Input name/id. In a radio group, all radios must have the same name and different values.
*/
name: {
type: String,
required: true
},
/**
* Whether the input is disabled
*/
disabled: {
type: Boolean,
default: false
},
/**
* Set label text
*/
label: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Help text
*/
description: {
type: String as PropType<Optional<string>>,
default: undefined
},
/**
* Whether to inline the help description
*/
inlineDescription: {
type: Boolean,
default: false
},
/**
* Optional Icon
*/
icon: {
type: Object as PropType<ConcreteComponent>,
default: undefined
},
/**
* vee-validate validation rules
*/
rules: {
type: [String, Object, Function, Array] as PropType<RuleExpression<ValueType>>,
default: undefined
},
/**
* vee-validate validation() on component mount
*/
validateOnMount: {
type: Boolean,
default: false
},
/**
* Whether to show the red "required" asterisk
*/
showRequired: {
type: Boolean,
default: false
},
/**
* Radio group's value
*/
modelValue: {
type: [String, Boolean] as PropType<ValueType | false>,
default: undefined
},
/**
* Radio's own value. If it is checked, modelValue will include this value (amongst any other checked values from the same group).
* If not set will default to 'name' value.
*/
value: {
type: [String, Boolean] as PropType<Optional<string | true>>,
default: true
},
/**
* HTML ID to use, must be globally unique. If not specified, a random ID will be generated. One is necessary to properly associate the label and radio.
*/
id: {
type: String as PropType<Optional<string>>,
default: undefined
},
hideLabel: {
type: Boolean,
default: false
}
})
const generateRandomId = (prefix: string) => `${prefix}-${nanoid()}`
defineEmits<{
(e: 'update:modelValue', val: ValueType): void
}>()
const radioValue = computed(() => props.value || props.name)
const {
checked: coreChecked,
errorMessage,
handleChange,
value: coreValue
} = useField<ValueType>(props.name, props.rules, {
validateOnMount: props.validateOnMount,
type: 'radio',
checkedValue: radioValue,
initialValue: props.modelValue || undefined
})
const title = computed(() => props.label || props.name)
const computedClasses = computed((): string => {
return errorMessage.value ? 'border-danger-lighter' : 'border-foreground-4 '
})
const descriptionText = computed(() => props.description || errorMessage.value)
const descriptionId = computed(() => `${props.name}-description`)
const descriptionClasses = computed((): string => {
const classParts: string[] = []
if (props.inlineDescription) {
classParts.push('inline ml-2')
} else {
classParts.push('block')
}
if (errorMessage.value) {
classParts.push('text-danger')
} else {
classParts.push('text-foreground-2')
}
return classParts.join(' ')
})
const implicitId = ref<Optional<string>>(generateRandomId('radio'))
const finalId = computed(() => props.id || implicitId.value)
const onChange = (e: unknown) => {
if (props.disabled) return
handleChange(e)
}
/**
* Bugfix for strange issue where radio appears checked even tho it shouldnt be.
* It's not clear why this happens, but for some reason coreValue.value shows that the radio
* is checked, even tho props.modelValue is undefined.
*/
onMounted(() => {
const newModelValue = props.modelValue
const newCoreValue = coreValue.value
const shouldBeChecked = Array.isArray(newModelValue)
? newModelValue.includes(props.value as any)
: newModelValue === props.value
const isCoreChecked = Array.isArray(newCoreValue)
? newCoreValue.includes(props.value as any)
: newCoreValue === props.value
if (shouldBeChecked !== isCoreChecked) {
handleChange(newModelValue)
}
})
</script>
@@ -77,6 +77,7 @@
>
<Teleport to="body" :disabled="!mountMenuOnBody">
<ListboxOptions
ref="menuEl"
:class="listboxOptionsClasses"
:style="listboxOptionsStyle"
@focus="searchInput?.focus()"
@@ -207,7 +208,7 @@ import type { RuleExpression } from 'vee-validate'
import { nanoid } from 'nanoid'
import CommonLoadingBar from '~~/src/components/common/loading/Bar.vue'
import { directive as vTippy } from 'vue-tippy'
import { useElementBounding, useMounted } from '@vueuse/core'
import { useElementBounding, useMounted, useIntersectionObserver } from '@vueuse/core'
type ButtonStyle = 'base' | 'simple' | 'tinted'
type ValueType = SingleItem | SingleItem[] | undefined
@@ -398,6 +399,7 @@ const { value, errorMessage: error } = useField<ValueType>(props.name, props.rul
const isMounted = useMounted()
const searchInput = ref(null as Nullable<HTMLInputElement>)
const menuEl = ref(null as Nullable<{ el: Nullable<HTMLElement> }>)
const listboxButton = ref(null as Nullable<{ el: Nullable<HTMLButtonElement> }>)
const searchValue = ref('')
const currentItems = ref([]) as Ref<SingleItem[]>
@@ -410,6 +412,15 @@ const listboxButtonBounding = useElementBounding(
{ windowResize: true, windowScroll: true, immediate: true }
)
useIntersectionObserver(
computed(() => menuEl.value?.el),
([{ isIntersecting }]) => {
if (isIntersecting && props.mountMenuOnBody) {
listboxButtonBounding.update()
}
}
)
const title = computed(() => unref(props.label) || unref(props.name))
const errorMessage = computed(() => {
const base = error.value
+2
View File
@@ -16,6 +16,7 @@ import CommonStepsNumber from '~~/src/components/common/steps/Number.vue'
import CommonStepsBullet from '~~/src/components/common/steps/Bullet.vue'
import FormCardButton from '~~/src/components/form/CardButton.vue'
import FormCheckbox from '~~/src/components/form/Checkbox.vue'
import FormRadio from '~~/src/components/form/Radio.vue'
import FormTextArea from '~~/src/components/form/TextArea.vue'
import FormTextInput from '~~/src/components/form/TextInput.vue'
import * as ValidationHelpers from '~~/src/helpers/common/validation'
@@ -95,6 +96,7 @@ export {
CommonStepsNumber,
FormCardButton,
FormCheckbox,
FormRadio,
FormTextArea,
FormTextInput,
FormSwitch,