Skip to content
Draft
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
17 changes: 17 additions & 0 deletions packages/craftcms-cp/scripts/generate-vue-wrappers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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`<craft-input-color
label="${label}"
.modelValue="${value}"
></craft-input-color>`;
},
} satisfies Meta<any>;

export default meta;
type Story = StoryObj<any>;

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`<craft-input-color
label="Fill Color"
.modelValue="${'7ab55c'}"
disabled
></craft-input-color>`,
};

export const WithPresets: Story = {
render: () => html`
<craft-input-color
label="Fill Color"
.modelValue="${'7ab55c'}"
.presets="${['#ffffff', '000000', '#7ab55c', 'e5422b']}"
></craft-input-color>
`,
};

export const WithError: Story = {
render: () => html`
<craft-input-color
label="Fill Color"
.modelValue="${'not-a-color'}"
has-feedback-for="error"
>
<div slot="feedback">
<ul class="error-list">
<li>Enter a valid hex color.</li>
</ul>
</div>
</craft-input-color>
`,
};
120 changes: 120 additions & 0 deletions packages/craftcms-cp/src/components/input-color/input-color.styles.ts
Original file line number Diff line number Diff line change
@@ -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;
}
`;
125 changes: 125 additions & 0 deletions packages/craftcms-cp/src/components/input-color/input-color.test.ts
Original file line number Diff line number Diff line change
@@ -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<CraftInputColor> {
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);
});
});
Loading
Loading