Merge branch 'main' into cal/drag-and-drop-to-move-skins

This commit is contained in:
tdgao
2026-06-10 09:32:41 -07:00
52 changed files with 1671 additions and 459 deletions
+84 -10
View File
@@ -35,7 +35,7 @@
<button
v-if="hasClearButton"
type="button"
class="absolute right-0.5 z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
class="absolute right-0.5 top-px z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
aria-label="Clear date"
@click.stop="clearValue"
>
@@ -161,7 +161,7 @@ const props = withDefaults(
mode: 'single',
showMonths: 1,
time24hr: false,
clearable: true,
clearable: false,
placeholder: 'Enter date',
showIcon: true,
showToday: false,
@@ -199,6 +199,7 @@ let originalInputFocus: HTMLInputElement['focus'] | null = null
let suppressNextInputFocusScroll = false
const calendarBaseClass = 'modrinth-date-picker-calendar'
const twoCalendarClass = 'has-two-calendars'
const calendarPositionGap = 2
const calendarStateClasses = [
'calendar-only',
'show-today',
@@ -1084,6 +1085,83 @@ function syncInputFocusScrollSuppression() {
inputFocusScrollSuppressionTarget = target
}
function setCalendarPositionClass(container: HTMLElement, className: string, isEnabled: boolean) {
container.classList.toggle(className, isEnabled)
}
function getCalendarPositionParts() {
const parts = props.position.split(' ')
return {
vertical: parts[0] ?? 'auto',
horizontal: parts[1] ?? null,
}
}
function getCalendarHeight(container: HTMLElement) {
const height = container.getBoundingClientRect().height
if (height > 0) return height
return Array.from(container.children).reduce(
(total, child) => total + (child instanceof HTMLElement ? child.offsetHeight : 0),
0,
)
}
function positionCalendar(instance: Instance, customPositionElement?: HTMLElement) {
const container = instance.calendarContainer
const positionElement = customPositionElement ?? instance._positionElement
if (!container || !positionElement) return
const calendarHeight = getCalendarHeight(container)
const calendarWidth = container.offsetWidth
const { vertical, horizontal } = getCalendarPositionParts()
const inputBounds = positionElement.getBoundingClientRect()
const distanceFromBottom = window.innerHeight - inputBounds.bottom
const showOnTop =
vertical === 'above' ||
(vertical !== 'below' &&
distanceFromBottom < calendarHeight &&
inputBounds.top > calendarHeight)
const top =
window.pageYOffset +
inputBounds.top +
(showOnTop
? -calendarHeight - calendarPositionGap
: positionElement.offsetHeight + calendarPositionGap)
let left = window.pageXOffset + inputBounds.left
let isCenter = false
let isRight = false
if (horizontal === 'center') {
left -= (calendarWidth - inputBounds.width) / 2
isCenter = true
} else if (horizontal === 'right') {
left -= calendarWidth - inputBounds.width
isRight = true
}
const viewportLeft = window.pageXOffset
const viewportRight = viewportLeft + document.documentElement.clientWidth
const isOverflowingRight = left + calendarWidth > viewportRight
const clampedLeft = Math.min(
Math.max(viewportLeft, left),
Math.max(viewportLeft, viewportRight - calendarWidth),
)
setCalendarPositionClass(container, 'arrowTop', !showOnTop)
setCalendarPositionClass(container, 'arrowBottom', showOnTop)
setCalendarPositionClass(container, 'arrowLeft', !isCenter && !isRight)
setCalendarPositionClass(container, 'arrowCenter', isCenter)
setCalendarPositionClass(container, 'arrowRight', isRight)
setCalendarPositionClass(container, 'rightMost', isOverflowingRight)
setCalendarPositionClass(container, 'centerMost', false)
container.style.top = `${top}px`
container.style.left = `${clampedLeft}px`
container.style.right = 'auto'
}
const resolvedDateFormat = computed(
() => props.dateFormat ?? (props.enableTime ? 'Y-m-d H:i' : 'Y-m-d'),
)
@@ -1104,12 +1182,7 @@ const selectedDates = computed(() => {
})
const hasClearButton = computed(
() =>
!props.calendarOnly &&
props.clearable &&
!props.disabled &&
!props.readonly &&
selectedDates.value.length > 0,
() => !props.calendarOnly && props.clearable && !props.disabled && selectedDates.value.length > 0,
)
const inputClasses = computed(() => [
@@ -1143,6 +1216,7 @@ watch(
props.calendarOnly,
props.closeOnSelect,
props.position,
props.clearable,
],
() => {
if (!picker.value) return
@@ -1399,7 +1473,7 @@ function flatpickrOptions(): Options {
mode: props.mode,
noCalendar: false,
nextArrow: chevronRightIcon,
position: props.position,
position: positionCalendar,
prevArrow: chevronLeftIcon,
showMonths: resolvedShowMonths.value,
static: false,
@@ -1480,7 +1554,7 @@ defineExpose({
}
.modrinth-date-picker :deep(.flatpickr-calendar.arrowBottom) {
margin-top: -2.5rem;
margin-top: -0.5rem;
}
.modrinth-date-picker.calendar-only {
+2 -91
View File
@@ -136,7 +136,6 @@
import { XIcon } from '@modrinth/assets'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useDebugLogger } from '../../composables/debug-logger'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
@@ -145,7 +144,6 @@ import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('NewModal')
const modalBehavior = injectModalBehavior(null)
const {
@@ -235,138 +233,56 @@ function getFocusableElements(): HTMLElement[] {
}
function show(event?: MouseEvent) {
debug('show: start', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
hasEvent: !!event,
})
props.onShow?.()
debug('show: after onShow', { header: props.header })
const wasEmpty = modalStackSize() === 0
stackDepth.value = modalStackSize()
debug('show: before open=true', {
header: props.header,
wasEmpty,
stackDepth: stackDepth.value,
})
open.value = true
debug('show: after open=true', {
header: props.header,
open: open.value,
modalBodyExists: !!modalBodyRef.value,
})
previousFocusEl = document.activeElement
debug('show: previous focus captured', {
header: props.header,
previousFocusTag: previousFocusEl instanceof HTMLElement ? previousFocusEl.tagName : null,
previousFocusClass: previousFocusEl instanceof HTMLElement ? previousFocusEl.className : null,
})
pushModal()
debug('show: after pushModal', { header: props.header, stackSize: modalStackSize() })
if (wasEmpty) modalBehavior?.onShow?.()
debug('show: after modalBehavior onShow', { header: props.header })
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleWindowKeyDown)
window.addEventListener('mousedown', updateMousePosition)
debug('show: listeners attached', { header: props.header })
if (event) {
updateMousePosition(event)
} else {
mouseX.value = Math.round(window.innerWidth / 2)
mouseY.value = Math.round(window.innerHeight / 2)
}
debug('show: mouse position set', {
header: props.header,
mouseX: mouseX.value,
mouseY: mouseY.value,
})
setTimeout(() => {
debug('show: timeout before visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
visible.value = true
debug('show: timeout after visible=true', {
header: props.header,
open: open.value,
visible: visible.value,
modalBodyExists: !!modalBodyRef.value,
})
nextTick(() => {
debug('show: nextTick focus start', {
header: props.header,
modalBodyExists: !!modalBodyRef.value,
})
const focusable = getFocusableElements()
debug('show: focusable elements', {
header: props.header,
count: focusable.length,
firstTag: focusable[0]?.tagName,
})
if (focusable.length > 0) {
focusable[0].focus()
} else {
modalBodyRef.value?.focus()
}
debug('show: nextTick focus done', { header: props.header })
})
}, 50)
debug('show: end', { header: props.header })
}
function hide() {
debug('hide: start', {
header: props.header,
open: open.value,
visible: visible.value,
disableClose: props.disableClose,
stackSize: modalStackSize(),
})
if (props.disableClose) {
debug('hide: ignored disableClose', { header: props.header })
return
}
props.onHide?.()
debug('hide: after onHide', { header: props.header })
visible.value = false
debug('hide: after visible=false', { header: props.header, visible: visible.value })
popModal()
debug('hide: after popModal', { header: props.header, stackSize: modalStackSize() })
if (modalStackSize() === 0) {
modalBehavior?.onHide?.()
document.body.style.overflow = ''
}
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
debug('hide: listeners removed', { header: props.header })
if (previousFocusEl instanceof HTMLElement) {
debug('hide: restoring focus', {
header: props.header,
previousFocusTag: previousFocusEl.tagName,
previousFocusClass: previousFocusEl.className,
})
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
debug('hide: timeout before open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
open.value = false
debug('hide: timeout after open=false', {
header: props.header,
open: open.value,
visible: visible.value,
})
}, 300)
debug('hide: end', { header: props.header })
}
defineExpose({
@@ -394,12 +310,6 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
}
onUnmounted(() => {
debug('unmounted', {
header: props.header,
open: open.value,
visible: visible.value,
stackSize: modalStackSize(),
})
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
@@ -528,11 +438,12 @@ defineOptions({
visibility: hidden;
pointer-events: none;
transform: translate(v-bind(mouseXOffset), v-bind(mouseYOffset));
transition: all 0.2s ease-out;
transition: none;
&.shown {
visibility: visible;
transform: translate(0, 0);
transition: all 0.2s ease-out;
> .modal-body {
opacity: 1;
@@ -3,7 +3,9 @@
class="popup-notification-group"
:class="{
'has-sidebar': hasSidebar,
'has-modal': hasModalActive,
}"
:style="notificationGroupStyle"
>
<transition-group name="popup-notifs">
<div
@@ -155,6 +157,7 @@ import {
} from '@modrinth/assets'
import { computed } from 'vue'
import { useModalStack } from '../../composables/modal-stack'
import {
injectPopupNotificationManager,
type PopupNotification,
@@ -169,6 +172,11 @@ const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() =>
popupNotificationManager.getNotifications(),
)
const { stackCount } = useModalStack()
const hasModalActive = computed(() => stackCount.value > 0)
const notificationGroupStyle = computed(() => ({
zIndex: hasModalActive.value ? 100 + stackCount.value * 10 + 8 : 200,
}))
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: PopupNotification) =>
@@ -252,12 +260,21 @@ withDefaults(
display: flex;
flex-direction: column;
gap: 0.75rem;
transition:
opacity 0.2s ease-in-out,
transform 0.2s ease-in-out;
}
.popup-notification-group.has-sidebar {
right: calc(var(--right-bar-width, 0px) + 1.5rem);
}
.popup-notification-group.has-modal {
opacity: 0;
pointer-events: none;
transform: translateY(-0.5rem);
}
@media screen and (max-width: 500px) {
.popup-notification-group {
right: 0.75rem;
+30 -11
View File
@@ -13,14 +13,14 @@ const props = withDefaults(
selected: boolean
active?: boolean
tooltip?: string
selectable?: boolean
disabled?: boolean
}>(),
{
forwardImageSrc: undefined,
backwardImageSrc: undefined,
active: false,
tooltip: undefined,
selectable: true,
disabled: false,
},
)
@@ -54,7 +54,11 @@ watch(
class="skin-button group relative flex items-end justify-center overflow-hidden border border-solid transition-[border-color,box-shadow] duration-200"
:class="[
selected ? 'skin-button--selected' : '',
{ 'skin-button--with-actions': $slots['overlay-buttons'] },
active ? 'skin-button--active' : '',
{
'skin-button--with-actions': $slots['overlay-buttons'] && !disabled,
'skin-button--disabled': disabled,
},
]"
>
<span
@@ -69,6 +73,7 @@ watch(
class="absolute inset-0 z-10 cursor-pointer border-none bg-transparent p-0 focus-visible:outline-none"
:aria-label="tooltip ? `Select ${tooltip}` : 'Select skin'"
:aria-pressed="selected"
:disabled="disabled"
@click="emit('select')"
></button>
@@ -108,7 +113,7 @@ watch(
</span>
<span
v-if="$slots['overlay-buttons']"
v-if="$slots['overlay-buttons'] && !disabled"
class="pointer-events-none absolute inset-x-0 bottom-3 z-30 flex translate-y-2 items-center justify-start gap-1.5 px-3 opacity-0 transition-all duration-200 group-focus-within:translate-y-0 group-focus-within:opacity-100 group-hover:translate-y-0 group-hover:opacity-100"
>
<slot name="overlay-buttons" />
@@ -166,8 +171,8 @@ watch(
outline-offset: 2px;
}
.skin-button:hover,
.skin-button:focus-within,
.skin-button:not(.skin-button--disabled):hover,
.skin-button:not(.skin-button--disabled):focus-within,
.skin-button--with-actions:hover,
.skin-button--with-actions:focus-within {
border-color: var(--surface-5);
@@ -177,13 +182,27 @@ watch(
0 1px 4px rgba(0, 0, 0, 0.15);
}
.skin-button--selected,
.skin-button--selected:hover,
.skin-button--selected:focus-within {
.skin-button.skin-button--selected,
.skin-button.skin-button--selected:hover,
.skin-button.skin-button--selected:focus-within,
.skin-button.skin-button--selected.skin-button--with-actions:hover,
.skin-button.skin-button--selected.skin-button--with-actions:focus-within,
.skin-button.skin-button--active:hover,
.skin-button.skin-button--active:focus-within,
.skin-button.skin-button--active.skin-button--with-actions:hover,
.skin-button.skin-button--active.skin-button--with-actions:focus-within {
border-color: var(--color-brand);
background: var(--color-brand-highlight);
}
.skin-button--disabled {
opacity: 0.65;
}
.skin-button--disabled button {
cursor: not-allowed;
}
.skin-button__image-parent {
width: 100%;
height: 95%;
@@ -195,7 +214,7 @@ watch(
backface-visibility: hidden;
}
.skin-button:hover .skin-button__image-parent {
.skin-button:not(.skin-button--disabled):hover .skin-button__image-parent {
transform: rotateY(180deg) translateZ(0);
}
@@ -218,7 +237,7 @@ watch(
transition: filter 200ms ease-in-out;
}
.group:hover .skin-button__image-parent img {
.group:not(.skin-button--disabled):hover .skin-button__image-parent img {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
</style>
@@ -7,12 +7,14 @@ const props = withDefaults(
tooltip?: string
dragActive?: boolean
dropzone?: boolean
disabled?: boolean
}>(),
{
selected: false,
tooltip: undefined,
dragActive: false,
dropzone: false,
disabled: false,
},
)
@@ -28,6 +30,10 @@ function handleDragEvent(
eventName: 'dragenter' | 'dragover' | 'dragleave' | 'drop',
event: DragEvent,
) {
if (props.disabled) {
return
}
if (props.dropzone) {
event.preventDefault()
if (event.dataTransfer) {
@@ -53,7 +59,10 @@ defineExpose({ getRootElement })
:class="[
isHighlighted
? 'border-brand bg-brand-highlight'
: 'border-surface-5 bg-surface-2 hover:bg-surface-3',
: disabled
? 'border-surface-5 bg-surface-2'
: 'border-surface-5 bg-surface-2 hover:bg-surface-3',
disabled ? 'opacity-[0.65]' : '',
]"
@dragenter="handleDragEvent('dragenter', $event)"
@dragover="handleDragEvent('dragover', $event)"
@@ -64,6 +73,8 @@ defineExpose({ getRootElement })
type="button"
:aria-label="tooltip ?? undefined"
class="absolute inset-0 z-0 cursor-pointer border-none bg-transparent p-0"
:class="{ 'cursor-not-allowed': disabled }"
:disabled="disabled"
@click="(e) => emit('click', e)"
></button>
@@ -32,7 +32,7 @@ function configureSkinPreviewMesh(mesh: THREE.Mesh) {
materials.forEach((material) => {
if (!(material instanceof THREE.MeshStandardMaterial) || material.name === 'cape') return
material.transparent = false
material.transparent = isSkinLayer
material.alphaTest = 0.1
material.depthTest = true
material.depthWrite = true
@@ -52,17 +52,30 @@ export const Clearable: Story = {
render: () => ({
components: { DatePicker },
setup() {
const value = ref('2026-04-27')
return { value }
const emptyValue = ref(null)
const selectedValue = ref('2026-04-27')
return { emptyValue, selectedValue }
},
template: /* html */ `
<div class="flex max-w-sm flex-col gap-2">
<DatePicker
v-model="value"
wrapperClass="w-[300px]"
placeholder="Select a date..."
/>
<p class="text-sm text-secondary">Selected value: {{ value || 'None' }}</p>
<div class="flex max-w-sm flex-col gap-5">
<div class="flex flex-col gap-2">
<DatePicker
v-model="emptyValue"
wrapperClass="w-[300px]"
clearable
placeholder="Button hidden while empty..."
/>
<p class="text-sm text-secondary">Empty value: {{ emptyValue || 'None' }}</p>
</div>
<div class="flex flex-col gap-2">
<DatePicker
v-model="selectedValue"
wrapperClass="w-[300px]"
clearable
placeholder="Select a date..."
/>
<p class="text-sm text-secondary">Selected value: {{ selectedValue || 'None' }}</p>
</div>
</div>
`,
}),
@@ -157,6 +157,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
model.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh
const isSkinLayer = mesh.name.endsWith('_Layer')
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
materials.forEach((mat: THREE.Material) => {
@@ -168,7 +169,7 @@ export function applyTexture(model: THREE.Object3D, texture: THREE.Texture): voi
flatShading: true,
side: THREE.FrontSide,
toneMapped: false,
transparent: false,
transparent: isSkinLayer,
})
setCommonMaterialProperties(mat)