Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add field type fields.color() #1279

Open
tordans opened this issue Aug 29, 2024 · 8 comments
Open

Add field type fields.color() #1279

tordans opened this issue Aug 29, 2024 · 8 comments

Comments

@tordans
Copy link
Contributor

tordans commented Aug 29, 2024

It would be great to have a fields.color field that shows the native color picker of the browser and returns a hex value.

@tordans
Copy link
Contributor Author

tordans commented Sep 14, 2024

@zanhk I can create and add custom field components? That sounds great! Do you know of an example on this or some docs?

@zanhk
Copy link

zanhk commented Sep 14, 2024

@tordans yes you can create custom fields components, I don't think there is docs about it.

for usage you can simply use like this

import { ColorPicker } from "keystatic-components";

....

primary: ColorPicker({
  label: "Primary Color",
  description: "Main color of the site, the selected color will be used to create a color palette based on the chosen color",
}),

@zanhk
Copy link

zanhk commented Sep 14, 2024

If people need I made an icon picker as well

import { Icon } from "@iconify/react";
import { FieldPrimitive } from "@keystar/ui/field";
import type { BasicFormField } from "@keystatic/core";
import { useEffect, useState } from "react";
import AsyncSelect from "react-select/async";
import packageJson from "../package.json";

const customStyles = {
	control: (provided: any, state: { isFocused: boolean }) => ({
		...provided,
		fontFamily: "var(--kui-typography-font-family-base)",
		paddingBlock: "var(--kui-size-space-small)",
		paddingInline: "var(--kui-size-space-medium)",
		paddingLeft: "6px",
		paddingRight: "0",
		maxWidth: "20rem",
		minHeight: "32px",
		height: "32px",
	}),
	valueContainer: (provided: any) => ({
		...provided,
		height: "30px",
		paddingLeft: "2px",
		paddingTop: "0",
		paddingBottom: "8px",
	}),
	input: (provided: any) => ({
		...provided,
		fontFamily: "var(--kui-typography-font-family-base)",
		fontWeight: "var(--kui-typography-font-weight-regular)",
		fontSize: "var(--kui-typography-text-regular-size)",
		margin: "0px",
	}),
	singleValue: (provided: any) => ({
		...provided,
		fontFamily: "var(--kui-typography-font-family-base)",
		fontWeight: "var(--kui-typography-font-weight-regular)",
		fontSize: "var(--kui-typography-text-regular-size)",
		display: "flex",
		alignItems: "center",
	}),
	menu: (provided: any) => ({
		...provided,
		fontFamily: "var(--kui-typography-font-family-base)",
		maxWidth: "20rem",
	}),
	option: (provided: any, state: { isFocused: boolean }) => ({
		...provided,
		fontFamily: "var(--kui-typography-font-family-base)",
		display: "flex",
		alignItems: "center",
	}),
	indicatorSeparator: () => ({
		display: "none",
	}),
	indicatorsContainer: (provided: any) => ({
		...provided,
		height: "30px",
		paddingBottom: "6px",
		paddingRight: "0",
	}),
	container: (provided: any) => ({
		...provided,
		maxWidth: "20rem",
	}),
};

function getIconifySets() {
	const iconifySets = Object.entries(packageJson.devDependencies || {})
		.filter(([key]) => key.startsWith("@iconify-json/"))
		.map(([key, version]) => {
			const setName = key.split("/")[1];
			return {
				key: setName,
				prefix: `${setName}:`,
				url: `https://cdn.jsdelivr.net/npm/${key}@${version}/icons.json`,
			};
		});
	return iconifySets;
}

const iconSets = getIconifySets();

async function fetchIconOptions(inputValue: string) {
	const options = [];
	for (const set of iconSets) {
		try {
			const response = await fetch(set.url);
			const data = await response.json();
			const icons = Object.keys(data.icons)
				.filter((icon) => icon.toLowerCase().includes(inputValue.toLowerCase()))
				.slice(0, 50);
			options.push(
				...icons.map((icon) => ({
					label: `${set.key}:${icon}`,
					value: `${set.prefix}${icon}`,
					icon: `${set.prefix}${icon}`,
				})),
			);
		} catch (error) {
			console.error(`Error fetching icons for ${set.key}:`, error);
		}
	}
	return options;
}

export function IconPicker({
	label,
	defaultValue = "mdi:home",
	description,
}: {
	label: string;
	defaultValue?: string;
	description?: string;
}): BasicFormField<string> {
	return {
		kind: "form",
		Input({ value, onChange, autoFocus, forceValidation }) {
			const [selectedOption, setSelectedOption] = useState<{ value: string; label: string; icon: string } | null>(null);

			useEffect(() => {
				if (value) {
					const [prefix, iconName] = value.split(":");
					setSelectedOption({ value, label: `${prefix}:${iconName}`, icon: value });
				}
			}, [value]);

			const handleChange = (option: { value: string; label: string; icon: string } | null) => {
				setSelectedOption(option);
				onChange(option?.value ?? "");
			};

			const loadOptions = (inputValue: string) =>
				new Promise<{ label: string; value: string; icon: string }[]>((resolve) => {
					setTimeout(() => {
						fetchIconOptions(inputValue).then(resolve);
					}, 300);
				});

			const customOption = ({ data, innerProps, isFocused, isSelected }) => (
				<div
					{...innerProps}
					style={{
						display: "flex",
						alignItems: "center",
						padding: "8px",
						color: isSelected ? "white" : "var(--kui-color-foreground-neutral)",
						background: isSelected ? "#3a5ccc" : isFocused ? "#f9f1fe" : "transparent",
					}}
				>
					<Icon icon={data.icon} style={{ marginRight: "8px" }} />
					{data.label}
				</div>
			);

			return (
				<FieldPrimitive label={label} description={description}>
					<>
						<AsyncSelect
							cacheOptions
							defaultOptions
							loadOptions={loadOptions}
							value={selectedOption}
							onChange={handleChange}
							placeholder="Search for an icon..."
							autoFocus={autoFocus}
							styles={customStyles}
							components={{ Option: customOption }}
							menuPortalTarget={document.body}
							formatOptionLabel={(option) => (
								<div style={{ display: "flex", alignItems: "center" }}>
									<Icon icon={option.icon} style={{ marginRight: "8px" }} />
									<span>{option.label}</span>
								</div>
							)}
						/>
						<small
							style={{
								fontFamily: "var(--kui-typography-font-family-base)",
								color: "var(--kui-color-content-subtle)",
								paddingTop: 0,
								paddingLeft: "2px",
								paddingBottom: 0,
								marginTop: "-2px",
								marginBottom: 0,
							}}
						>
							Find all available icons at{" "}
							<a href="https://icon-sets.iconify.design/" target="_blank" rel="noopener noreferrer">
								Iconify Icon Sets
							</a>{" "}
							(available sets: {iconSets.map((set) => set.key).join(", ")})
						</small>
					</>
				</FieldPrimitive>
			);
		},
		defaultValue() {
			return defaultValue;
		},
		parse(value) {
			return value as string;
		},
		serialize(value) {
			return { value };
		},
		validate(value) {
			return value;
		},
		reader: {
			parse(value) {
				return value as string;
			},
		},
	};
}

@ismaelrumzan
Copy link

Nice @zanhk
can you use the ColorPicker as a field in the config? Or it would have to be as item in the wysiwyg editor. I tried to add it directly as a value of a schema item in my config and got an error

@zanhk
Copy link

zanhk commented Oct 17, 2024

@ismaelrumzan Show me how you are using it and what error you are getting

@ismaelrumzan
Copy link

Thanks, here is part of my config (it's part of a singleton)

        sections: fields.blocks(
          {
            card: {
              label: "Cards section",
              schema: fields.object({
                title: fields.text({ label: "Section title" }),
                subtitle: fields.text({ label: "Section subtitle" }),
                background: ColorPicker({ label: "Section background color" }),
                cards: fields.array(
                  fields.object({
                    image: fields.image({
                      label: "Card cover image",
                      directory: "public/images/home",
                      publicPath: "/images/home",
                    }),
                    title: fields.text({ label: "Card title" }),
                  })
                ),
              }),
            },
          },
          { label: "Content blocks" }
        ),

And the front-end error when loading the admin page

./node_modules/.pnpm/keystatic-components@0.0.2_next@14.2.15_react-dom@18.3.1/node_modules/keystatic-components/components/ColorPicker.tsx
Module parse failed: Unexpected token (2:12)
| import { FieldPrimitive } from "@keystar/ui/field";
> import type { BasicFormField, FormFieldStoredValue } from "@keystatic/core";
| import { HexColorInput, HexColorPicker } from "react-colorful";
|

@zanhk
Copy link

zanhk commented Oct 24, 2024

Hi @ismaelrumzan sorry for the delayed response, it could be the configuration on tsconfig.json maybe? I will make some repo using ColorPicker open source so you can take a look there

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants