Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions src/components/NcFormBox/NcFormBoxItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ const {
class?: VueClassType
/** Interactive item classes */
itemClasses?: VueClassType
/** Disable clickable overlay from the interactive item element to manually implement */
pure?: boolean
}>()

defineEmits<{
Expand All @@ -52,7 +54,15 @@ const slots = defineSlots<{
descriptionId?: string
}>
/** Icon content */
icon?: Slot
icon?: Slot<{
/** IDRef of the description element if present */
descriptionId?: string
}>
/** Extra content slot for additional overlays */
extra?: Slot<{
/** IDRef of the description element if present */
descriptionId?: string
}>
}>()

const { formBoxItemClass } = useNcFormBox()
Expand All @@ -72,18 +82,19 @@ const hasDescription = () => !!description || !!slots.description
[$style.formBoxItem_legacy]: isLegacy,
},
]">
<slot name="extra" :description-id />
<span :class="$style.formBoxItem__content">
<component
:is="tag"
:class="[$style.formBoxItem__element, itemClasses]"
:class="[$style.formBoxItem__element, itemClasses, { [$style.formBoxItem__element_clickable]: !pure }]"
v-bind="$attrs"
@click="$emit('click', $event)">
<slot :description-id>
{{ label || '⚠️ Label is missing' }}
</slot>
</component>
<span v-if="hasDescription()" :id="descriptionId" :class="$style.formBoxItem__description">
<slot name="description">
<slot name="description" :description-id>
{{ description }}
</slot>
</span>
Expand Down Expand Up @@ -169,7 +180,7 @@ const hasDescription = () => !!description || !!slots.description

// A trick for accessibility:
// make entire component clickable while internally splitting the interactive item and the description
.formBoxItem__element::after {
.formBoxItem__element_clickable::after {
content: '';
position: absolute;
inset: 0;
Expand Down
135 changes: 135 additions & 0 deletions src/components/NcFormBoxSelectNative/NcFormBoxSelectNative.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<script setup lang="ts" generic="T extends string">
import { mdiUnfoldMoreHorizontal } from '@mdi/js'
import { computed, useTemplateRef } from 'vue'
import NcFormBoxItem from '../NcFormBox/NcFormBoxItem.vue'
import NcIconSvgWrapper from '../NcIconSvgWrapper/NcIconSvgWrapper.vue'
import { createElementId } from '../../utils/createElementId.ts'

/** Selected value */
const modelValue = defineModel<T>({ required: true })

const {
label = undefined,
options,
disabled = false,
} = defineProps<{
/** Main label */
label?: string
/** Select options */
options: {
label: string
value: T
}[]
/** Disabled state */
disabled?: boolean
}>()

const selectId = createElementId()
const selectElement = useTemplateRef('select')

const selectedLabel = computed(() => options.find((option) => option.value === modelValue.value)?.label)

// .showPicker() is not available some browsers (e.g. Safari)
// When the method is not available, we keep select overlay clickable not invisible
// When the method is available, hidden select is not directly clickable but opens programmatically
// The last approach looks slightly better without focusing select by click
const isShowPickerAvailable = 'showPicker' in HTMLSelectElement.prototype

/**
* Handle label click to open the native select picker if possible.
*
* @param event - Click event
*/
function onLabelClick(event: MouseEvent) {
if (!isShowPickerAvailable) {
return
}
event?.preventDefault()
selectElement.value!.showPicker()
}
</script>

<template>
<NcFormBoxItem
tag="label"
:for="selectId"
:label
:description="selectedLabel"
:disabled
:pure="!isShowPickerAvailable"
inverted-accent
@click="onLabelClick">
<template #icon>
<NcIconSvgWrapper :path="mdiUnfoldMoreHorizontal" inline />
</template>
<template #extra="{ descriptionId }">
<select
:id="selectId"
ref="select"
v-model="modelValue"
:class="[$style.hiddenSelect, { [$style.hiddenSelect_manual]: isShowPickerAvailable }]"
:aria-describedby="descriptionId">
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</template>
</NcFormBoxItem>
</template>

<style lang="scss" module>
.hiddenSelect {
position: absolute;
inset: 0;
margin: 0;
height: auto;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only thing is that this makes it so that on macOS, the currently selected item's label is aligned to the top of the component, but in reality, the label is on the bottom.

Not sure if there is an easy fix for this though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you share a screenshot? I'm not sure I get what you mean.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your screenshot in the issue description also exhibits this issue. Basically, select on macOS (and to an extent iOS) moves the list up/down depending on the selected item to align the currently selected list item with the component.

Essentially, the label in the list and the label in the component shouldn't move when opening the list and should be perfectly (or at least closely) aligned.

Example of how it should look, seeing "Goldfish" only once:

image

whereas for us, comparatively, it is shifted up and you see "Never" twice:

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Example of how it should look, seeing "Goldfish" only once

But in the example the select is small, and our button is 40px.

If the entire button just were a select on the exact same place, it would look exactly the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not shifted relative to macOS's select's picker position

Copy link
Member

@kra-mo kra-mo Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better if I explain it this way: since the component is taller than list items, right now the list is basically aligned as if there was a less tall invisible select that was top-aligned.

But since our label is at the bottom, it should act like there is a less tall invisible select that is bottom-aligned instead.

Copy link
Member

@kra-mo kra-mo Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the entire button just were a select on the exact same place, it would look exactly the same.

And yes, but it should basically be as if only the bottom half were a select, since that is where our label is, not the top half. Not sure if this is easily doable though.

(Maybe if the text in the invisible select could be also bottom-aligned? But maybe it's not smart enough to pick up on that even if it is possible.)

cursor: pointer;
/* TODO: does it work well cross-browser? */
opacity: 0;
}

// Select is open manual instead of opening by click on invisible select
.hiddenSelect_manual {
pointer-events: none;
}
</style>

<docs>
### General

Native select wrapper to be used inside `NcFormBox`.

```vue
<script>
export default {
data() {
return {
playSoundChat: 'always',
playSoundCall: 'always',
enableCallbox: 'always',
notificationLevelOptions: [
{ label: 'Always', value: 'always' },
{ label: 'Only when away', value: 'away' },
{ label: 'Never', value: 'never' },
],
}
}
}
</script>

<template>
<NcFormGroup label="Notifications and sounds">
<NcFormBox>
<NcFormBoxSelectNative v-model="playSoundChat" label="Play chat notification sound" :options="notificationLevelOptions" />
<NcFormBoxSelectNative v-model="playSoundCall" label="Play call notification sound" :options="notificationLevelOptions" />
<NcFormBoxSelectNative v-model="enableCallbox" label="Show call notification popup" :options="notificationLevelOptions" />
</NcFormBox>
</NcFormGroup>
</template>
```
</docs>
6 changes: 6 additions & 0 deletions src/components/NcFormBoxSelectNative/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

export { default } from './NcFormBoxSelectNative.vue'
Loading