diff --git a/app/javascript/flavours/glitch/components/form_fields/checkbox.module.scss b/app/javascript/flavours/glitch/components/form_fields/checkbox.module.scss index 36f029a17b..8f4ab99a5c 100644 --- a/app/javascript/flavours/glitch/components/form_fields/checkbox.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/checkbox.module.scss @@ -1,5 +1,6 @@ .checkbox { --size: 16px; + --border-width: 1px; appearance: none; box-sizing: border-box; @@ -10,18 +11,26 @@ height: var(--size); vertical-align: top; border-radius: calc(var(--size) / 4); - border: 1px solid var(--color-border-primary); + border: var(--border-width) solid var(--color-border-primary); background-color: var(--color-bg-primary); transition: 0.15s ease-out; transition-property: background-color, border-color; cursor: pointer; - @supports not (appearance: none) { - accent-color: var(--color-bg-brand-base); + /* Increase clickable area, prevents misclicks and covers gap between control and label */ + &::after { + content: ''; + position: absolute; + + --spread: calc(var(--border-width) + var(--form-field-label-gap, 8px)); + + inset-inline: calc(-1 * var(--spread)); + inset-block: calc(-0.75 * var(--spread)); } &:disabled { - background: var(--color-bg-secondary); + background: var(--color-bg-tertiary); + border: none; cursor: not-allowed; } @@ -53,9 +62,12 @@ border-color: var(--color-bg-brand-base); &:disabled { - border-color: var(--color-bg-disabled); - background: var(--color-bg-disabled); - color: var(--color-text-on-disabled); + border: none; + background-color: var(--color-text-disabled); + + &::before { + background-color: var(--color-bg-tertiary); + } } &::before { diff --git a/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx index 3f73143ba6..4d208cf21b 100644 --- a/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Checkbox, CheckboxField } from './checkbox_field'; +import { Fieldset } from './fieldset'; const meta = { title: 'Components/Form Fields/CheckboxField', @@ -29,6 +30,37 @@ export const WithoutHint: Story = { }, }; +export const InFieldset: Story = { + render() { + return ( +
+ + + +
+ ); + }, +}; + +export const InFieldsetHorizontal: Story = { + render() { + return ( +
+ + + +
+ ); + }, +}; + export const Required: Story = { args: { required: true, @@ -82,6 +114,6 @@ export const Small: Story = { export const Large: Story = { args: { - size: 64, + size: 36, }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss b/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss new file mode 100644 index 0000000000..f222762af5 --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss @@ -0,0 +1,19 @@ +.fieldset { + display: flex; + flex-direction: column; + gap: 12px; + color: var(--color-text-primary); + font-size: 15px; +} + +.fieldsWrapper { + display: flex; + flex-direction: column; + row-gap: 8px; + + &[data-layout='horizontal'] { + flex-direction: row; + flex-wrap: wrap; + column-gap: 24px; + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx new file mode 100644 index 0000000000..d52a95130b --- /dev/null +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ + +import type { ReactNode, FC } from 'react'; +import { createContext, useId } from 'react'; + +import classes from './fieldset.module.scss'; +import formFieldWrapperClasses from './form_field_wrapper.module.scss'; + +interface FieldsetProps { + legend: ReactNode; + hint?: ReactNode; + name?: string; + hasError?: boolean; + layout?: 'vertical' | 'horizontal'; + children: ReactNode; +} + +export const FieldsetNameContext = createContext(undefined); + +/** + * A fieldset suitable for wrapping a group of checkboxes, + * radio buttons, or other grouped form controls. + */ + +export const Fieldset: FC = ({ + legend, + hint, + name, + hasError, + layout, + children, +}) => { + const uniqueId = useId(); + const labelId = `${uniqueId}-label`; + const hintId = `${uniqueId}-hint`; + const fieldsetName = name || `${uniqueId}-fieldset-name`; + const hasHint = !!hint; + + return ( +
+
+
+ {legend} +
+ {hasHint && ( +

+ {hint} +

+ )} +
+ +
+ + {children} + +
+
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss index 3625b107c7..faeb48aae4 100644 --- a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss @@ -1,13 +1,16 @@ .wrapper { + --form-field-label-gap: 6px; + display: flex; flex-direction: column; - gap: 6px; + gap: var(--form-field-label-gap); color: var(--color-text-primary); font-size: 15px; &[data-input-placement^='inline'] { flex-direction: row; - gap: 8px; + + --form-field-label-gap: 8px; } &[data-input-placement='inline-start'] { @@ -29,6 +32,10 @@ .label { font-weight: 500; + &[data-has-parent-fieldset='true'] { + font-weight: normal; + } + [data-has-error='true'] & { color: var(--color-text-error); } diff --git a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx index 18a877e4bd..ec7c2e584b 100644 --- a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import type { ReactNode, FC } from 'react'; -import { useId } from 'react'; +import { useContext, useId } from 'react'; import { FormattedMessage } from 'react-intl'; +import { FieldsetNameContext } from './fieldset'; import classes from './form_field_wrapper.module.scss'; interface InputProps { @@ -51,6 +52,8 @@ export const FormFieldWrapper: FC = ({ const hintId = `${inputIdProp || uniqueId}-hint`; const hasHint = !!hint; + const hasParentFieldset = !!useContext(FieldsetNameContext); + const inputProps: InputProps = { required, id: inputId, @@ -72,7 +75,11 @@ export const FormFieldWrapper: FC = ({ {inputPlacement === 'inline-start' && input}
-