diff --git a/Gemfile b/Gemfile index f5da754b1a..504facf2e9 100644 --- a/Gemfile +++ b/Gemfile @@ -187,7 +187,7 @@ group :development do gem 'letter_opener_web', '~> 3.0' # Security analysis CLI tools - gem 'brakeman', '~> 7.0', require: false + gem 'brakeman', '~> 8.0', require: false gem 'bundler-audit', '~> 0.9', require: false # Linter CLI for HAML files diff --git a/Gemfile.lock b/Gemfile.lock index ee26939a26..8511092ff9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,13 +90,13 @@ GEM public_suffix (>= 2.0.2, < 8.0) aes_key_wrap (1.1.0) android_key_attestation (0.3.0) - annotaterb (4.20.0) + annotaterb (4.21.0) activerecord (>= 6.0.0) activesupport (>= 6.0.0) ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1206.0) + aws-partitions (1.1210.0) aws-sdk-core (3.241.4) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -108,7 +108,7 @@ GEM aws-sdk-kms (1.121.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.212.0) + aws-sdk-s3 (1.213.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -131,7 +131,7 @@ GEM blurhash (0.1.8) bootsnap (1.20.1) msgpack (~> 1.2) - brakeman (7.1.2) + brakeman (8.0.1) racc browser (6.2.0) builder (3.3.0) @@ -613,7 +613,7 @@ GEM net-smtp premailer (~> 1.7, >= 1.7.9) prettyprint (0.2.0) - prism (1.8.0) + prism (1.9.0) prometheus_exporter (2.3.1) webrick propshaft (1.3.1) @@ -950,7 +950,7 @@ DEPENDENCIES binding_of_caller (~> 1.0) blurhash (~> 0.1) bootsnap - brakeman (~> 7.0) + brakeman (~> 8.0) browser bundler-audit (~> 0.9) capybara (~> 3.39) diff --git a/app/javascript/mastodon/components/badge.stories.tsx b/app/javascript/mastodon/components/badge.stories.tsx new file mode 100644 index 0000000000..aaddcaa91a --- /dev/null +++ b/app/javascript/mastodon/components/badge.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import CelebrationIcon from '@/material-icons/400-24px/celebration-fill.svg?react'; + +import * as badges from './badge'; + +const meta = { + component: badges.Badge, + title: 'Components/Badge', + args: { + label: 'Example', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Domain: Story = { + args: { + domain: 'example.com', + }, +}; + +export const CustomIcon: Story = { + args: { + icon: , + }, +}; + +export const Admin: Story = { + args: { + roleId: '1', + }, + render(args) { + return ; + }, +}; + +export const Group: Story = { + render(args) { + return ; + }, +}; + +export const Automated: Story = { + render(args) { + return ; + }, +}; + +export const Muted: Story = { + render(args) { + return ; + }, +}; + +export const Blocked: Story = { + render(args) { + return ; + }, +}; diff --git a/app/javascript/mastodon/components/badge.tsx b/app/javascript/mastodon/components/badge.tsx index b7dc169edb..0ffb7baa8a 100644 --- a/app/javascript/mastodon/components/badge.tsx +++ b/app/javascript/mastodon/components/badge.tsx @@ -4,17 +4,28 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import AdminIcon from '@/images/icons/icon_admin.svg?react'; +import BlockIcon from '@/material-icons/400-24px/block.svg?react'; import GroupsIcon from '@/material-icons/400-24px/group.svg?react'; import PersonIcon from '@/material-icons/400-24px/person.svg?react'; import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react'; +import VolumeOffIcon from '@/material-icons/400-24px/volume_off.svg?react'; -export const Badge: FC<{ +interface BadgeProps { label: ReactNode; icon?: ReactNode; className?: string; domain?: ReactNode; roleId?: string; -}> = ({ icon = , label, className, domain, roleId }) => ( +} + +export const Badge: FC = ({ + icon = , + label, + className, + domain, + roleId, +}) => (
); -export const GroupBadge: FC<{ className?: string }> = ({ className }) => ( +export const AdminBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const GroupBadge: FC> = (props) => ( } label={ } - className={className} + {...props} /> ); @@ -44,3 +65,23 @@ export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => ( className={className} /> ); + +export const MutedBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); + +export const BlockedBadge: FC> = (props) => ( + } + label={ + + } + {...props} + /> +); diff --git a/app/javascript/mastodon/components/form_fields/checkbox.module.scss b/app/javascript/mastodon/components/form_fields/checkbox.module.scss index 36f029a17b..8f4ab99a5c 100644 --- a/app/javascript/mastodon/components/form_fields/checkbox.module.scss +++ b/app/javascript/mastodon/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/mastodon/components/form_fields/checkbox_field.stories.tsx b/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx index 3f73143ba6..4d208cf21b 100644 --- a/app/javascript/mastodon/components/form_fields/checkbox_field.stories.tsx +++ b/app/javascript/mastodon/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/mastodon/components/form_fields/fieldset.module.scss b/app/javascript/mastodon/components/form_fields/fieldset.module.scss new file mode 100644 index 0000000000..f222762af5 --- /dev/null +++ b/app/javascript/mastodon/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/mastodon/components/form_fields/fieldset.tsx b/app/javascript/mastodon/components/form_fields/fieldset.tsx new file mode 100644 index 0000000000..d52a95130b --- /dev/null +++ b/app/javascript/mastodon/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/mastodon/components/form_fields/form_field_wrapper.module.scss b/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss index 3625b107c7..faeb48aae4 100644 --- a/app/javascript/mastodon/components/form_fields/form_field_wrapper.module.scss +++ b/app/javascript/mastodon/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/mastodon/components/form_fields/form_field_wrapper.tsx b/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx index 18a877e4bd..ec7c2e584b 100644 --- a/app/javascript/mastodon/components/form_fields/form_field_wrapper.tsx +++ b/app/javascript/mastodon/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}
-