fix(stage-tamagotchi): prevent controls island overflow in small windows with scroll (#2474)

## Description

<!-- Please insert your description here and especially provide info
about the "what" this PR is solving -->

### As-is
Depending on the size, the Controls Island area gets cut off, making the
buttons in the cropped area inaccessible.

### To-be
The Controls Island is managed via scrolling, allowing interaction with
buttons regardless of the size.
If the main controls also exceed the available space, the entire
Controls Island becomes scrollable. Horizontal scrolling is available
for narrow windows.

#### narrow & smaill

https://github.com/user-attachments/assets/1a44ff4f-8fe2-4a05-96a8-59ebf7dafcff

#### somewhat generous size


https://github.com/user-attachments/assets/c73dbee4-8c8f-4937-ae1a-eda00178274c



## Linked Issues



<!-- Optional, if you have any -->
close #2400

## Additional Context

<!-- e.g. is there anything you'd like reviewers to focus on? -->
Although the size is somewhat less than ideal, the issue has been
resolved, and this serves as a universal solution.

---------

Co-authored-by: leafyy <wuqizq@outlook.com>
This commit is contained in:
Younsang Na
2026-09-09 14:20:51 +08:00
committed by GitHub
co-authored by leafyy
parent 7bcd8e5274
commit 5e78b64e35
10 changed files with 1123 additions and 320 deletions
@@ -2,19 +2,22 @@
import type { SelectContentProps } from 'reka-ui'
import { Select } from '@proj-airi/ui'
import { onClickOutside } from '@vueuse/core'
import { onClickOutside, useElementBounding, useElementSize, useWindowSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, toRaw, watch } from 'vue'
import { computed, nextTick, onScopeDispose, ref, toRaw, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiriCardStore } from '../../stores/modules/airi-card'
const props = withDefaults(defineProps<Props>(), {
active: true,
contentAlign: 'start',
contentSide: 'bottom',
})
const emit = defineEmits<{
(e: 'manage'): void
/** Includes the selector and its portaled create form for host interaction protection. */
(e: 'interactionChange', active: boolean): void
}>()
const CREATE_PROFILE_ACTION = '__create-profile__'
const MANAGE_PROFILE_ACTION = '__manage-profile__'
@@ -23,9 +26,18 @@ type ProfileSelectValue = string | typeof CREATE_PROFILE_ACTION | typeof MANAGE_
/** Placement preferences for the profile selector and its create form. */
export interface Props {
/** Horizontal alignment before collision handling. */
/**
* Stops all overlays when the host menu closes.
* @default true
*/
active?: boolean
/** Horizontal alignment before collision handling.
* @default 'start'
*/
contentAlign?: Extract<SelectContentProps['align'], 'start' | 'end'>
/** Vertical side before collision handling. */
/** Vertical side before collision handling.
* @default 'bottom'
*/
contentSide?: Extract<SelectContentProps['side'], 'top' | 'bottom'>
}
@@ -39,6 +51,31 @@ const creatingNew = ref(false)
const newProfileName = ref('')
const nameInputRef = ref<HTMLInputElement>()
const containerRef = ref<HTMLElement>()
const { x: containerX, y: containerY, width: containerWidth, height: containerHeight } = useElementBounding(containerRef)
const createFormRef = ref<HTMLElement>()
const createContentRef = ref<HTMLElement>()
const { width: windowWidth, height: windowHeight } = useWindowSize()
const { width: formWidth } = useElementSize(createFormRef, undefined, { box: 'border-box' })
const { height: formHeight } = useElementSize(createContentRef, undefined, { box: 'border-box' })
const createFormStyle = computed(() => {
// The portal cannot use the Island's scroll area. Fit it to the viewport,
// flip toward the larger vertical space, then clamp if neither side fits.
const margin = 8
const height = Math.min(formHeight.value, Math.max(0, windowHeight.value - margin * 2))
const above = containerY.value - margin * 2
const below = windowHeight.value - containerY.value - containerHeight.value - margin * 2
let topSide = props.contentSide === 'top'
if ((topSide ? above : below) < height)
topSide = above > below
const anchorLeft = props.contentAlign === 'start' ? containerX.value : containerX.value + containerWidth.value - formWidth.value
const anchorTop = topSide ? containerY.value - margin - height : containerY.value + containerHeight.value + margin
return {
left: `${Math.max(margin, Math.min(anchorLeft, windowWidth.value - formWidth.value - margin))}px`,
top: `${Math.max(margin, Math.min(anchorTop, windowHeight.value - height - margin))}px`,
width: 'min(14rem, calc(100dvw - 1rem))',
maxHeight: 'calc(100dvh - 1rem)',
}
})
const cardsList = computed(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({ id, name: card.name })),
@@ -78,11 +115,16 @@ const selectOptions = computed(() => [
},
])
watch(open, (isOpen) => {
if (!isOpen) {
// Selector close and form open are one transition. Only the host becoming
// inactive cancels both surfaces; Select closes itself after selection.
watch(() => props.active, (active) => {
if (!active) {
open.value = false
cancelCreate()
}
})
watch(() => open.value || creatingNew.value, active => emit('interactionChange', active), { immediate: true })
onScopeDispose(() => emit('interactionChange', false))
watch(activeCardId, (value) => {
selectedProfile.value = value
@@ -93,13 +135,17 @@ watch(selectedProfile, (value, previousValue) => {
return
}
// Restoring the active card after choosing "Save as new" is part of the
// close-to-create transition, not a user selection that cancels creation.
if (previousValue === CREATE_PROFILE_ACTION && value === activeCardId.value)
return
handleSelection(value)
})
onClickOutside(containerRef, () => {
open.value = false
cancelCreate()
})
}, { ignore: [createFormRef] })
async function handleSelection(value: ProfileSelectValue) {
if (value === CREATE_PROFILE_ACTION) {
@@ -110,11 +156,13 @@ async function handleSelection(value: ProfileSelectValue) {
if (value === MANAGE_PROFILE_ACTION) {
selectedProfile.value = activeCardId.value
cancelCreate()
handleManage()
return
}
await cardStore.activateCard(value)
cancelCreate()
}
async function showCreateInput() {
@@ -288,61 +336,66 @@ function toggleOpen() {
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="creatingNew"
:class="[
'absolute z-[10011] w-56 rounded-xl border-2 p-2 shadow-sm backdrop-blur-xl',
props.contentSide === 'top' ? 'bottom-full mb-2' : 'top-full mt-2',
props.contentAlign === 'start' ? 'left-0' : 'right-0',
props.contentSide === 'top' && props.contentAlign === 'start' ? 'origin-bottom-left' : '',
props.contentSide === 'top' && props.contentAlign === 'end' ? 'origin-bottom-right' : '',
props.contentSide === 'bottom' && props.contentAlign === 'start' ? 'origin-top-left' : '',
props.contentSide === 'bottom' && props.contentAlign === 'end' ? 'origin-top-right' : '',
'border-neutral-200 bg-white/95 dark:border-neutral-800 dark:bg-neutral-900/95',
]"
>
<div :class="['flex items-center gap-2']">
<input
ref="nameInputRef"
v-model="newProfileName"
type="text"
:placeholder="t('stage.profile-switcher.new-profile-name')"
:class="[
'min-w-0 flex-1 rounded-lg border-2 px-2 py-1 text-sm outline-none transition-colors',
'bg-neutral-50 text-neutral-800 placeholder:text-neutral-400',
'dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-500',
isDuplicateName
? 'border-red-400 dark:border-red-600'
: 'border-neutral-100 focus:border-primary-300 dark:border-neutral-900 dark:focus:border-primary-400/50',
]"
@keydown.enter="confirmCreate"
@keydown.escape="cancelCreate"
>
<Teleport to="body">
<div
v-if="creatingNew"
ref="createFormRef"
data-testid="profile-create-form"
:style="createFormStyle"
:class="[
'fixed z-[10011] overflow-auto rounded-xl shadow-sm backdrop-blur-xl',
props.contentSide === 'top' && props.contentAlign === 'start' ? 'origin-bottom-left' : '',
props.contentSide === 'top' && props.contentAlign === 'end' ? 'origin-bottom-right' : '',
props.contentSide === 'bottom' && props.contentAlign === 'start' ? 'origin-top-left' : '',
props.contentSide === 'bottom' && props.contentAlign === 'end' ? 'origin-top-right' : '',
'border-neutral-200 bg-white/95 dark:border-neutral-800 dark:bg-neutral-900/95',
]"
>
<div ref="createContentRef" :class="['flex flex-wrap items-center justify-end gap-2 border-2 rounded-xl p-2']">
<input
ref="nameInputRef"
v-model="newProfileName"
type="text"
:placeholder="t('stage.profile-switcher.new-profile-name')"
:class="[
'min-w-0 w-full rounded-lg border-2 px-2 py-1 text-sm outline-none transition-colors',
'bg-neutral-50 text-neutral-800 placeholder:text-neutral-400',
'dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-500',
isDuplicateName
? 'border-red-400 dark:border-red-600'
: 'border-neutral-100 focus:border-primary-300 dark:border-neutral-900 dark:focus:border-primary-400/50',
]"
@keydown.enter="confirmCreate"
@keydown.escape="cancelCreate"
>
<button
:class="[
'shrink-0 p-1.5 transition',
'text-primary-500 hover:text-primary-600 dark:hover:text-primary-400',
(newProfileName.trim() && !isDuplicateName) ? '' : 'pointer-events-none opacity-30',
]"
type="button"
@click="confirmCreate"
>
<div class="i-solar:check-circle-bold size-4.5" />
</button>
<button
:class="[
'shrink-0 p-1.5 transition',
'text-primary-500 hover:text-primary-600 dark:hover:text-primary-400',
(newProfileName.trim() && !isDuplicateName) ? '' : 'pointer-events-none opacity-30',
]"
type="button"
:aria-label="t('stage.profile-switcher.save-as-new')"
:disabled="!newProfileName.trim() || isDuplicateName"
@click="confirmCreate"
>
<div class="i-solar:check-circle-bold size-4.5" />
</button>
<button
:class="[
'shrink-0 p-1.5 transition',
'text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300',
]"
type="button"
@click="cancelCreate"
>
<div class="i-solar:close-circle-bold size-4.5" />
</button>
<button
:class="[
'shrink-0 p-1.5 transition',
'text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300',
]"
type="button"
@click="cancelCreate"
>
<div class="i-solar:close-circle-bold size-4.5" />
</button>
</div>
</div>
</div>
</Teleport>
</Transition>
</div>
</template>