diff --git a/packages/craftcms-cp/scripts/generate-vue-wrappers.js b/packages/craftcms-cp/scripts/generate-vue-wrappers.js index 4608ae50f4b..55235c1c9ba 100644 --- a/packages/craftcms-cp/scripts/generate-vue-wrappers.js +++ b/packages/craftcms-cp/scripts/generate-vue-wrappers.js @@ -44,6 +44,23 @@ const VALUE_COMPONENTS = [ 'after', ], }, + { + tagName: 'craft-input-color', + className: 'CraftInputColor', + fileName: 'CraftInputColor', + modelType: 'string', + importPath: '../components/input-color/input-color', + slots: [ + 'label', + 'help-text', + 'input', + 'feedback', + 'prefix', + 'suffix', + 'before', + 'after', + ], + }, { tagName: 'craft-input-handle', className: 'CraftInputHandle', diff --git a/packages/craftcms-cp/src/components/input-color/input-color.stories.ts b/packages/craftcms-cp/src/components/input-color/input-color.stories.ts new file mode 100644 index 00000000000..e4adafb1829 --- /dev/null +++ b/packages/craftcms-cp/src/components/input-color/input-color.stories.ts @@ -0,0 +1,74 @@ +import type {Meta, StoryObj} from '@storybook/web-components-vite'; + +import {html} from 'lit'; + +import './input-color.js'; + +const meta = { + title: 'Controls/InputColor', + component: 'craft-input-color', + args: { + label: 'Fill Color', + value: '7ab55c', + }, + render: function ({label, value}) { + return html``; + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Shorthand: Story = { + args: { + value: 'abc', + }, +}; + +export const Invalid: Story = { + args: { + value: 'not-a-color', + }, +}; + +export const Disabled: Story = { + render: () => + html``, +}; + +export const WithPresets: Story = { + render: () => html` + + `, +}; + +export const WithError: Story = { + render: () => html` + +
+
    +
  • Enter a valid hex color.
  • +
+
+
+ `, +}; diff --git a/packages/craftcms-cp/src/components/input-color/input-color.styles.ts b/packages/craftcms-cp/src/components/input-color/input-color.styles.ts new file mode 100644 index 00000000000..aba1e110b13 --- /dev/null +++ b/packages/craftcms-cp/src/components/input-color/input-color.styles.ts @@ -0,0 +1,120 @@ +import {css} from 'lit'; +import {baseFieldStyles, baseInputStyles} from '../../styles/form.styles'; + +export default css` + ${baseFieldStyles} + + :host { + display: block; + } + + .input-color { + display: grid; + gap: var(--c-spacing-sm); + } + + .input-color__control { + display: flex; + align-items: center; + gap: var(--c-spacing-sm); + } + + .input-color__swatch { + position: relative; + display: block; + flex: 0 0 auto; + inline-size: var(--c-input-height, var(--c-size-control-md)); + block-size: var(--c-input-height, var(--c-size-control-md)); + border-radius: 50%; + overflow: hidden; + background: + linear-gradient( + 45deg, + var(--c-color-neutral-fill-quiet) 25%, + transparent 25% + ), + linear-gradient( + -45deg, + var(--c-color-neutral-fill-quiet) 25%, + transparent 25% + ), + linear-gradient( + 45deg, + transparent 75%, + var(--c-color-neutral-fill-quiet) 75% + ), + linear-gradient( + -45deg, + transparent 75%, + var(--c-color-neutral-fill-quiet) 75% + ); + background-position: + 0 0, + 0 0.375rem, + 0.375rem -0.375rem, + -0.375rem 0; + background-size: 0.75rem 0.75rem; + } + + :host(:not([disabled])) .input-color__swatch { + cursor: pointer; + } + + .input-color__swatch:focus-within { + box-shadow: var( + --focus-ring, + 0 0 0 2px var(--c-color-accent-border-normal) + ); + } + + .input-color__preview { + position: absolute; + inset: 0; + border-radius: 50%; + box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%); + } + + .input-color__picker { + position: absolute; + inset: 0; + inline-size: 100%; + block-size: 100%; + border: 0; + margin: 0; + padding: 0; + opacity: 0; + } + + .input-group__container { + ${baseInputStyles} + flex: 0 0 7.25rem; + inline-size: 7.25rem; + max-inline-size: 100%; + } + + .input-group__input { + display: flex; + flex: 1 1 auto; + } + + .input-group__prefix { + color: var(--c-text-quiet); + user-select: none; + font-family: var(--c-font-mono); + padding-inline: var(--c-input-spacing-inline) 0; + display: grid; + place-items: center; + } + + ::slotted([slot='input']) { + width: 100%; + min-inline-size: 0; + font: inherit; + font-family: var(--c-font-mono); + padding-block: 0; + padding-inline: var(--c-spacing-xs) var(--c-input-spacing-inline); + border: 0; + appearance: none; + background-color: transparent; + } +`; diff --git a/packages/craftcms-cp/src/components/input-color/input-color.test.ts b/packages/craftcms-cp/src/components/input-color/input-color.test.ts new file mode 100644 index 00000000000..61e85ebdd41 --- /dev/null +++ b/packages/craftcms-cp/src/components/input-color/input-color.test.ts @@ -0,0 +1,125 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import type CraftInputColor from './input-color.js'; +import './input-color.js'; + +async function createInputColor(): Promise { + const element = document.createElement('craft-input-color'); + element.label = 'Fill Color'; + document.body.append(element); + await element.updateComplete; + + return element; +} + +function textInput(element: CraftInputColor): HTMLInputElement { + return element.querySelector('input[slot="input"]') as HTMLInputElement; +} + +function pickerInput(element: CraftInputColor): HTMLInputElement { + return element.shadowRoot?.querySelector( + '.input-color__picker' + ) as HTMLInputElement; +} + +function preview(element: CraftInputColor): HTMLElement { + return element.shadowRoot?.querySelector( + '.input-color__preview' + ) as HTMLElement; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +describe('craft-input-color', () => { + it('strips a leading # from typed values', async () => { + const element = await createInputColor(); + const input = textInput(element); + + input.value = '#abc'; + input.dispatchEvent( + new InputEvent('input', {bubbles: true, composed: true}) + ); + await element.updateComplete; + + expect(element.modelValue).toBe('abc'); + expect(input.value).toBe('abc'); + }); + + it('preserves shorthand values while expanding the picker and preview values', async () => { + const element = await createInputColor(); + + element.modelValue = 'abc'; + await element.updateComplete; + + expect(element.modelValue).toBe('abc'); + expect(pickerInput(element).value).toBe('#aabbcc'); + expect(preview(element).getAttribute('style')).toContain( + 'background-color: #aabbcc' + ); + }); + + it('keeps invalid text values and clears the preview', async () => { + const element = await createInputColor(); + + element.modelValue = 'not-a-color'; + await element.updateComplete; + + expect(textInput(element).value).toBe('not-a-color'); + expect(preview(element).getAttribute('style')).toBe(''); + }); + + it('updates the model from the native color picker without a # prefix', async () => { + const element = await createInputColor(); + const picker = pickerInput(element); + + picker.value = '#112233'; + picker.dispatchEvent( + new InputEvent('input', {bubbles: true, composed: true}) + ); + await element.updateComplete; + + expect(element.modelValue).toBe('112233'); + expect(textInput(element).value).toBe('112233'); + }); + + it('parses presets from a JSON attribute and normalizes them for the picker datalist', async () => { + const element = await createInputColor(); + + element.setAttribute('presets', '["abc", "#112233", "not-a-color"]'); + await element.updateComplete; + + const options = [ + ...element.shadowRoot!.querySelectorAll('datalist option'), + ].map((option) => option.getAttribute('value')); + + expect(options).toEqual(['#aabbcc', '#112233']); + }); + + it('sets disabled state on the native picker', async () => { + const element = await createInputColor(); + + element.disabled = true; + await element.updateComplete; + + expect(pickerInput(element).disabled).toBe(true); + }); + + it('submits the no-# value with native forms', async () => { + const form = document.createElement('form'); + const element = document.createElement('craft-input-color'); + + element.name = 'fill'; + form.append(element); + document.body.append(form); + element.modelValue = '#abc'; + await element.updateComplete; + + expect(new FormData(form).get('fill')).toBe('abc'); + + element.disabled = true; + await element.updateComplete; + + expect(new FormData(form).has('fill')).toBe(false); + }); +}); diff --git a/packages/craftcms-cp/src/components/input-color/input-color.ts b/packages/craftcms-cp/src/components/input-color/input-color.ts new file mode 100644 index 00000000000..35e4accea8d --- /dev/null +++ b/packages/craftcms-cp/src/components/input-color/input-color.ts @@ -0,0 +1,224 @@ +import {LionInput} from '@lion/ui/input.js'; +import {html, nothing} from 'lit'; +import {property} from 'lit/decorators.js'; +import {inputStyles} from '@src/styles/form.styles'; +import {t} from '@src/utilities/translate'; +import styles from './input-color.styles.js'; + +const HEX_COLOR_PATTERN = /^[0-9a-f]{6}$/i; +const SHORT_HEX_COLOR_PATTERN = /^[0-9a-f]{3}$/i; + +const presetsConverter = { + fromAttribute(value: string | null): string[] { + if (!value) { + return []; + } + + try { + const parsedValue = JSON.parse(value); + + if (!Array.isArray(parsedValue)) { + return []; + } + + return parsedValue.map((preset) => String(preset)); + } catch { + return []; + } + }, +}; + +function normalizeColorValue(value: unknown): string { + return String(value ?? '') + .trim() + .replace(/^#/, ''); +} + +function expandedHexValue(value: unknown): string | null { + const normalizedValue = normalizeColorValue(value); + + if (SHORT_HEX_COLOR_PATTERN.test(normalizedValue)) { + return normalizedValue + .split('') + .map((character) => character + character) + .join(''); + } + + if (HEX_COLOR_PATTERN.test(normalizedValue)) { + return normalizedValue; + } + + return null; +} + +export default class CraftInputColor extends LionInput { + static override get styles() { + return [...super.styles, inputStyles, styles]; + } + + static _browserSupportsColorInputs: boolean | null = null; + + @property({converter: presetsConverter}) + presets: string[] = []; + + protected _pickerListId = `${this._inputId}-presets`; + + constructor() { + super(); + this.type = 'text'; + } + + static doesBrowserSupportColorInputs(): boolean { + if (CraftInputColor._browserSupportsColorInputs === null) { + const input = document.createElement('input'); + input.setAttribute('type', 'color'); + CraftInputColor._browserSupportsColorInputs = input.type === 'color'; + } + + return CraftInputColor._browserSupportsColorInputs; + } + + override get slots() { + return { + ...super.slots, + input: () => { + const input = document.createElement('input'); + const value = this.getAttribute('value'); + + input.type = 'text'; + input.inputMode = 'text'; + input.spellcheck = false; + input.setAttribute('autocorrect', 'off'); + input.setAttribute('autocapitalize', 'off'); + + if (value) { + input.setAttribute('value', normalizeColorValue(value)); + } + + return input; + }, + }; + } + + override parser(value: string) { + return normalizeColorValue(value); + } + + override formatter(value: unknown) { + return normalizeColorValue(value); + } + + override serializer(value: unknown) { + return normalizeColorValue(value); + } + + override deserializer(value: string) { + return normalizeColorValue(value); + } + + override preprocessor(value: string) { + const normalizedValue = normalizeColorValue(value); + + if (normalizedValue === value) { + return undefined; + } + + return normalizedValue; + } + + protected get _expandedHexValue(): string | null { + return expandedHexValue(this.modelValue); + } + + protected get _pickerValue(): string { + return this._expandedHexValue ? `#${this._expandedHexValue}` : '#ffffff'; + } + + protected get _validPresets(): string[] { + return this.presets + .map((preset) => expandedHexValue(preset)) + .filter((preset): preset is string => preset !== null) + .map((preset) => `#${preset}`); + } + + protected _handlePickerInput(event: Event) { + const input = event.target as HTMLInputElement; + const normalizedValue = normalizeColorValue(input.value); + const wasHandlingUserInput = this._isHandlingUserInput; + + this._isHandlingUserInput = true; + this.modelValue = normalizedValue; + this.value = normalizedValue; + this._isHandlingUserInput = wasHandlingUserInput; + } + + protected _pickerTemplate() { + if (!CraftInputColor.doesBrowserSupportColorInputs()) { + return nothing; + } + + const presets = this._validPresets; + + return html` + + ${presets.length + ? html` + + ${presets.map( + (preset) => html`` + )} + + ` + : nothing} + `; + } + + protected _swatchTemplate() { + const previewStyle = this._expandedHexValue + ? `background-color: #${this._expandedHexValue}` + : ''; + + return html` + + `; + } + + override _inputGroupTemplate() { + return html` +
+ ${this._inputGroupBeforeTemplate()} +
+ ${this._swatchTemplate()} +
+ + ${this._inputGroupInputTemplate()} + ${this._inputGroupSuffixTemplate()} +
+
+ ${this._inputGroupAfterTemplate()} +
+ `; + } +} + +if (!customElements.get('craft-input-color')) { + customElements.define('craft-input-color', CraftInputColor); +} + +declare global { + interface HTMLElementTagNameMap { + 'craft-input-color': CraftInputColor; + } +} diff --git a/packages/craftcms-cp/src/index.ts b/packages/craftcms-cp/src/index.ts index fc68049f859..2b8fd5c9bde 100644 --- a/packages/craftcms-cp/src/index.ts +++ b/packages/craftcms-cp/src/index.ts @@ -15,6 +15,7 @@ export {default as CraftCopyButton} from './components/copy-button/copy-button.j export {default as CraftButton} from './components/button/button.js'; export {default as CraftAvatar} from './components/avatar/avatar.js'; export {default as CraftInput} from './components/input/input.js'; +export {default as CraftInputColor} from './components/input-color/input-color.js'; export {default as CraftInputFile} from './components/input-file/input-file.js'; export {default as CraftInputHandle} from './components/input-handle/input-handle.js'; export {default as CraftInputPassword} from './components/input-password/input-password.js'; diff --git a/packages/craftcms-cp/src/styles/form.styles.ts b/packages/craftcms-cp/src/styles/form.styles.ts index a328d52b580..03feebe72ac 100644 --- a/packages/craftcms-cp/src/styles/form.styles.ts +++ b/packages/craftcms-cp/src/styles/form.styles.ts @@ -92,4 +92,8 @@ export const inputStyles = css` :host([center]) ::slotted([slot='input']) { text-align: center; } + + ::slotted([slot='input']) { + width: 100%; + } `; diff --git a/packages/craftcms-cp/vitest.config.ts b/packages/craftcms-cp/vitest.config.ts index 4b413cdd686..ff016216e31 100644 --- a/packages/craftcms-cp/vitest.config.ts +++ b/packages/craftcms-cp/vitest.config.ts @@ -32,6 +32,16 @@ export default defineConfig({ environment: 'happy-dom', }, }, + { + resolve: { + tsconfigPaths: true, + }, + test: { + name: 'components', + root: './src/components', + environment: 'happy-dom', + }, + }, { extends: true, plugins: [ diff --git a/resources/js/pages/settings/assets/transforms/EditImageTransformPage.vue b/resources/js/pages/settings/assets/transforms/EditImageTransformPage.vue new file mode 100644 index 00000000000..a3d7b04ce27 --- /dev/null +++ b/resources/js/pages/settings/assets/transforms/EditImageTransformPage.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/resources/js/pages/SettingsImageTransformsIndexPage.vue b/resources/js/pages/settings/assets/transforms/ImageTransformsIndexPage.vue similarity index 81% rename from resources/js/pages/SettingsImageTransformsIndexPage.vue rename to resources/js/pages/settings/assets/transforms/ImageTransformsIndexPage.vue index bd368725a0a..2c2ceac169a 100644 --- a/resources/js/pages/SettingsImageTransformsIndexPage.vue +++ b/resources/js/pages/settings/assets/transforms/ImageTransformsIndexPage.vue @@ -8,6 +8,7 @@ import { create, destroy, + edit, index as imageTransformsIndex, } from '@actions/Settings/ImageTransformsController'; import AdminTable from '@/components/AdminTable/AdminTable.vue'; @@ -15,25 +16,9 @@ import Empty from '@/components/Empty.vue'; import {router} from '@inertiajs/vue3'; import {index} from '@actions/Settings/VolumesController'; + import type {ExistingImageTransform} from '@/pages/settings/assets/transforms/types'; - export interface ImageTransform { - id: number; - name: string; - handle: string; - width: number; - height: number; - format: any; - quality: number; - mode: string; - position: string; - interlace: string; - fill: any; - upscale: boolean; - uid: string; - parameterChangeTime: any[]; - } - - function deleteTransform(transform: ImageTransform) { + function deleteTransform(transform: ExistingImageTransform) { if ( confirm( t('Are you sure you want to delete the “{name}” transform?', { @@ -41,22 +26,32 @@ }) ) ) { - router.delete(destroy(transform.id)); + router + .optimistic<{transforms: Array}>((props) => ({ + transforms: props.transforms.filter(({id}) => id !== transform.id), + })) + .delete(destroy(transform.id), { + preserveScroll: true, + }); } } const props = defineProps<{ - transforms: Array; + transforms: Array; }>(); const columnVisibility = ref({ name: true, handle: true, }); - const columnHelper = createCraftColumnHelper(); + const columnHelper = createCraftColumnHelper(); const columns = ref([ columnHelper.link('name', { header: t('Name'), + props: ({row}) => ({ + href: edit(row.original.handle).url, + inertia: true, + }), }), columnHelper.handle('handle'), columnHelper.accessor('mode', { @@ -94,7 +89,7 @@ return columns.value; }, enableSorting: false, - getCoreRowModel: getCoreRowModel(), + getCoreRowModel: getCoreRowModel(), state: { get columnVisibility() { return columnVisibility.value; @@ -104,7 +99,7 @@ const navItems = computed(() => { return { - volumes: {label: t('Volumes'), url: index().url}, + volumes: {label: t('Volumes'), url: index().url, active: false}, transforms: { label: t('Image Transforms'), url: imageTransformsIndex().url, @@ -119,7 +114,6 @@