diff --git a/pkgs/clan-app/ui/src/components/v2/Form/Checkbox.stories.tsx b/pkgs/clan-app/ui/src/components/v2/Form/Checkbox.stories.tsx new file mode 100644 index 000000000..4685a5e29 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/Checkbox.stories.tsx @@ -0,0 +1,68 @@ +import meta, { Story } from "./TextField.stories"; + +const checkboxMeta = { + ...meta, + title: "Components/Form/Checkbox", +}; + +export default checkboxMeta; + +export const Bare: Story = { + args: { + type: "checkbox", + }, +}; + +export const Label: Story = { + args: { + ...Bare.args, + label: "Accept Terms", + }, +}; + +export const Description: Story = { + args: { + ...Label.args, + description: "That stuff you never bother reading", + }, +}; + +export const Required: Story = { + args: { + ...Description.args, + required: true, + }, +}; + +export const Tooltip: Story = { + args: { + ...Required.args, + tooltip: + "Let people know how you got here, great achievements or obstacles overcome", + }, +}; + +export const Invalid: Story = { + args: { + ...Tooltip.args, + invalid: true, + }, +}; + +export const Disabled: Story = { + args: { + ...Tooltip.args, + disabled: true, + }, +}; + +export const ReadOnly: Story = { + args: { + ...Tooltip.args, + readOnly: true, + checkbox: { + ...Tooltip.args.checkbox, + defaultChecked: true, + }, + }, +}; diff --git a/pkgs/clan-app/ui/src/components/v2/Form/Field.css b/pkgs/clan-app/ui/src/components/v2/Form/Field.css new file mode 100644 index 000000000..d108d005f --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/Field.css @@ -0,0 +1,212 @@ +div.form-field { + @apply flex items-center w-full; + + & > div.meta { + @apply flex flex-col gap-1 w-full; + + & > label, + & > div { + @apply w-full; + /* remove line height which messes with sizing */ + @apply leading-none; + } + + & > label { + @apply flex items-center gap-1; + } + + & > label[data-required] { + span.typography::after { + @apply fg-def-4 ml-1; + + content: "*"; + font-family: "Commit Mono", monospace; + font-size: 0.6875rem; + } + } + + .tooltip-trigger { + } + } + + & input, + & textarea { + @apply w-full px-2 py-1.5 rounded-sm; + @apply outline outline-1 outline-def-acc-1 bg-def-1 fg-def-1; + + font-weight: 500; + font-family: "Archivo", sans-serif; + line-height: 132%; + + &::placeholder { + @apply fg-def-4; + } + + &:hover { + @apply bg-def-acc-1 outline-def-acc-2; + } + + &:focus-visible { + @apply bg-def-1 outline-def-acc-3; + + box-shadow: + 0 0 0 0.125rem theme(colors.bg.def.1), + 0 0 0 0.1875rem theme(colors.border.semantic.info.1); + } + + &[data-invalid] { + @apply outline-semantic-error-4; + } + + &[data-disabled] { + @apply outline-def-2 fg-def-4 cursor-not-allowed; + } + + &[data-readonly] { + @apply outline-def-2 cursor-not-allowed; + } + } + + &.orientation-vertical { + @apply flex-col gap-2; + } + + &.orientation-horizontal { + @apply flex-row gap-2 justify-between; + + & > div.meta { + @apply w-1/2 shrink; + } + + & div.input-container, + & textarea { + @apply w-1/2 grow; + } + + &:has(> textarea) { + @apply items-start; + } + } + + div.input-container { + @apply inline-block relative w-full; + + /* I'm unsure why I have to do this */ + @apply leading-none; + + & > input { + @apply w-full; + + &.has-icon { + @apply pl-7; + } + } + + & > .icon { + @apply absolute left-2 top-1/2 transform -translate-y-1/2; + @apply w-[0.875rem] h-[0.875rem] pointer-events-none; + } + } + + &.form-field-checkbox { + @apply items-start; + + & > div.checkbox-control { + @apply w-5 h-5 rounded-sm bg-def-1 border border-inv-1 p-[0.0625rem]; + + &[data-disabled] { + @apply border-def-2; + } + + &[data-invalid] { + @apply border-semantic-error-4; + } + } + } + + &.size-default { + & input, + & textarea { + font-size: 0.875rem; + } + + div.input-container { + @apply h-[1.875rem]; + + input { + @apply h-[1.875rem]; + } + } + } + + &.size-s { + & input, + & textarea { + @apply px-1.5 py-1; + font-size: 0.75rem; + } + + div.input-container { + @apply h-[1.25rem]; + + input { + @apply h-[1.25rem]; + } + + input.has-icon { + @apply pl-6; + } + + & > .icon { + @apply w-[0.6875rem] h-[0.6875rem] transform -translate-y-1/2; + } + } + } + + &.inverted { + & input, + & textarea { + @apply bg-inv-1 fg-inv-1 outline-inv-acc-1; + + &::placeholder { + @apply fg-inv-4; + } + + &:hover { + @apply bg-inv-acc-2 outline-inv-acc-2; + } + + &:focus-visible { + @apply bg-inv-acc-4; + box-shadow: + 0 0 0 0.125rem theme(colors.bg.inv.1), + 0 0 0 0.1875rem theme(colors.border.semantic.info.1); + } + + &[data-invalid] { + @apply outline-semantic-error-4; + } + } + + &.form-field-checkbox { + & > div.checkbox-control { + @apply bg-inv-acc-4; + + &[data-disabled] { + @apply bg-def-4 border-none; + } + } + } + } + + &.ghost { + & input, + & textarea { + @apply outline-none; + + &:hover { + @apply outline-none; + } + } + } +} diff --git a/pkgs/clan-app/ui/src/components/v2/Form/Field.tsx b/pkgs/clan-app/ui/src/components/v2/Form/Field.tsx new file mode 100644 index 000000000..2e762ee23 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/Field.tsx @@ -0,0 +1,214 @@ +import { + TextField as KTextField, + TextFieldInputProps as KTextFieldInputProps, + TextFieldTextAreaProps as KTextFieldTextAreaProps, +} from "@kobalte/core/text-field"; +import { + Checkbox as KCheckbox, + CheckboxInputProps as KCheckboxInputProps, +} from "@kobalte/core/checkbox"; +import { Typography } from "@/src/components/v2/Typography/Typography"; +import Icon, { IconVariant } from "@/src/components/v2/Icon/Icon"; + +import cx from "classnames"; +import { Match, splitProps, Switch } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { Tooltip as KTooltip } from "@kobalte/core/tooltip"; +import "./Field.css"; + +type Size = "default" | "s"; +type Orientation = "horizontal" | "vertical"; +type FieldType = "text" | "textarea" | "checkbox"; + +export interface TextFieldProps { + input?: KTextFieldInputProps; + value?: string; + onChange?: (value: string) => void; + defaultValue?: string; +} + +export interface TextAreaProps { + input?: KTextFieldTextAreaProps; + value?: string; + onChange?: (value: string) => void; + defaultValue?: string; +} + +export interface CheckboxProps { + input?: KCheckboxInputProps; + checked?: boolean; + defaultChecked?: boolean; + indeterminate?: boolean; + onChange?: (value: boolean) => void; +} + +export interface FieldProps { + class?: string; + name?: string; + label?: string; + description?: string; + tooltip?: string; + icon?: IconVariant; + ghost?: boolean; + + size?: Size; + orientation?: Orientation; + inverted?: boolean; + + required?: boolean; + disabled?: boolean; + readOnly?: boolean; + invalid?: boolean; + + type: FieldType; + text?: TextFieldProps; + textarea?: TextAreaProps; + checkbox?: CheckboxProps; +} + +const componentsForType = { + text: { + container: KTextField, + label: KTextField.Label, + description: KTextField.Description, + }, + textarea: { + container: KTextField, + label: KTextField.Label, + description: KTextField.Description, + }, + checkbox: { + container: KCheckbox, + label: KCheckbox.Label, + description: KCheckbox.Description, + }, +}; + +export const Field = (props: FieldProps) => { + const [commonProps] = splitProps(props, [ + "name", + "class", + "required", + "disabled", + "readOnly", + ]); + + const [textProps] = splitProps(props.text || props.textarea || {}, [ + "value", + "onChange", + "defaultValue", + ]); + + const [checkboxProps] = splitProps(props.checkbox || {}, [ + "checked", + "defaultChecked", + "indeterminate", + ]); + + const validationState = () => (props.invalid ? "invalid" : "valid"); + + const labelSize = () => `size-${props.size || "default"}`; + const orientation = () => `orientation-${props.orientation || "vertical"}`; + const descriptionSize = () => (labelSize() == "size-default" ? "xs" : "xxs"); + const fieldClass = () => (props.type ? `form-field-${props.type}` : ""); + + const { container, label, description } = componentsForType[props.type]; + + return ( + + {props.label && ( +
+ {props.label && ( + + + {props.label} + + + {props.tooltip && ( + + + + + + + {props.tooltip} + + + + + + )} + + )} + {props.description && ( + + + {props.description} + + + )} +
+ )} + + + +
+ {props.icon && ( + + )} + +
+
+ + + + + + + + + + + +
+
+ ); +}; diff --git a/pkgs/clan-app/ui/src/components/v2/Form/TextArea.stories.tsx b/pkgs/clan-app/ui/src/components/v2/Form/TextArea.stories.tsx new file mode 100644 index 000000000..cb87a9586 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/TextArea.stories.tsx @@ -0,0 +1,82 @@ +import meta, { Icon, Story } from "./TextField.stories"; + +const textAreaMeta = { + ...meta, + title: "Components/Form/TextArea", +}; + +export default textAreaMeta; + +export const Bare: Story = { + args: { + type: "textarea", + textarea: { + input: { + rows: 10, + placeholder: "I like craft beer and long walks on the beach", + }, + }, + }, +}; + +export const Label: Story = { + args: { + ...Bare.args, + label: "Biography", + }, +}; + +export const Description: Story = { + args: { + ...Label.args, + description: "Tell us about yourself", + }, +}; + +export const Required: Story = { + args: { + ...Description.args, + required: true, + }, +}; + +export const Tooltip: Story = { + args: { + ...Required.args, + tooltip: + "Let people know how you got here, great achievements or obstacles overcome", + }, +}; + +export const Ghost: Story = { + args: { + ...Tooltip.args, + ghost: true, + }, +}; + +export const Invalid: Story = { + args: { + ...Tooltip.args, + invalid: true, + }, +}; + +export const Disabled: Story = { + args: { + ...Tooltip.args, + disabled: true, + }, +}; + +export const ReadOnly: Story = { + args: { + ...Tooltip.args, + readOnly: true, + textarea: { + ...Tooltip.args.textarea, + defaultValue: + "Good evening. I'm Ron Burgundy, and this is what's happening in your world tonight. ", + }, + }, +}; diff --git a/pkgs/clan-app/ui/src/components/v2/Form/TextField.stories.tsx b/pkgs/clan-app/ui/src/components/v2/Form/TextField.stories.tsx new file mode 100644 index 000000000..f4ca52ed6 --- /dev/null +++ b/pkgs/clan-app/ui/src/components/v2/Form/TextField.stories.tsx @@ -0,0 +1,125 @@ +import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; +import { Field, FieldProps } from "./Field"; +import cx from "classnames"; + +const FieldExamples = (props: FieldProps) => ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+); + +const meta = { + title: "Components/Form/TextField", + component: FieldExamples, + decorators: [ + (Story: StoryObj, context: StoryContext) => { + return ( +
+ +
+ ); + }, + ], +} satisfies Meta; + +export default meta; + +export type Story = StoryObj; + +export const Bare: Story = { + args: { + type: "text", + text: { + input: { + placeholder: "e.g. 11/06/89", + }, + }, + }, +}; + +export const Label: Story = { + args: { + ...Bare.args, + label: "DOB", + }, +}; + +export const Description: Story = { + args: { + ...Label.args, + description: "The date you were born", + }, +}; + +export const Required: Story = { + args: { + ...Description.args, + required: true, + }, +}; + +export const Tooltip: Story = { + args: { + ...Required.args, + tooltip: "The day you came out of your momma", + }, +}; + +export const Icon: Story = { + args: { + ...Tooltip.args, + icon: "Checkmark", + }, +}; + +export const Ghost: Story = { + args: { + ...Icon.args, + ghost: true, + }, +}; + +export const Invalid: Story = { + args: { + ...Tooltip.args, + invalid: true, + }, +}; + +export const Disabled: Story = { + args: { + ...Icon.args, + disabled: true, + }, +}; + +export const ReadOnly: Story = { + args: { + ...Icon.args, + readOnly: true, + text: { + defaultValue: "14/05/02", + }, + }, +}; diff --git a/pkgs/clan-app/ui/src/components/v2/Typography/Typography.css b/pkgs/clan-app/ui/src/components/v2/Typography/Typography.css index 3e8fb94bd..a8138671f 100644 --- a/pkgs/clan-app/ui/src/components/v2/Typography/Typography.css +++ b/pkgs/clan-app/ui/src/components/v2/Typography/Typography.css @@ -67,6 +67,12 @@ line-height: 1; letter-spacing: 0.0075rem; } + + &.size-xxs { + font-size: 0.6875rem; + line-height: 1; + letter-spacing: normal; + } } &.family-mono { diff --git a/pkgs/clan-app/ui/src/components/v2/Typography/Typography.tsx b/pkgs/clan-app/ui/src/components/v2/Typography/Typography.tsx index 05d467c4b..6026b2c7b 100644 --- a/pkgs/clan-app/ui/src/components/v2/Typography/Typography.tsx +++ b/pkgs/clan-app/ui/src/components/v2/Typography/Typography.tsx @@ -21,6 +21,7 @@ interface SizeForHierarchy { default: string; s: string; xs: string; + xxs: string; }; headline: { default: string; @@ -62,6 +63,7 @@ const sizeHierarchyMap: SizeForHierarchy = { default: cx("size-default"), s: cx("size-s"), xs: cx("size-xs"), + xxs: cx("size-xxs"), }, teaser: { default: cx("size-default"), diff --git a/pkgs/clan-app/ui/src/components/v2/colors.ts b/pkgs/clan-app/ui/src/components/v2/colors.ts index ca01cedc2..cc80ff31f 100644 --- a/pkgs/clan-app/ui/src/components/v2/colors.ts +++ b/pkgs/clan-app/ui/src/components/v2/colors.ts @@ -3,6 +3,7 @@ export type Color = | "secondary" | "tertiary" | "quaternary" + | "error" | "inherit"; export const AllColors: Color[] = [ @@ -10,6 +11,7 @@ export const AllColors: Color[] = [ "secondary", "tertiary", "quaternary", + "error", "inherit", ]; @@ -18,6 +20,7 @@ const colorMap: Record = { secondary: "fg-def-2", tertiary: "fg-def-3", quaternary: "fg-def-4", + error: "fg-semantic-error-4", inherit: "text-inherit", }; @@ -26,6 +29,7 @@ const invertedColorMap: Record = { secondary: "fg-inv-2", tertiary: "fg-inv-3", quaternary: "fg-inv-4", + error: "fg-semantic-error-2", inherit: "text-inherit", }; diff --git a/pkgs/clan-app/ui/tailwind/core-plugin.ts b/pkgs/clan-app/ui/tailwind/core-plugin.ts index fb98615f1..f2263321b 100644 --- a/pkgs/clan-app/ui/tailwind/core-plugin.ts +++ b/pkgs/clan-app/ui/tailwind/core-plugin.ts @@ -202,7 +202,7 @@ const colorSystem = { 1: primaries.secondary["950"], 2: primaries.secondary["900"], 3: primaries.secondary["700"], - 4: primaries.secondary["400"], + 4: primaries.secondary["500"], }, inv: { 1: primaries.off.white, @@ -283,6 +283,13 @@ export default plugin.withOptions( addUtilities(mkColorUtil(["border-r"], "borderRight", border)); addUtilities(mkColorUtil(["border-b"], "borderBottom", border)); addUtilities(mkColorUtil(["border-l"], "borderLeft", border)); + + // re-use the border colors for outline colors + addUtilities(mkColorUtil(["outline"], "outlineColor", border)); + addUtilities(mkColorUtil(["outline-t"], "outlineTop", border)); + addUtilities(mkColorUtil(["outline-r"], "outlineRight", border)); + addUtilities(mkColorUtil(["outline-b"], "outlineBottom", border)); + addUtilities(mkColorUtil(["outline-l"], "outlineLeft", border)); }, // add configuration which is merged with the final config () => ({