diff --git a/.storybook/main.ts b/.storybook/main.ts index 009e3246e52..b0acb491452 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -21,6 +21,12 @@ const config: StorybookConfig = { '../resources/js/**/*.mdx', '../resources/js/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], + // `craft-icon` fetches `/vendor/craft/icons//.svg`, which in the + // CP is a symlink to `cms-assets/resources`. Without this every icon in every + // story 404s and renders nothing. + staticDirs: [ + {from: '../cms-assets/resources/icons', to: '/vendor/craft/icons'}, + ], addons: [ getAbsolutePath('@storybook/addon-themes'), getAbsolutePath('@storybook/addon-docs'), diff --git a/packages/craftcms-legacy/cp/src/css/_customize-sources.scss b/packages/craftcms-legacy/cp/src/css/_customize-sources.scss deleted file mode 100644 index 64a15fc1bda..00000000000 --- a/packages/craftcms-legacy/cp/src/css/_customize-sources.scss +++ /dev/null @@ -1,246 +0,0 @@ -@use '@craftcms/sass/mixins'; - -/* ---------------------------------------- -/* Customize sources modal -/* ---------------------------------------- */ - -.cs-modal { - display: flex !important; - flex-direction: column; - - & > .cs-body { - flex: 1; - display: flex; - flex-flow: row nowrap; - height: calc(100% - 44px); - container-type: inline-size; - - @container (max-width: 699px) { - & > .cs-sidebar, - & > .cs-source-settings--outer { - &.cs-selected-screen { - flex: 1; - } - - &:not(.cs-selected-screen) { - display: none; - } - } - } - - .cs-header { - height: 44px; - border-block-end: 1px solid var(--hairline-color); - display: flex; - align-items: center; - gap: var(--s); - padding-inline: var(--m); - - .cs-back-btn { - display: flex; - align-items: center; - justify-content: center; - margin-inline-start: -6px; - width: var(--touch-target-size); - height: var(--touch-target-size); - - @container (min-width: 700px) { - display: none; - } - } - - h2 { - margin-block: 0; - } - } - - & > .cs-sidebar { - flex: 0 0 200px; - height: 100%; - border-inline-end: 1px solid var(--hairline-color); - - & > .cs-sidebar-content { - padding: var(--m); - border: none; - height: calc(100% - 44px); - box-sizing: border-box; - overflow: auto; - - & > .cs-sidebar-list { - padding-inline-start: 0; - - &:not(:empty) { - margin-block-end: var(--m); - } - } - } - } - - &.sidebar-hidden { - padding-inline-start: 0; - - & > .cs-sidebar { - display: none; - } - } - - & > .cs-source-settings--outer { - flex: 1; - height: 100%; - - & > .cs-source-settings { - height: calc(100% - 44px); - position: relative; - padding: var(--m); - overflow: auto; - } - } - } - - & > .footer { - flex: 0 0 44px; - width: 100%; - } -} - -.cs-item { - --light-text-color: var(--gray-600); - --selected-item-color: var(--white); - display: flex; - gap: var(--s); - padding-inline-end: var(--s); - flex-direction: row; - width: 100%; - align-items: center; - position: relative; - background-color: var(--gray-100); - border-radius: var(--radius-lg); - user-select: none; - cursor: default; - - & + .cs-item { - &.cs-item--heading { - margin-block-start: var(--m); - } - - &:not(.cs-item--heading) { - margin-block-start: var(--2xs); - } - } - - @container (min-width: 700px) { - &.sel, - &.active { - --focus-ring: var(--focus-ring-outset); - --ui-control-color: var(--selected-item-color); - --text-color: var(--selected-item-color); - background-color: var(--bg-selection-dark); - color: var(--selected-item-color); - } - } - - .label, - .handle { - overflow: hidden; - text-overflow: ellipsis; - } - - .handle { - @include mixins.fixed-width-font; - font-size: 0.8em !important; - } - - &:not(.sel) .handle { - color: var(--fg-subtle); - } - - @container (max-width: 699px) { - .handle { - color: var(--fg-subtle); - } - } - - &.cs-item--heading { - .label { - text-transform: uppercase; - color: var(--light-text-color); - font-size: 12px; - font-weight: bold; - } - - @container (min-width: 700px) { - &.sel .label { - color: var(--selected-item-color); - } - } - } -} - -.cs-item__move { - justify-content: center; - align-items: center; - - &.icon { - display: flex; - text-decoration: none; - } -} - -.cs-item__btn { - flex: 1; - padding-block: 6px; - padding-inline-start: var(--m); - border-radius: var(--radius-lg); - overflow: hidden; - - &.cs-item__page-btn { - display: flex; - flex-direction: row; - gap: var(--s); - align-items: center; - - .cp-icon:empty { - display: none; - } - } - - &:focus { - position: relative; - z-index: 1; - } -} - -/* Shared modal chrome (also applied to .elementselectormodal in _main.scss). */ -.cs-modal { - .main-header { - display: flex; - align-items: center; - margin-block-end: var(--s); - } - - .main-heading { - margin-block-end: 0; - } - - .nav-toggle { - margin-inline-start: 4px; - @include mixins.touch-target; - color: var(--ui-control-color); - - &:hover { - color: var(--gray-500); - } - } - - .sidebar-header { - display: flex; - justify-content: flex-end; - padding-block: 0; - padding-inline: var(--s); - margin-block-end: var(--s); - } - - .nav-close { - @include mixins.touch-target; - } -} diff --git a/packages/craftcms-legacy/cp/src/css/craft.scss b/packages/craftcms-legacy/cp/src/css/craft.scss index 55f27973012..1b724bfb146 100644 --- a/packages/craftcms-legacy/cp/src/css/craft.scss +++ b/packages/craftcms-legacy/cp/src/css/craft.scss @@ -5,7 +5,6 @@ @import 'hud'; @import 'icon-picker'; @import 'login'; -@import 'customize-sources'; @import 'main'; @import 'cp'; @import 'range'; diff --git a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js index 6e5d1041bb7..1c3323b1481 100644 --- a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js +++ b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js @@ -1497,19 +1497,8 @@ Craft.BaseElementIndex = Garnish.Base.extend( }, getSourceActions: function () { - let actions = []; - - if (Craft.userIsAdmin && Craft.allowAdminChanges) { - actions.push({ - label: Craft.t('app', 'Customize sources'), - administrative: true, - onSelect: () => { - this.createCustomizeSourcesModal(); - }, - }); - } - - return actions; + // "Customize sources" now lives on the Inertia/Vue element index. + return []; }, updateViewMenu: function () { @@ -2987,19 +2976,6 @@ Craft.BaseElementIndex = Garnish.Base.extend( this.$updateSpinner.remove(); }, - createCustomizeSourcesModal: function () { - // Recreate it each time - var modal = new Craft.CustomizeSourcesModal(this, { - hideOnEsc: false, - hideOnShadeClick: false, - onFadeOut: function () { - modal.destroy(); - }, - }); - - return modal; - }, - disable: function () { if (this.sourceNav) { this.sourceNav.disable(); diff --git a/packages/craftcms-ui/src/components/action-item/action-item.styles.ts b/packages/craftcms-ui/src/components/action-item/action-item.styles.ts index dbf5a8c322d..0d4d767b538 100644 --- a/packages/craftcms-ui/src/components/action-item/action-item.styles.ts +++ b/packages/craftcms-ui/src/components/action-item/action-item.styles.ts @@ -29,7 +29,7 @@ export default css` } @media (hover: hover) { - :host(:hover) .action-item:not(:disabled) { + :host(:hover:not([active])) .action-item:not(:disabled) { background-color: var( --c-color-fill-quiet, var(--c-color-neutral-fill-quiet) @@ -91,7 +91,7 @@ export default css` } @media (hover: hover) { - :host(:hover[variant='danger']) .action-item:not(:disabled) { + :host(:hover[variant='danger']:not([active])) .action-item:not(:disabled) { background-color: var(--c-color-fill-quiet); color: var(--c-color-on-quiet); } diff --git a/packages/craftcms-ui/src/components/action-item/action-item.ts b/packages/craftcms-ui/src/components/action-item/action-item.ts index dae63b07ae1..b3f347b9ec5 100644 --- a/packages/craftcms-ui/src/components/action-item/action-item.ts +++ b/packages/craftcms-ui/src/components/action-item/action-item.ts @@ -42,7 +42,7 @@ export default class CraftActionItem extends LitElement { @property({type: Boolean}) disabled: boolean = false; @property({reflect: true}) variant: VariantValue = Variant.Neutral; @property({type: Boolean}) checked: boolean = false; - @property({type: Boolean}) active: boolean = false; + @property({type: Boolean, reflect: true}) active: boolean = false; @property() type: 'button' | 'checkbox' = 'button'; @property({type: Object}) action: BaseAction | string | null = null; @property({type: Object}) feedback: ActionFeedback | null = null; @@ -193,9 +193,11 @@ export default class CraftActionItem extends LitElement { renderPrefix() { const hasIcon = !!this.querySelector('[slot="icon"]') || !!this.icon; + const hasPrefix = !!this.querySelector('[slot="prefix"]'); return html` ${this.type === 'checkbox' ? this.renderCheckbox() : nothing} + ${hasPrefix ? html`` : nothing} ${hasIcon ? html`
${this.renderIcon()}
` : nothing} diff --git a/packages/craftcms-ui/src/components/field-group/field-group.ts b/packages/craftcms-ui/src/components/field-group/field-group.ts index ac6480aa885..82fb2b708f5 100644 --- a/packages/craftcms-ui/src/components/field-group/field-group.ts +++ b/packages/craftcms-ui/src/components/field-group/field-group.ts @@ -7,7 +7,7 @@ export default class CraftFieldGroup extends LitElement { craft-field-group { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); - gap: var(--gap, var(--c-spacing-lg)); + gap: var(--gap, var(--c-spacing-lg)) 0; container-type: inline-size; } @@ -15,6 +15,29 @@ export default class CraftFieldGroup extends LitElement { grid-column: 1 / -1; min-width: 0; } + + @container (min-width: 30rem){ + craft-field-group > .width-25 { + grid-column: span 3; + } + + craft-field-group > .width-33 { + grid-column: span 4; + } + + craft-field-group > .width-50 { + grid-column: span 6; + } + + craft-field-group > .width-66 { + grid-column: span 6; + } + + craft-field-group > .width-75 { + grid-column: span 6; + } + + } @container (min-width: 50rem) { craft-field-group > .width-25 { diff --git a/packages/craftcms-ui/src/components/pane/pane.styles.ts b/packages/craftcms-ui/src/components/pane/pane.styles.ts index d1ac843d89e..38d8eefd629 100644 --- a/packages/craftcms-ui/src/components/pane/pane.styles.ts +++ b/packages/craftcms-ui/src/components/pane/pane.styles.ts @@ -145,11 +145,12 @@ export default css` justify-content: space-between; align-items: center; padding-inline: var(--_pane-spacing); - padding-block: var(--_pane-spacing) 0; + padding-block: calc(var(--_pane-spacing) / 2); position: sticky; inset-block-start: 0; z-index: 10; background-color: var(--_pane-background); + border-block-end: 1px solid var(--_pane-divider-color); } .cp-pane__title { diff --git a/resources/css/cp.css b/resources/css/cp.css index b4fff33991a..de1756b31f2 100644 --- a/resources/css/cp.css +++ b/resources/css/cp.css @@ -38,6 +38,13 @@ CP Styles var(--workspace-bg-image); } +/* Held by overlays — modals and slideouts — while any of them is open. The + legacy stylesheet and panel-stack.css each define this too; repeated here so + the lock still works on a CP page that loads neither. */ +body.no-scroll { + overflow: hidden; +} + /** Temporary holding for cp-pane styles until craft-pane is a thing */ diff --git a/resources/js/common/components/CornerResizeHandle.vue b/resources/js/common/components/CornerResizeHandle.vue new file mode 100644 index 00000000000..b411f81bdf4 --- /dev/null +++ b/resources/js/common/components/CornerResizeHandle.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/resources/js/common/components/CpSidebar.vue b/resources/js/common/components/CpSidebar.vue index c195339ded6..63cdd2dc384 100644 --- a/resources/js/common/components/CpSidebar.vue +++ b/resources/js/common/components/CpSidebar.vue @@ -85,7 +85,7 @@ — but below modals (10001+). The sidebar is chrome: a floating drawer overlays the page, and a collapsed rail's label tooltips overflow across it. Both get sliced by a sticky header otherwise. */ - z-index: 1001; + z-index: 10; height: 100dvh; width: var(--global-sidebar-width); display: flex; diff --git a/resources/js/common/components/Modal.vue b/resources/js/common/components/Modal.vue index 93e8e8ef72a..31fcad5e772 100644 --- a/resources/js/common/components/Modal.vue +++ b/resources/js/common/components/Modal.vue @@ -1,6 +1,10 @@ @@ -46,14 +69,16 @@
+
@@ -63,7 +88,11 @@ + diff --git a/resources/js/common/composables/resizeHandle.ts b/resources/js/common/composables/resizeHandle.ts new file mode 100644 index 00000000000..1712545885e --- /dev/null +++ b/resources/js/common/composables/resizeHandle.ts @@ -0,0 +1,17 @@ +/** + * What a resize handle needs from whatever it drives. + * + * The CP has two shapes of resizing — {@link useResizable} for a layout column + * and {@link useResizableBox} for a centered box — and they differ in almost + * everything: one axis against stored bounds versus two axes against a CSS cap. + * What they share is how a handle component talks to them, so that much is + * named here rather than restated in each return type. + */ +export interface ResizeHandleControls { + /** Template ref callback for the handle element. Wire to `:ref`. */ + setHandle: (el: HTMLElement | null) => void; + /** Resize keys — arrows to nudge, Enter to reset. Wire to `@keydown`. */ + onKeydown: (ev: KeyboardEvent) => void; + /** Back to the default size. Wire to `@dblclick`. */ + reset: () => void; +} diff --git a/resources/js/common/composables/useBodyScrollLock.test.ts b/resources/js/common/composables/useBodyScrollLock.test.ts new file mode 100644 index 00000000000..dc3ad263011 --- /dev/null +++ b/resources/js/common/composables/useBodyScrollLock.test.ts @@ -0,0 +1,65 @@ +import {effectScope, ref} from 'vue'; +import {afterEach, describe, expect, it} from 'vite-plus/test'; +import {useBodyScrollLock} from './useBodyScrollLock'; + +describe('useBodyScrollLock', () => { + const locked = () => document.body.classList.contains('no-scroll'); + + afterEach(() => { + document.body.classList.remove('no-scroll'); + }); + + it('locks the body while a caller wants it', async () => { + const open = ref(false); + const scope = effectScope(); + scope.run(() => useBodyScrollLock(open)); + + expect(locked()).toBe(false); + + open.value = true; + await Promise.resolve(); + expect(locked()).toBe(true); + + open.value = false; + await Promise.resolve(); + expect(locked()).toBe(false); + + scope.stop(); + }); + + it('stays locked until the last of several callers lets go', async () => { + const outer = ref(true); + const inner = ref(false); + const outerScope = effectScope(); + const innerScope = effectScope(); + outerScope.run(() => useBodyScrollLock(outer)); + innerScope.run(() => useBodyScrollLock(inner)); + + expect(locked()).toBe(true); + + // A nested overlay opens and closes; the outer one still wants the lock. + inner.value = true; + await Promise.resolve(); + inner.value = false; + await Promise.resolve(); + expect(locked()).toBe(true); + + outer.value = false; + await Promise.resolve(); + expect(locked()).toBe(false); + + outerScope.stop(); + innerScope.stop(); + }); + + it('releases a lock held by a scope that goes away', async () => { + const scope = effectScope(); + scope.run(() => useBodyScrollLock(ref(true))); + + expect(locked()).toBe(true); + + // A modal unmounted while still open must not strand the lock. + scope.stop(); + expect(locked()).toBe(false); + }); +}); diff --git a/resources/js/common/composables/useBodyScrollLock.ts b/resources/js/common/composables/useBodyScrollLock.ts new file mode 100644 index 00000000000..f3ef547b6d5 --- /dev/null +++ b/resources/js/common/composables/useBodyScrollLock.ts @@ -0,0 +1,36 @@ +import {onScopeDispose, toValue, watch, type MaybeRefOrGetter} from 'vue'; + +/** + * How many callers currently want the body locked. Module scope, so every + * caller on the page shares it. + */ +let holders = 0; + +function apply(): void { + document.body.classList.toggle('no-scroll', holders > 0); +} + +/** + * Stops the page behind an overlay from scrolling while `locked` is true, + * using the CP's `body.no-scroll` convention. + * + * Ref-counted: overlays nest — the customize-sources modal opens a page + * settings modal, which opens an icon picker — and each holds its own lock, so + * the body only scrolls again once the last of them lets go. The lock is + * released automatically when the owning scope is disposed, which covers a + * caller unmounted while it was still open. + */ +export function useBodyScrollLock(locked: MaybeRefOrGetter): void { + let held = false; + + function hold(next: boolean): void { + if (next === held) return; + + held = next; + holders += next ? 1 : -1; + apply(); + } + + watch(() => toValue(locked), hold, {immediate: true}); + onScopeDispose(() => hold(false)); +} diff --git a/resources/js/common/composables/useResizable.ts b/resources/js/common/composables/useResizable.ts index 86c52d75ea7..233ed94ee89 100644 --- a/resources/js/common/composables/useResizable.ts +++ b/resources/js/common/composables/useResizable.ts @@ -10,6 +10,7 @@ import { import type {ComputedRef, MaybeRefOrGetter, Ref} from 'vue'; import {useElementSize} from '@vueuse/core'; import {BaseDrag, X_AXIS} from '@craftcms/garnish'; +import type {ResizeHandleControls} from '@/common/composables/resizeHandle'; import {useLocalStorage} from '@/common/composables/useStorage'; /** @@ -90,7 +91,7 @@ export interface UseResizableOptions { onReset?: (detail: ResizeDetail) => void; } -export interface UseResizableReturn { +export interface UseResizableReturn extends ResizeHandleControls { /** Explicit width in px, or `null` when CSS is in charge. */ width: Ref; /** The width to report to assistive tech: the explicit width, else the measured one. */ @@ -103,8 +104,6 @@ export interface UseResizableReturn { atMax: ComputedRef; /** `{[cssVariable]: 'px'}`, or `{}` when the width is CSS-driven. */ style: ComputedRef>; - /** Template ref callback for the handle element. */ - setHandle: (el: HTMLElement | null) => void; /** Set the width directly. `null` hands the column back to CSS. */ setWidth: ( width: number | null, @@ -112,10 +111,6 @@ export interface UseResizableReturn { ) => void; /** Grow or shrink by `delta` px along the physical X axis (RTL-aware). */ nudge: (deltaX: number) => void; - /** Return to `defaultWidth`. Wired to the handle's double-click and Enter key. */ - reset: () => void; - /** Keydown handler implementing the WAI-ARIA window splitter keys. */ - onKeydown: (ev: KeyboardEvent) => void; } /** Class put on `` for the duration of a drag; see `cp.css`. */ diff --git a/resources/js/common/composables/useResizableBox.test.ts b/resources/js/common/composables/useResizableBox.test.ts new file mode 100644 index 00000000000..e0aa76a869b --- /dev/null +++ b/resources/js/common/composables/useResizableBox.test.ts @@ -0,0 +1,65 @@ +import {effectScope, ref} from 'vue'; +import {describe, expect, it} from 'vite-plus/test'; +import { + useResizableBox, + type UseResizableBoxOptions, + type UseResizableBoxReturn, +} from './useResizableBox'; + +/** + * The style is a pure projection of `width`/`height`/`floor`, so the tests + * set those directly rather than going through a drag — happy-dom reports every + * box as 0×0, which the floor watcher ignores by design. + */ +function box( + options: Partial = {} +): UseResizableBoxReturn { + const scope = effectScope(); + + return scope.run(() => + useResizableBox({target: ref(null), ...options}) + ) as UseResizableBoxReturn; +} + +describe('useResizableBox style', () => { + it('leaves the size to CSS until something sets one', () => { + expect(box().style.value).toEqual({}); + }); + + it('holds the box open at the floor while the height is content-driven', () => { + const {floor, style} = box(); + floor.value = 400; + + expect(style.value).toEqual({minHeight: '400px'}); + }); + + it('drops the floor once a drag fixes the height', () => { + const {width, height, floor, style} = box(); + floor.value = 400; + width.value = 800; + // Deliberately below the floor: a drag may shrink the box past it, and + // min-height beats max-height, so a surviving floor would fight the drag. + height.value = 300; + + expect(style.value).toEqual({width: '800px', height: '300px'}); + }); + + it('stands down when the consumer fixes the height itself', () => { + const fixed = ref(false); + const {floor, style} = box({fixedHeight: fixed}); + floor.value = 400; + + expect(style.value).toHaveProperty('minHeight', '400px'); + + fixed.value = true; + expect(style.value).toEqual({}); + }); + + it('still reports a dragged width when the height is fixed', () => { + const {width, floor, style} = box({fixedHeight: () => true}); + floor.value = 400; + width.value = 800; + + expect(style.value).toEqual({width: '800px'}); + }); +}); diff --git a/resources/js/common/composables/useResizableBox.ts b/resources/js/common/composables/useResizableBox.ts new file mode 100644 index 00000000000..1f2bbfc9040 --- /dev/null +++ b/resources/js/common/composables/useResizableBox.ts @@ -0,0 +1,284 @@ +import { + computed, + onScopeDispose, + ref, + shallowRef, + toValue, + watch, + type ComputedRef, + type MaybeRefOrGetter, + type Ref, +} from 'vue'; +import {useElementSize, useEventListener} from '@vueuse/core'; +import {BaseDrag} from '@craftcms/garnish'; +import type {ResizeHandleControls} from '@/common/composables/resizeHandle'; + +/** + * Two-axis resizing for a centered box — a modal — from a corner handle. + * + * The sibling {@link import('./useResizable').useResizable} resizes a layout + * column: one axis, anchored to an edge, persisted, reported to assistive tech + * as a splitter. This is the other shape. A centered box grows from both edges + * at once, has no meaningful "side", and is sized against whatever cap CSS + * already puts on it rather than a track width. + * + * It also holds a height floor, because the two are the same question — how + * big is this box — and answer to the same cap. + */ + +export interface UseResizableBoxOptions { + /** The element being resized. */ + target: MaybeRefOrGetter; + /** Whether the box is on screen. The floor resets when it isn't. */ + active?: MaybeRefOrGetter; + /** + * Set when the consumer fixes the height itself. The floor stands down: it + * only means anything while the height is content-driven. + */ + fixedHeight?: MaybeRefOrGetter; + /** Smallest size a drag can leave it at, in px. Default `200`. */ + minSize?: number; + /** Arrow-key increment, in px. Default `16`. */ + step?: number; + /** Shift + arrow-key increment, in px. Default `64`. */ + largeStep?: number; +} + +export interface UseResizableBoxReturn extends ResizeHandleControls { + /** Dragged width in px, or `null` while the width is CSS-driven. */ + width: Ref; + /** Dragged height in px, or `null` while the height is CSS-driven. */ + height: Ref; + /** Tallest the box has been while active, in px. Only ever rises. */ + floor: Ref; + /** The CSS the values above imply, or `{}` when the size is CSS-driven. */ + style: ComputedRef>; +} + +export function useResizableBox({ + target, + active, + fixedHeight, + minSize = 200, + step = 16, + largeStep = 64, +}: UseResizableBoxOptions): UseResizableBoxReturn { + const width = ref(null); + const height = ref(null); + const floor = ref(null); + const handle = shallowRef(null); + + function el(): HTMLElement | null { + return toValue(target) ?? null; + } + + /** + * The largest the box may be. Taken from the CSS rather than a hard-coded + * gutter: max-width/max-height compute to px even when authored as calc(), + * which the custom properties they're built from do not. + */ + function bounds(node: HTMLElement): {width: number; height: number} { + const styles = getComputedStyle(node); + + return { + width: parseFloat(styles.maxWidth) || window.innerWidth, + height: parseFloat(styles.maxHeight) || window.innerHeight, + }; + } + + function measured(): {width: number; height: number} { + const rect = el()?.getBoundingClientRect(); + + return { + width: Math.round(rect?.width ?? 0), + height: Math.round(rect?.height ?? 0), + }; + } + + function resize(nextWidth: number, nextHeight: number): void { + const node = el(); + if (!node) return; + + const max = bounds(node); + width.value = Math.round(Math.min(Math.max(nextWidth, minSize), max.width)); + height.value = Math.round( + Math.min(Math.max(nextHeight, minSize), max.height) + ); + } + + function reset(): void { + width.value = null; + height.value = null; + } + + const style = computed>(() => { + const style: Record = {}; + + if (width.value !== null) { + style.width = `${width.value}px`; + } + + if (height.value !== null) { + style.height = `${height.value}px`; + } + + // A floor under a fixed height is either redundant or, since min-height + // beats max-height, actively wrong. + if ( + floor.value !== null && + height.value === null && + !toValue(fixedHeight ?? false) + ) { + style.minHeight = `${floor.value}px`; + } + + return style; + }); + + // --- Height floor --------------------------------------------------------- + // + // A box whose content shrinks would otherwise collapse under whoever is + // reading it. Remember the tallest it has been and refuse to go below that. + + // Border-box, so the floor matches the rendered box the cap is measured + // against — a content-box floor sits a border short and lets it creep down. + const {height: renderedHeight} = useElementSize(target, undefined, { + box: 'border-box', + }); + + watch(renderedHeight, (next) => { + if (active !== undefined && !toValue(active)) return; + if (!next) return; + + raiseFloor(Math.round(next)); + }); + + if (active !== undefined) { + watch( + () => toValue(active), + (isActive) => { + // Reopening sizes itself to the new content rather than inheriting the + // last session's floor. + if (!isActive) floor.value = null; + } + ); + } + + function raiseFloor(next: number): void { + const node = el(); + // min-height beats max-height, so the floor has to respect the cap itself + // or a shrinking viewport would leave the box taller than the screen. + const capped = Math.min(next, node ? bounds(node).height : Infinity); + + if (floor.value === null || capped > floor.value) { + floor.value = capped; + } + } + + // --- Keyboard ------------------------------------------------------------- + + function nudge(deltaX: number, deltaY: number): void { + // Build on the last size we asked for, not the rendered one: key repeats + // outrun Vue's DOM updates, so measuring every time would have them all + // read the same stale box and overwrite each other. + const size = measured(); + const node = el(); + const rtl = node ? getComputedStyle(node).direction === 'rtl' : false; + + resize( + (width.value ?? size.width) + (rtl ? -deltaX : deltaX), + (height.value ?? size.height) + deltaY + ); + } + + function onKeydown(ev: KeyboardEvent): void { + const increment = ev.shiftKey ? largeStep : step; + + switch (ev.key) { + case 'ArrowLeft': + nudge(-increment, 0); + break; + case 'ArrowRight': + nudge(increment, 0); + break; + case 'ArrowUp': + nudge(0, -increment); + break; + case 'ArrowDown': + nudge(0, increment); + break; + case 'Enter': + reset(); + break; + default: + return; + } + + ev.preventDefault(); + } + + // --- Pointer dragging ----------------------------------------------------- + + let dragger: BaseDrag | null = null; + let startWidth = 0; + let startHeight = 0; + let startDistX = 0; + let startDistY = 0; + let sign = 1; + + function setHandle(node: HTMLElement | null): void { + handle.value = node; + } + + watch(handle, (node) => { + dragger?.destroy(); + dragger = null; + + if (!node) return; + + dragger = new BaseDrag(node, { + // The default selector list would swallow pointer-downs on the handle's + // own children; the handle *is* the control here. + ignoreHandleSelector: null, + onBeforeDragStart: () => { + // Sync, unlike onDragStart, so we measure what was on screen when the + // drag threshold was crossed, and discount the distance already + // travelled so the box doesn't jump by it. + const size = measured(); + const box = el(); + startWidth = size.width; + startHeight = size.height; + startDistX = dragger?.mouseDistX ?? 0; + startDistY = dragger?.mouseDistY ?? 0; + sign = box && getComputedStyle(box).direction === 'rtl' ? -1 : 1; + }, + onDrag: () => { + const dx = ((dragger?.mouseDistX ?? 0) - startDistX) * sign; + const dy = (dragger?.mouseDistY ?? 0) - startDistY; + + // Centered, so each edge moves by the pointer delta. + resize(startWidth + dx * 2, startHeight + dy * 2); + }, + }); + }); + + // A smaller viewport lowers the cap; re-clamp so dragging back responds at + // once rather than after the overshoot unwinds. + useEventListener(window, 'resize', () => { + if (width.value !== null && height.value !== null) { + resize(width.value, height.value); + } + + const node = el(); + if (floor.value !== null && node) { + floor.value = Math.min(floor.value, bounds(node).height); + } + }); + + onScopeDispose(() => { + dragger?.destroy(); + dragger = null; + }); + + return {width, height, floor, style, setHandle, onKeydown, reset}; +} diff --git a/resources/js/common/form/CheckboxGroup.vue b/resources/js/common/form/CheckboxGroup.vue index cb854a22d73..39e2073284b 100644 --- a/resources/js/common/form/CheckboxGroup.vue +++ b/resources/js/common/form/CheckboxGroup.vue @@ -4,6 +4,7 @@ import type {CheckboxOption} from '@/common/types'; import CheckboxGroupItem from '@/common/form/CheckboxGroupItem.vue'; import {useReorderableItems} from '@/common/composables/useReorderableItems'; + import {ignoreModelValueInitialization} from '@/modules/forms/runtime'; const emit = defineEmits<{ (e: 'update:modelValue', value: Array): void; @@ -34,7 +35,10 @@ return [...new Set([...props.modelValue, ...forced])]; }); - function handleValueChange(event: CustomEvent) { + // Lion announces a model value as its children register, before the group + // has applied ours. Treating that as a change would emit an empty selection + // over whatever the caller passed in. + const handleValueChange = ignoreModelValueInitialization((event) => { const target = event.target; if ( target instanceof HTMLElement && @@ -43,7 +47,7 @@ ) { emit('update:modelValue', target.modelValue.map(String)); } - } + }); // Only non-disabled options are reorderable; disabled options (e.g. a pinned // "always on" column) keep their position. diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts index 938c44bacc8..f1a21e3bc06 100644 --- a/resources/js/common/types/globals.d.ts +++ b/resources/js/common/types/globals.d.ts @@ -209,14 +209,7 @@ interface CraftStatic { CpScreenSlideout: { new (url: string, settings?: LegacyWidgetSettings): SlideoutInstance; }; - createElementEditor( - elementType: string, - settings?: LegacyWidgetSettings - ): SlideoutInstance; - CustomizeSourcesModal: new ( - elementIndex: ElementIndexInstance, - settings?: LegacyWidgetSettings - ) => {destroy(): void}; + createElementEditor(elementType: string, settings?: object): SlideoutInstance; FieldLayoutDesigner: { new ( container: HTMLElement | JQuery, diff --git a/resources/js/cp.ts b/resources/js/cp.ts index a9bdc5bcde4..dcc5e84a0e3 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -45,7 +45,6 @@ import './modules/element-selector-modal'; import './modules/element-select-input'; import './modules/preview-file-modal'; import './modules/asset-select-input'; -import './modules/customize-sources-modal'; import './modules/element-deletion-manager'; import './modules/uploader'; import './modules/nested-element-manager'; diff --git a/resources/js/legacy.ts b/resources/js/legacy.ts index 851d7f95a03..2f513d16020 100644 --- a/resources/js/legacy.ts +++ b/resources/js/legacy.ts @@ -58,7 +58,6 @@ import './modules/element-selector-modal/index'; import './modules/element-select-input/index'; import './modules/preview-file-modal/index'; import './modules/asset-select-input/index'; -import './modules/customize-sources-modal/index'; import './modules/element-deletion-manager/index'; import './modules/uploader/index'; import './modules/nested-element-manager/index'; diff --git a/resources/js/modules/customize-sources-modal/customize-sources-modal.ts b/resources/js/modules/customize-sources-modal/customize-sources-modal.ts deleted file mode 100644 index f5f811d16b5..00000000000 --- a/resources/js/modules/customize-sources-modal/customize-sources-modal.ts +++ /dev/null @@ -1,1798 +0,0 @@ -import {Modal, Base, DragSort, bod} from '@craftcms/garnish'; - -declare const Craft: any; -declare const Garnish: any; - -interface LegacySourceContainer { - insertBefore(target: any): LegacySourceContainer; - appendTo(target: any): LegacySourceContainer; - addClass(name: string): LegacySourceContainer; - removeClass(name: string): LegacySourceContainer; -} -declare const $: any; - -// ─── CustomizeSourcesModal ──────────────────────────────────────────────────── - -/** - * CustomizeSourcesModal — a port of `Craft.CustomizeSourcesModal` and all of - * its nested companion classes onto modern TypeScript (extending - * `@craftcms/garnish` base classes). - * - * Nested classes are exported individually and registered on - * `Craft.CustomizeSourcesModal.*` via `registerCraftGlobals`. They are: - * - {@link PageSettingsModal} - * - {@link SourceDrag} - * - {@link Page} - * - {@link BaseSource} - * - {@link Source} - * - {@link CustomSource} - * - {@link Heading} - * - * Notes: - * - `activate` events on jQuery-created buttons MUST be bound with jQuery - * `.on('activate', …)` — that is a jQuery synthetic event; `addListener` - * would use native `addEventListener` and miss it. - * - `Garnish.DisclosureMenu` is accessed via `declare const Garnish: any` - * (the compat layer wires it; importing `DisclosureMenu` directly would - * bypass the compat init guard). - * - `Craft.Listbox` is still a legacy class — accessed via `declare const Craft`. - */ -export class CustomizeSourcesModal extends Modal { - declare settings: any; - - elementIndex: any = null; - - $body: any = null; - $pagesSidebar: any = null; - $pagesSidebarContent: any = null; - $pagesSidebarItems: any = null; - $newPageBtn: any = null; - pageIconInputs: any = null; - - multiPage = false; - pageDrag: any = null; - pages: any[] | null = null; - selectedPage: any = null; - - $sourcesSidebar: any = null; - $sourcesSidebarContent: any = null; - sourceContainers: Record = {}; - $sourcesHeader: any = null; - $newSourceBtn: any = null; - - $sourceSettingsOuterContainer: any = null; - $sourceSettingsContainer: any = null; - $sourceSettingsHeader: any = null; - $sourceMenu: any = null; - sourceMenu: any = null; - $footer: any = null; - $footerBtnContainer: any = null; - $saveBtn: any = null; - $cancelBtn: any = null; - $loadingSpinner: any = null; - - sourceDrag: any = null; - sources: any[] | null = null; - selectedSource: any = null; - destroying = false; - - elementTypeName: string | null = null; - baseSortOptions: any[] | null = null; - availableTableAttributes: any[] | null = null; - customFieldAttributes: any[] | null = null; - viewModes: any[] | null = null; - - conditionBuilderHtml: string | null = null; - conditionBuilderJs: string | null = null; - userGroups: any[] | null = null; - - #$container: any = null; - - constructor(elementIndex: any, settings?: any) { - // SAFETY: The legacy Modal base accepts an omitted container during deferred construction. - super(undefined as any, {autoShow: false}); - this.setSettings(settings, {resizable: true}); - - this.elementIndex = elementIndex; - - this.#$container = $('