Migrate dropdown-item, list-comments, subscribe-by-rss, and settings from BEM to CSS Modules (#2013)

Consolidate legacy BEM CSS files into CSS Modules for 4 components:
- dropdown/__item: 1 CSS file → dropdown-item.module.css
- list-comments: 1 CSS file → list-comments.module.css (removed unused
  comments-list class that had no CSS rules)
- comment-form/__subscribe-by-rss: 1 CSS file → subscribe-by-rss.module.css,
  removed dead titleClass prop and dead __rss-link directory
- settings: 10 CSS files → settings.module.css, removed dead
  .settings__blocked-users-username CSS rule

Built artefact comparison (master vs branch):
- 83 of 89 files in /srv/web/ are byte-identical (all locale bundles,
  SVGs, HTML pages unchanged)
- 6 files differ: remark.css/js/mjs and last-comments.css/js/mjs
- CSS changes are class name hash shifts (e.g. G_A → H_A) caused by
  webpack's module ordering, plus 3 new var() fallback values added
  by the CSS modules build; all property:value pairs are preserved
- JS changes are minified variable name shifts (O ↔ A, I ↔ L) from
  changed import order; no logic changes
- Visual comparison (pixel-by-pixel screenshots of both light and dark
  themes on the demo page) shows 0 different pixels
- Bundle sizes: remark.css -626 bytes, remark.js -512 bytes,
  last-comments.css -16 bytes (dead CSS removed)
This commit is contained in:
Dmitry Verkhoturov
2026-03-25 16:42:44 -05:00
committed by GitHub
parent c26f45e55e
commit b888a53759
24 changed files with 124 additions and 159 deletions
@@ -1,15 +0,0 @@
.comment-form__rss-dropdown {
text-align: left;
}
.comment-form__rss-dropdown__link {
font-weight: 700;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
color: var(--color9);
&:hover {
color: var(--color33);
}
}
@@ -2,6 +2,8 @@ import { shallow } from 'enzyme';
import { SubscribeByRSS, createSubscribeUrl } from '.';
import styles from './subscribe-by-rss.module.css';
jest.mock('react-redux', () => ({
useSelector: jest.fn((fn) => fn({ theme: 'light' })),
}));
@@ -21,14 +23,12 @@ describe('<SubscribeByRSS/>', () => {
it('should be render links in dropdown', () => {
const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
expect(wrapper.find('.comment-form__rss-dropdown__link')).toHaveLength(3);
expect(wrapper.find(`.${styles.link}`)).toHaveLength(3);
});
it('should have userId in replies link', () => {
const wrapper = shallow(<SubscribeByRSS userId="user-1" />);
expect(wrapper.find('.comment-form__rss-dropdown__link').at(2).prop('href')).toBe(
createSubscribeUrl('reply', '&user=user-1')
);
expect(wrapper.find(`.${styles.link}`).at(2).prop('href')).toBe(createSubscribeUrl('reply', '&user=user-1'));
});
});
@@ -7,6 +7,8 @@ import { siteId, url } from 'common/settings';
import { BASE_URL, API_BASE } from 'common/constants';
import { Dropdown, DropdownItem } from 'components/dropdown';
import styles from './subscribe-by-rss.module.css';
export const createSubscribeUrl = (type: 'post' | 'site' | 'reply', urlParams = '') =>
`${BASE_URL}${API_BASE}/rss/${type}?site=${siteId}${urlParams}`;
@@ -48,14 +50,13 @@ export const SubscribeByRSS: FunctionComponent<{ userId: string | null }> = ({ u
return (
<Dropdown
title={intl.formatMessage(messages.title)}
titleClass="comment-form__rss-dropdown__title"
buttonTitle={intl.formatMessage(messages.buttonTitle)}
mix="comment-form__rss-dropdown"
mix={styles.rssDropdown}
theme={theme}
>
{items.map(([href, label]) => (
<DropdownItem key={label}>
<a href={href} className="comment-form__rss-dropdown__link" target="_blank" rel="noreferrer">
<a href={href} className={styles.link} target="_blank" rel="noreferrer">
{label}
</a>
</DropdownItem>
@@ -1,4 +1,8 @@
.comment-form__rss-link {
.rssDropdown {
text-align: left;
}
.link {
font-weight: 700;
white-space: nowrap;
text-decoration: none;
@@ -14,12 +14,10 @@ import './__field-wrapper/comment-form__field-wrapper.css';
import './__preview/comment-form__preview.css';
import './__preview-wrapper/comment-form__preview-wrapper.css';
import './__rss/comment-form__rss.css';
import './__rss-link/comment-form__rss-link.css';
import './__markdown/comment-form__markdown.css';
import './__markdown-link/comment-form__markdown-link.css';
import './__markdown-toolbar/comment-form__markdown-toolbar.css';
import './__subscribe-by-email/comment-form__subscribe-by-email.css';
import './__subscribe-by-rss/comment-form__subscribe-by-rss.css';
import './_theme/_dark/comment-form_theme_dark.css';
import './_theme/_light/comment-form_theme_light.css';
@@ -1,14 +1,14 @@
.dropdown__item {
.root {
& > button,
& > a,
& > .dropdown {
& > :global(.dropdown) {
display: inline-block;
text-align: left;
padding: 5px;
}
}
.dropdown__item_separator {
.separator {
border-bottom: 1px solid var(--color15);
margin-bottom: 5px;
}
@@ -1,10 +1,12 @@
import clsx from 'clsx';
import { h, JSX, FunctionComponent } from 'preact';
import b from 'bem-react-helper';
import styles from './dropdown-item.module.css';
export interface Props extends JSX.HTMLAttributes {
separator?: boolean;
}
export const DropdownItem: FunctionComponent<Props> = ({ children, separator = false }) => (
<div className={b('dropdown__item', {}, { separator })}>{children}</div>
<div className={clsx(styles.root, separator && styles.separator)}>{children}</div>
);
@@ -1,7 +1,6 @@
import './dropdown.css';
import './_active/dropdown_active.css';
import './__item/dropdown__item.css';
import './__items/dropdown__items.css';
import './__title/dropdown__title.css';
import './__content/dropdown__content.css';
@@ -1,3 +1 @@
import './__item/list-comments__item.css';
export * from './list-comments';
@@ -1,4 +1,4 @@
.list-comments__item {
.item {
&:last-child {
margin-bottom: 0;
}
@@ -4,6 +4,8 @@ import { useIntl } from 'react-intl';
import type { Comment as CommentType } from 'common/types';
import { Comment } from 'components/comment';
import styles from './list-comments.module.css';
type Props = {
comments: CommentType[];
};
@@ -12,7 +14,7 @@ export function ListComments({ comments = [] }: Props) {
const intl = useIntl();
return (
<div className="comments-list">
<div>
{comments.map((comment) => (
<Comment
intl={intl}
@@ -20,7 +22,7 @@ export function ListComments({ comments = [] }: Props) {
data={comment}
level={0}
view="preview"
mix="list-comments__item"
mix={styles.item}
user={null}
theme="light"
isCommentsDisabled={false}
@@ -1,15 +0,0 @@
.settings__action {
margin-left: 8px;
font-weight: 700;
color: var(--color9);
cursor: pointer;
&:hover {
color: var(--color33);
}
&::before {
content: '•';
margin-right: 8px;
}
}
@@ -1,3 +0,0 @@
.settings__dimmed {
opacity: 0.5;
}
@@ -1,3 +0,0 @@
.settings__invisible {
opacity: 0.4;
}
@@ -1,21 +0,0 @@
.settings__list {
padding: 0 0 0 20px;
}
.settings__list-item {
cursor: default;
position: relative;
margin-bottom: 0.5em;
@media (hover: hover) {
& .settings__action {
opacity: 0;
}
&:hover {
& .settings__action {
opacity: 1;
}
}
}
}
@@ -1,3 +0,0 @@
.settings__section + .settings__section {
margin-top: 2em;
}
@@ -1,5 +0,0 @@
.settings__user-id {
font-style: italic;
font-size: 0.8em;
word-break: break-all;
}
@@ -1,3 +0,0 @@
.settings__username {
font-weight: 700;
}
@@ -1,11 +0,0 @@
.settings_theme_dark {
& .settings__action {
&::before {
color: var(--color20);
}
}
& .settings__blocked-users-username {
color: var(--color5);
}
}
@@ -1,11 +0,0 @@
.settings_theme_light {
& .settings__action {
&::before {
color: var(--color10);
}
}
& .settings__blocked-users-username {
color: var(--color13);
}
}
@@ -1,13 +1 @@
import './settings.css';
import './__action/settings__action.css';
import './__section/settings__section.css';
import './__list/settings__list.css';
import './__invisible/settings__invisible.css';
import './__dimmed/settings__dimmed.css';
import './__username/settings__username.css';
import './__user-id/settings__user-id.css';
import './_theme/_dark/settings_theme_dark.css';
import './_theme/_light/settings_theme_light.css';
export * from './settings';
@@ -1,3 +0,0 @@
.settings {
padding: 10px 0;
}
@@ -0,0 +1,79 @@
.root {
padding: 10px 0;
}
.section + .section {
margin-top: 2em;
}
.action {
margin-left: 8px;
font-weight: 700;
color: var(--color9);
cursor: pointer;
&:hover {
color: var(--color33);
}
&::before {
content: '•';
margin-right: 8px;
}
}
.list {
padding: 0 0 0 20px;
}
.listItem {
cursor: default;
position: relative;
margin-bottom: 0.5em;
@media (hover: hover) {
& .action {
opacity: 0;
}
&:hover {
& .action {
opacity: 1;
}
}
}
}
.username {
font-weight: 700;
}
.userId {
font-style: italic;
font-size: 0.8em;
word-break: break-all;
}
.invisible {
opacity: 0.4;
}
.dimmed {
opacity: 0.5;
}
.themeDark {
& .action {
&::before {
color: var(--color20);
}
}
}
.themeLight {
& .action {
&::before {
color: var(--color10);
}
}
}
@@ -1,11 +1,12 @@
import { h, Component } from 'preact';
import b from 'bem-react-helper';
import clsx from 'clsx';
import { User, BlockedUser, Theme, BlockTTL } from 'common/types';
import { getHandleClickProps } from 'common/accessibility';
import { StoreState } from 'store';
import { defineMessages, IntlShape, FormattedMessage, useIntl } from 'react-intl';
import { useTheme } from 'hooks/useTheme';
import styles from './settings.module.css';
interface Props {
theme: Theme;
@@ -98,44 +99,37 @@ class SettingsComponent extends Component<Props, State> {
const hiddenUsersList = Object.values(this.state.hiddenUsers);
const intl = this.props.intl;
return (
<div className={b('settings', {}, { theme })}>
<div
className="settings__section settings__hidden-users"
role="region"
aria-label={intl.formatMessage(messages.hiddenUsers)}
>
<div className={clsx(styles.root, theme === 'dark' ? styles.themeDark : styles.themeLight)}>
<div className={styles.section} role="region" aria-label={intl.formatMessage(messages.hiddenUsers)}>
<h3>
<FormattedMessage id="settings.hidden-user-header" defaultMessage="Hidden users:" />
</h3>
{!hiddenUsersList.length && (
<h4 className="settings__dimmed">
<h4 className={styles.dimmed}>
<FormattedMessage id="settings.no-hidden-users" defaultMessage="There are no hidden users." />
</h4>
)}
{!!hiddenUsersList.length && (
<ul className="settings__list">
<ul className={styles.list}>
{hiddenUsersList.map((user) => {
const isUserUnhidden = unhiddenUsers.includes(user.id);
return (
<li key={user.id} className="settings__list-item">
<span
className={['settings__username', isUserUnhidden ? 'settings__invisible' : null].join(' ')}
title={user.id}
>
<li key={user.id} className={styles.listItem}>
<span className={clsx(styles.username, isUserUnhidden && styles.invisible)} title={user.id}>
{user.name ? user.name : <FormattedMessage id="settings.unknown" defaultMessage="unknown" />}
</span>
{this.__isUserHidden(user) ? (
<span className="settings__action" {...getHandleClickProps(() => this.unhide(user))}>
<span className={styles.action} {...getHandleClickProps(() => this.unhide(user))}>
<FormattedMessage id="settings.show" defaultMessage="show" />
</span>
) : (
<span className="settings__action" {...getHandleClickProps(() => this.hide(user))}>
<span className={styles.action} {...getHandleClickProps(() => this.hide(user))}>
<FormattedMessage id="settings.hide" defaultMessage="hide" />
</span>
)}
<div>
<span className="settings__user-id">
<span className={styles.userId}>
id: <span>{user.id}</span>
</span>
</div>
@@ -146,50 +140,43 @@ class SettingsComponent extends Component<Props, State> {
)}
</div>
{user && user.admin && (
<div
className="settings__section settings__blocked-users"
role="region"
aria-label={intl.formatMessage(messages.blockedUsers)}
>
<div className={styles.section} role="region" aria-label={intl.formatMessage(messages.blockedUsers)}>
<h3>
<FormattedMessage id="settings.blocked-users-header" defaultMessage="Blocked users:" />
</h3>
{!blockedUsers.length && (
<h4 className="settings__dimmed">
<h4 className={styles.dimmed}>
<FormattedMessage id="settings.no-blocked-users" defaultMessage="There are no blocked users." />
</h4>
)}
{!!blockedUsers.length && (
<ul className="settings__list settings__blocked-users-list">
<ul className={styles.list}>
{blockedUsers.map((user) => {
const isUserUnblocked = unblockedUsers.includes(user.id);
return (
<li key={user.id} className="settings__list-item">
<span
className={['settings__username', isUserUnblocked ? 'settings__invisible' : null].join(' ')}
title={user.id}
>
<li key={user.id} className={styles.listItem}>
<span className={clsx(styles.username, isUserUnblocked && styles.invisible)} title={user.id}>
{user.name ? user.name : <FormattedMessage id="settings.unknown" defaultMessage="unknown" />}
</span>
<span className="settings__blocked-users-user-block-ttl">
<span>
{' '}
<FormatTime time={new Date(user.time)} />
</span>
{isUserUnblocked && (
<span {...getHandleClickProps(() => this.block(user))} className="settings__action">
<span {...getHandleClickProps(() => this.block(user))} className={styles.action}>
<FormattedMessage id="settings.block" defaultMessage="block" />
</span>
)}
{!isUserUnblocked && (
<span {...getHandleClickProps(() => this.unblock(user))} className="settings__action">
<span {...getHandleClickProps(() => this.unblock(user))} className={styles.action}>
<FormattedMessage id="settings.unblock" defaultMessage="unblock" />
</span>
)}
<div>
<span className="settings__user-id">
<span className={styles.userId}>
id: <span>{user.id}</span>
</span>
</div>