Migrate batch 1 components from BEM to CSS Modules (#2014)

* feat: migrate batch 1 components from BEM to CSS Modules

Migrate 8 components from BEM to CSS Modules:
- button (7 BEM files -> 1 module)
- dropdown (7 BEM files -> 1 module)
- thread (3 BEM files -> 1 module)
- auth-panel (2 BEM files -> 1 module)
- dropdown-item, list-comments, subscribe-by-rss, settings (from batch 0 PR #2013)

Consolidates 19 BEM CSS files into 8 CSS Module files. Uses clsx for
conditional class composition, replacing bem-react-helper's b() calls.
Class naming follows the established convention: BEM block = .root,
elements = camelCase, modifiers = camelCase.

Visual regression verification on built artefacts:
- remark.css: 43,779 -> 43,299 bytes (480 bytes smaller)
- last-comments.css: 18,792 -> 18,776 bytes (16 bytes smaller)
- remark.js: 256,709 -> 304,837 bytes (48KB larger, expected: CSS Module
  classname mappings now live in JS instead of plain strings)
- Dark theme: pixel-identical (zero difference)
- Light theme: pixel-identical (0.21% diff is the native demo page
  "Toggle theme" button, not any remark42 widget element)

Also updates CLAUDE.md CSS guideline to reflect the migration status.

* Migrate remaining BEM components to CSS Modules (final batch)

Migrate the last 4 BEM components to CSS Modules, completing the
migration and removing bem-react-helper from the project entirely.

Components migrated:
- subscribe-by-email (1 BEM CSS file -> 1 module)
- comment-form + markdown-toolbar (20 BEM CSS files -> 2 modules)
- comment (19 BEM CSS files -> expanded existing module)
- root (10 BEM CSS files -> expanded existing module)

Consolidates ~50 BEM CSS files into 4 new + 2 expanded CSS Module files.
Removes bem-react-helper dependency — all components now use clsx for
conditional class composition.

Dead CSS cleanup during migration:
- Orphaned comment-actions selectors in comment theme CSS (already migrated)
- Dead BEM modifiers: comment_disabled, comment_pinned, comment_guest
- Dead element: comment__user-id (CSS existed but never used in TSX)
- Dead button type classes: comment-form__button_type_preview/_send
- Dead mix values: auth-email-login-form__back-button, comment-form__email-dropdown

Key implementation details:
- comment_highlighting stays global via :global() (imperatively added by classList)
- Bare .dark/.light theme class preserved on root wrapper (8+ modules depend on it)
- raw-content.css kept as global utility CSS (syntax highlighting)

Visual regression verification on built artefacts:
- remark.css: 43,779 -> 36,106 bytes (-17.5%)
- last-comments.css: 18,792 -> 13,955 bytes (-25.7%)
- remark.js: 256,709 -> 253,637 bytes (-1.2%)
- last-comments.js: 121,726 -> 120,795 bytes (-0.8%)
- Total: 441,006 -> 424,493 bytes (-3.7%)
- Screenshot comparison: pixel-identical across light/dark themes
This commit is contained in:
Dmitry Verkhoturov
2026-03-25 16:53:32 -05:00
committed by GitHub
parent b888a53759
commit 06436ff9b0
105 changed files with 1520 additions and 1289 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
- **Backend**: Formatting with golangci-lint, strict error handling
- **Frontend**: TypeScript with ESLint, Stylelint and Prettier
- **Imports**: Group stdlib, external packages, then internal packages
- **CSS**: CSS Modules for new components (`component.module.css`)
- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx
## Key Backend Packages
- **Web/API**: `github.com/go-chi/chi/v5`, `github.com/go-pkgz/rest`
@@ -0,0 +1,443 @@
# BEM → CSS Modules migration, batch 1: leaf components
## Overview
- Migrate 4 leaf BEM components to CSS Modules: **button**, **dropdown**, **thread**, **auth-panel**
- Consolidates 19 BEM CSS files into 4 CSS module files
- Cleans up dead CSS classes and unused props discovered during analysis
- Follows the pattern established in PR #2013 (batch 0: dropdown-item, list-comments, subscribe-by-rss, settings)
## Context
- PR #2013 migrated 4 small components with zero visual regression (verified pixel-by-pixel on built artefacts)
- Key learning from batch 0: `mix` is pure string concatenation — parent and child can be migrated independently in any order
- These 4 "leaf" components are migrated first because they have no inward `mix` coupling with unmigrated parents (or the coupling is dead)
- Batch 2 (comment-form, subscribe-by-email, comment, root) follows after this lands
## Development Approach
- **Testing approach**: Regular (run existing tests after each change; no new tests needed as these are CSS-only changes with no logic)
- Complete each task fully before moving to the next
- Make small, focused changes
- Run `cd frontend && pnpm lint` and `cd frontend && pnpm test` after each task
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
## Implementation Steps
### Task 1: Migrate `button` to CSS Modules
7 BEM CSS files → 1 `button.module.css`. Uses lookup objects for dynamic kind/size/theme props.
**Files to modify:**
- `frontend/apps/remark42/app/components/button/button.tsx`
- `frontend/apps/remark42/app/components/button/index.ts`
**Files to create:**
- `frontend/apps/remark42/app/components/button/button.module.css`
**Files to delete (7):**
- `button/button.css`
- `button/_kind/_link/button_kind_link.css` + parent dirs
- `button/_kind/_primary/button_kind_primary.css` + parent dirs
- `button/_kind/_secondary/button_kind_secondary.css` + parent dirs
- `button/_size/_large/button_size_large.css` + parent dirs
- `button/_size/_middle/button_size_middle.css` + parent dirs
- `button/_theme/_dark/button_theme_dark.css` + parent dirs
**Steps:**
- [x] Create `button.module.css` consolidating all 7 CSS files:
```css
.root {
background: none;
border: 0;
padding: 0;
margin: 0;
border-radius: 4px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px var(--color47);
outline: none;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.kindLink {
background: transparent;
font-weight: 600;
color: var(--color9);
&:hover { color: var(--color33); }
&:disabled, &:hover:disabled { color: var(--color9); }
}
.kindPrimary {
background: var(--color15);
color: var(--color6);
&:hover { background: var(--color33); }
&:hover:disabled { background: var(--color15); }
}
.kindSecondary {
background: var(--color6);
color: inherit;
&:hover { box-shadow: inset 0 0 0 2px var(--color33); }
}
.sizeMiddle {
height: 2rem;
padding: 0 12px;
}
.sizeLarge {
height: 36px;
padding: 0 12px;
font-size: 16px;
}
.themeDark {
&.kindSecondary {
background: var(--color8);
color: var(--color20);
}
&.kindLink {
&:disabled, &:hover:disabled { color: var(--color6); }
}
}
```
- [x] Update `button.tsx`:
- Remove `import b, { Mods, Mix } from 'bem-react-helper'`
- Add `import styles from './button.module.css'`
- Add lookup objects:
```tsx
const kindStyles: Record<string, string> = {
primary: styles.kindPrimary,
secondary: styles.kindSecondary,
link: styles.kindLink,
};
const sizeStyles: Record<string, string> = {
middle: styles.sizeMiddle,
large: styles.sizeLarge,
};
```
- Remove `mods` from `ButtonProps` type and destructuring (never passed by any caller)
- Change `Mix` type import to plain `string | string[]` for `mix` prop
- Change className to: `clsx(styles.root, kind && kindStyles[kind], size && sizeStyles[size], theme === 'dark' && styles.themeDark, mix, className)`
- [x] Update `index.ts`: remove all 7 CSS imports, keep only `export { Button } from './button'`
- [x] Delete all 7 old CSS files and their BEM directories
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task
### Task 2: Migrate `dropdown` to CSS Modules
7 BEM CSS files → 1 `dropdown.module.css`. Replaces 3 DOM class queries with refs and `data-dropdown` attribute. Removes dead `heading` prop (no callers) and dead `dropdown__heading` class (no CSS). Updates already-migrated `dropdown-item.module.css`.
**Files to modify:**
- `frontend/apps/remark42/app/components/dropdown/dropdown.tsx`
- `frontend/apps/remark42/app/components/dropdown/index.ts`
- `frontend/apps/remark42/app/components/dropdown/__item/dropdown-item.module.css` (change `:global(.dropdown)` → `[data-dropdown]`)
**Files to create:**
- `frontend/apps/remark42/app/components/dropdown/dropdown.module.css`
**Files to delete (7):**
- `dropdown/dropdown.css`
- `dropdown/__content/dropdown__content.css` + dir
- `dropdown/__items/dropdown__items.css` + dir
- `dropdown/__title/dropdown__title.css` + dir
- `dropdown/_active/dropdown_active.css` + dir
- `dropdown/_theme/_dark/dropdown_theme_dark.css` + dirs
- `dropdown/_theme/_light/dropdown_theme_light.css` + dirs
**Steps:**
- [x] Create `dropdown.module.css` consolidating all 7 CSS files:
```css
.root {
display: inline-block;
position: relative;
}
.content {
position: absolute;
z-index: 20;
outline-width: 0;
display: none;
top: 100%;
left: 0;
transform: translate(-0.5em, 5px);
min-width: 120px;
max-width: 260px;
border: 2px solid var(--color15);
border-radius: 3px;
padding: 0 0 5px;
}
.items {
padding: 5px 0;
&:last-child { padding-bottom: 0; }
}
.title {
&::after {
content: '\25BE';
margin-left: 2px;
}
}
.active > .content {
display: block;
}
.themeDark > .content {
background-color: var(--color8);
}
.themeLight > .content {
background-color: var(--color6);
}
```
- [x] Update `dropdown.tsx`:
- Replace `import b from 'bem-react-helper'` with `import clsx from 'clsx'` and `import styles from './dropdown.module.css'`
- Add `contentRef = createRef<HTMLDivElement>()` alongside existing `rootNode` ref
- Add `data-dropdown` attribute to root div (for nested dropdown detection by parent traversal)
- Replace 3 DOM queries with ref:
- Line 78: `parent.classList.contains('dropdown')` → `parent.hasAttribute('data-dropdown')`
- Line 95: `Array.from(...).find(c => c.classList.contains('dropdown__content'))` → `this.contentRef.current`
- Line 118: `this.rootNode.current.querySelector('.dropdown__content')` → `this.contentRef.current`
- Root div: `className={clsx(styles.root, isActive && styles.active, theme === 'dark' ? styles.themeDark : styles.themeLight, mix)}`
- Content div: `className={styles.content}` with `ref={this.contentRef}`
- Items div: `className={styles.items}`
- Button mix: `mix={[styles.title, titleClass]}` (clsx in Button handles arrays)
- Remove dead `heading` prop from Props type and the heading div from JSX (prop is never passed by any caller, class has no CSS)
- [x] Update `dropdown-item.module.css`: change `& > :global(.dropdown)` to `& > [data-dropdown]`
- [x] Update `index.ts`: remove all 7 CSS imports, keep `export { Dropdown }` and `export { DropdownItem }`
- [x] Delete all 7 old CSS files and their BEM directories
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task
### Task 3: Migrate `thread` to CSS Modules
3 BEM CSS files → 1 `thread.module.css`. Levels 0-5 had no CSS rules — only level 6 matters. The `mix` prop (receives `"root__thread"` from root component) is passed through as a plain class string.
**Files to modify:**
- `frontend/apps/remark42/app/components/thread/thread.tsx`
- `frontend/apps/remark42/app/components/thread/index.ts`
**Files to create:**
- `frontend/apps/remark42/app/components/thread/thread.module.css`
**Files to delete (3):**
- `thread/thread.css`
- `thread/__collapse/thread__collapse.css` + dir
- `thread/_theme_dark/thread_theme_dark.css` + dir
**Steps:**
- [x] Create `thread.module.css` consolidating all 3 CSS files:
```css
.root {
position: relative;
}
.indented {
margin-left: 17px;
}
.level6 .level6 {
margin-left: 0;
}
.collapse {
height: calc(100% - 50px);
width: 11px;
position: absolute;
top: 50px;
left: -4px;
cursor: pointer;
&::after {
display: block;
content: '';
position: absolute;
left: 5px;
top: 0;
border-left: 1px dotted var(--color35);
height: 100%;
}
&:hover::after {
transform: translateX(-1px);
border-left: 3px solid var(--color10);
z-index: 10;
}
}
.collapsed {
composes: collapse;
width: 18px;
height: 18px;
top: 12px;
left: 0;
display: flex;
text-align: center;
opacity: 0.8;
border-radius: 2px;
border: 1px solid;
&::after { display: none; }
&:hover { opacity: 1; }
&:hover::after { transform: translateX(0); }
& > div {
position: relative;
top: 6px;
left: 3px;
width: 12px;
height: 2px;
border-bottom: 2px solid;
&::before, &::after {
content: '';
width: 100%;
height: 2px;
border-bottom: 2px solid;
position: absolute;
top: -4px;
left: 0;
}
&::after { top: 4px !important; }
}
}
.themeDark {
& .collapse {
&::after { border-color: var(--color36); }
&:hover::after { border-color: var(--color6); }
}
}
```
- [x] Update `thread.tsx`:
- Replace `import b from 'bem-react-helper'` with `import clsx from 'clsx'` and `import styles from './thread.module.css'`
- Root div: `className={clsx(styles.root, indented && styles.indented, level === 6 && styles.level6, theme === 'dark' && styles.themeDark, mix)}`
- Collapse div: `className={collapsed ? styles.collapsed : styles.collapse}`
- [x] Update `index.ts`: remove all 3 CSS imports, keep only `export { Thread } from './thread'`
- [x] Delete all 3 old CSS files and their BEM directories
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task
### Task 4: Migrate `auth-panel` BEM remnants to CSS Modules
2 BEM CSS files → merge into existing `auth-panel.module.css`. Removes dead global class strings alongside module classes. Has test file to update.
**Dead code to clean up:**
- `auth-panel__pseudo-link` — no CSS rules, remove from JSX
- `auth-panel_loggedIn` / `auth-panel_theme_*` — dead mods from `b()`, no CSS rules
- `clsx('user', styles.user)` etc. — bare global strings (`'user'`, `'user-profile-button'`, `'user-avatar'`, `'user-logout-button'`) have no CSS; remove from `clsx()`
**Files to modify:**
- `frontend/apps/remark42/app/components/auth-panel/auth-panel.tsx`
- `frontend/apps/remark42/app/components/auth-panel/auth-panel.module.css`
- `frontend/apps/remark42/app/components/auth-panel/auth-panel.test.tsx`
- `frontend/apps/remark42/app/components/auth-panel/index.ts`
**Files to delete (2):**
- `auth-panel/auth-panel.css`
- `auth-panel/__column/auth-panel__column.css` + dir
**Steps:**
- [x] Merge BEM styles into existing `auth-panel.module.css` — add these classes after existing ones:
```css
.root {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 16px;
align-items: center;
}
.column:last-child {
margin-left: 8px;
text-align: right;
}
.columnSeparated > * + * {
position: relative;
display: inline-block;
margin-left: 20px;
&::before {
position: absolute;
left: -15px;
display: inline-block;
width: 10px;
text-align: center;
content: '•';
}
}
.adminAction { }
```
- [x] Update `auth-panel.tsx`:
- Remove `import b from 'bem-react-helper'`
- Root div: `className={styles.root}` (drop dead `theme`/`loggedIn` mods)
- Column divs: `className={styles.column}`
- Separated column: `className={clsx(styles.column, styles.columnSeparated)}`
- Remove `className="auth-panel__pseudo-link"` from `<a>` (dead class, no CSS)
- Clean up dual class patterns: `clsx('user', styles.user)` → `styles.user`, same for userButton/userAvatar/userLogoutButton
- Change `mix="auth-panel__admin-action"` → `className={styles.adminAction}` on both Button calls
- [x] Update `auth-panel.test.tsx`:
- Add `import styles from './auth-panel.module.css'`
- `.find('.auth-panel__admin-action')` → `` .find(`.${styles.adminAction}`) ``
- `.find('.auth-panel__column')` → `` .find(`.${styles.column}`) ``
- [x] Update `index.ts`: remove 2 CSS imports, keep `export * from './auth-panel'`
- [x] Delete `auth-panel.css` and `__column/` directory
- [x] Run `cd frontend && pnpm lint && pnpm test` — must pass before next task
### Task 5: Verify acceptance criteria
- [x] Verify all 4 components use CSS Modules (no remaining `b()` calls in migrated files)
- [x] Run full frontend test suite: `cd frontend && pnpm test`
- [x] Run frontend linter: `cd frontend && pnpm lint`
- [x] Grep for old BEM class names to verify no remaining references to deleted CSS files
- [x] Verify `bem-react-helper` is no longer imported in any of the 4 migrated components
## Technical Details
### Class naming convention
- BEM block → `root`
- BEM element → camelCase of element name (`dropdown__content` → `content`, `auth-panel__column` → `column`)
- BEM modifier → camelCase (`button_kind_primary` → `kindPrimary`, `thread_theme_dark` → `themeDark`)
- Combined modifier → compound `&.` nesting (`button_theme_dark.button_kind_secondary` → `.themeDark { &.kindSecondary { ... } }`)
### Button `mix` prop after migration
- Type changes from `Mix` (bem-react-helper) to `string | string[] | undefined`
- Passed directly to `clsx()` which handles all these types
- Callers in batch 2 (not yet migrated) continue passing BEM strings — works fine
- Already-migrated callers pass module hashes — works fine
### Dropdown DOM query replacements
| Before | After |
|---|---|
| `classList.contains('dropdown')` | `hasAttribute('data-dropdown')` |
| `querySelector('.dropdown__content')` | `this.contentRef.current` |
| `Array.from(...).find(c => c.classList.contains('dropdown__content'))` | `this.contentRef.current` |
### Files to delete (total: 19 CSS files + BEM directories)
- Button: 7 CSS files
- Dropdown: 7 CSS files
- Thread: 3 CSS files
- Auth-panel: 2 CSS files
## Post-Completion
- Visual smoke test: `cd frontend && pnpm dev:app`, verify button variants, dropdown open/close, thread collapse/expand, auth-panel admin actions in both light and dark themes
- Built artefact comparison: build docker image from branch, compare against master (same method as PR #2013)
- Batch 2 (comment-form, subscribe-by-email, comment, root) as follow-up
@@ -1,19 +0,0 @@
.auth-panel__column_separated > * + * {
position: relative;
display: inline-block;
margin-left: 20px;
&::before {
position: absolute;
left: -15px;
display: inline-block;
width: 10px;
text-align: center;
content: '•';
}
}
.auth-panel__column:last-child {
margin-left: 8px;
text-align: right;
}
@@ -1,7 +0,0 @@
.auth-panel {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 16px;
align-items: center;
}
@@ -1,3 +1,35 @@
.root {
display: flex;
justify-content: space-between;
font-size: 14px;
line-height: 16px;
align-items: center;
}
.column:last-child {
margin-left: 8px;
text-align: right;
}
.columnSeparated > * + * {
position: relative;
display: inline-block;
margin-left: 20px;
&::before {
position: absolute;
left: -15px;
display: inline-block;
width: 10px;
text-align: center;
content: '•';
}
}
/* stylelint-disable-next-line block-no-empty -- selector-only class for test targeting */
.adminAction {
}
.user {
display: flex;
align-items: center;
@@ -8,6 +8,7 @@ import type { User } from 'common/types';
import enMessages from 'locales/en.json';
import { AuthPanel, Props } from './auth-panel';
import styles from './auth-panel.module.css';
const DefaultProps = {
postInfo: {
@@ -47,7 +48,7 @@ describe('<AuthPanel />', () => {
postInfo: { ...DefaultProps.postInfo, read_only: true },
} as Props);
const adminAction = element.find('.auth-panel__admin-action');
const adminAction = element.find(`.${styles.adminAction}`);
expect(adminAction.exists()).toBe(false);
});
@@ -60,7 +61,7 @@ describe('<AuthPanel />', () => {
hiddenUsers: { hidden_joe: {} as User },
} as Props);
const adminAction = element.find('.auth-panel__admin-action');
const adminAction = element.find(`.${styles.adminAction}`).first();
expect(adminAction.text()).toEqual('Show settings');
});
@@ -73,7 +74,7 @@ describe('<AuthPanel />', () => {
user: { id: 'john', name: 'John' },
} as Props);
const authPanelColumn = element.find('.auth-panel__column');
const authPanelColumn = element.find(`.${styles.column}`);
expect(authPanelColumn.length).toEqual(2);
@@ -89,7 +90,7 @@ describe('<AuthPanel />', () => {
user: { id: 'test', admin: true, name: 'John' },
} as Props);
const adminAction = element.find('.auth-panel__admin-action').first();
const adminAction = element.find(`.${styles.adminAction}`).first();
expect(adminAction.text()).toEqual('Show settings');
});
@@ -1,6 +1,5 @@
import { h, Component } from 'preact';
import { FormattedMessage, IntlShape, useIntl } from 'react-intl';
import b from 'bem-react-helper';
import clsx from 'clsx';
import { User, Theme, PostInfo } from 'common/types';
@@ -60,18 +59,18 @@ class AuthPanelComponent extends Component<Props, State> {
renderAuthorized = (user: User) => {
return (
<div className={clsx('user', styles.user)}>
<div className={styles.user}>
<button
className={clsx('user-profile-button', styles.userButton)}
className={styles.userButton}
onClick={() => postMessageToParent({ profile: { ...user, current: '1' } })}
title={this.props.intl.formatMessage(messages.openProfile)}
>
<div className={clsx('user-avatar', styles.userAvatar)}>
<div className={styles.userAvatar}>
<Avatar url={user.picture} />
</div>
{user.name}
</button>{' '}
<div className={clsx('user-logout-button', styles.userLogoutButton)}>
<div className={styles.userLogoutButton}>
<IconButton title={this.props.intl.formatMessage(messages.signout)} onClick={this.props.signout}>
<SignOutIcon size="14" />
</IconButton>
@@ -83,13 +82,12 @@ class AuthPanelComponent extends Component<Props, State> {
renderThirdPartyWarning = () => {
if (IS_STORAGE_AVAILABLE || !IS_THIRD_PARTY) return null;
return (
<div className="auth-panel__column">
<div className={styles.column}>
<FormattedMessage
id="authPanel.disabled-cookies"
defaultMessage="Disable third-party cookies blocking to login or open comments in"
/>{' '}
<a
className="auth-panel__pseudo-link"
href={`${window.location.origin}/web/comments.html${window.location.search}`}
target="_blank"
rel="noreferrer"
@@ -105,7 +103,7 @@ class AuthPanelComponent extends Component<Props, State> {
return null;
}
return (
<div className="auth-panel__column">
<div className={styles.column}>
<FormattedMessage id="authPanel.enable-cookies" defaultMessage="Allow cookies to login and comment" />
</div>
);
@@ -115,7 +113,7 @@ class AuthPanelComponent extends Component<Props, State> {
return (
<Button
kind="link"
mix="auth-panel__admin-action"
className={styles.adminAction}
{...getHandleClickProps(this.toggleBlockedVisibility)}
role="link"
>
@@ -133,7 +131,7 @@ class AuthPanelComponent extends Component<Props, State> {
return (
<Button
kind="link"
mix="auth-panel__admin-action"
className={styles.adminAction}
{...getHandleClickProps(this.toggleCommentsAvailability)}
role="link"
>
@@ -146,17 +144,17 @@ class AuthPanelComponent extends Component<Props, State> {
);
};
render({ user, postInfo, theme }: Props, { isBlockedVisible }: State) {
render({ user, postInfo }: Props, { isBlockedVisible }: State) {
const { read_only } = postInfo;
const isAdmin = user && user.admin;
const isSettingsLabelVisible = Object.keys(this.props.hiddenUsers).length > 0 || isAdmin || isBlockedVisible;
return (
<div className={b('auth-panel', {}, { theme, loggedIn: !!user })}>
<div className="auth-panel__column">{user ? this.renderAuthorized(user) : read_only && <Auth />}</div>
<div className={styles.root}>
<div className={styles.column}>{user ? this.renderAuthorized(user) : read_only && <Auth />}</div>
{this.renderThirdPartyWarning()}
{this.renderCookiesWarning()}
<div className="auth-panel__column auth-panel__column_separated">
<div className={clsx(styles.column, styles.columnSeparated)}>
{isSettingsLabelVisible && <span>{this.renderSettingsLabel()}</span>}
{isAdmin && <span>{this.renderReadOnlySwitch()}</span>}
{!isAdmin && read_only && (
@@ -1,5 +1 @@
import './auth-panel.css';
import './__column/auth-panel__column.css';
export * from './auth-panel';
@@ -1,14 +0,0 @@
.button_kind_link {
background: transparent;
font-weight: 600;
color: var(--color9);
&:hover {
color: var(--color33);
}
&:disabled,
&:hover:disabled {
color: var(--color9);
}
}
@@ -1,12 +0,0 @@
.button_kind_primary {
background: var(--color15);
color: var(--color6);
&:hover {
background: var(--color33);
}
&:hover:disabled {
background: var(--color15);
}
}
@@ -1,8 +0,0 @@
.button_kind_secondary {
background: var(--color6);
color: inherit;
&:hover {
box-shadow: inset 0 0 0 2px var(--color33);
}
}
@@ -1,5 +0,0 @@
.button_size_large {
height: 36px;
padding: 0 12px;
font-size: 16px;
}
@@ -1,4 +0,0 @@
.button_size_middle {
height: 2rem;
padding: 0 12px;
}
@@ -1,11 +0,0 @@
.button_theme_dark.button_kind_secondary {
background: var(--color8);
color: var(--color20);
}
.button_theme_dark.button_kind_link {
&:disabled,
&:hover:disabled {
color: var(--color6);
}
}
@@ -1,21 +0,0 @@
.button {
background: none;
border: 0;
padding: 0;
margin: 0;
border-radius: 4px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px var(--color47);
outline: none;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
@@ -0,0 +1,83 @@
.root {
background: none;
border: 0;
padding: 0;
margin: 0;
border-radius: 4px;
font-family: inherit;
font-size: inherit;
cursor: pointer;
white-space: nowrap;
&:focus {
box-shadow: 0 0 0 2px var(--color47);
outline: none;
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.kindLink {
background: transparent;
font-weight: 600;
color: var(--color9);
&:hover {
color: var(--color33);
}
&:disabled,
&:hover:disabled {
color: var(--color9);
}
}
.kindPrimary {
background: var(--color15);
color: var(--color6);
&:hover {
background: var(--color33);
}
&:hover:disabled {
background: var(--color15);
}
}
.kindSecondary {
background: var(--color6);
color: inherit;
&:hover {
box-shadow: inset 0 0 0 2px var(--color33);
}
}
.sizeMiddle {
height: 2rem;
padding: 0 12px;
}
.sizeLarge {
height: 36px;
padding: 0 12px;
font-size: 16px;
}
.themeDark {
&.kindSecondary {
background: var(--color8);
color: var(--color20);
}
&.kindLink {
&:disabled,
&:hover:disabled {
color: var(--color6);
}
}
}
@@ -1,24 +1,42 @@
import clsx from 'clsx';
import { h, JSX } from 'preact';
import { forwardRef } from 'preact/compat';
import b, { Mods, Mix } from 'bem-react-helper';
import type { Theme } from 'common/types';
import styles from './button.module.css';
const kindStyles: Record<string, string> = {
primary: styles.kindPrimary,
secondary: styles.kindSecondary,
link: styles.kindLink,
};
const sizeStyles: Record<string, string> = {
middle: styles.sizeMiddle,
large: styles.sizeLarge,
};
export type ButtonProps = Omit<JSX.HTMLAttributes, 'size' | 'className'> & {
kind?: 'primary' | 'secondary' | 'link';
size?: 'middle' | 'large';
theme?: Theme;
mods?: Mods;
mix?: Mix;
mix?: string | string[];
type?: string;
className?: string;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children, theme, mods, mix, kind, type = 'button', size, className, ...props }, ref) => (
({ children, theme, mix, kind, type = 'button', size, className, ...props }, ref) => (
<button
className={clsx(b('button', { mods: { kind, size, theme }, mix }, { ...mods }), className)}
className={clsx(
styles.root,
kind && kindStyles[kind],
size && sizeStyles[size],
theme === 'dark' && styles.themeDark,
mix,
className
)}
type={type}
{...props}
ref={ref}
@@ -1,12 +1 @@
import './button.css';
import './_kind/_link/button_kind_link.css';
import './_kind/_primary/button_kind_primary.css';
import './_kind/_secondary/button_kind_secondary.css';
import './_size/_large/button_size_large.css';
import './_size/_middle/button_size_middle.css';
import './_theme/_dark/button_theme_dark.css';
export { Button } from './button';
@@ -1,9 +0,0 @@
.comment-form__actions {
display: flex;
align-items: center;
padding-top: 12px;
flex-wrap: wrap;
justify-content: space-between;
min-height: 30px;
gap: 12px;
}
@@ -1,7 +0,0 @@
.comment-form__button_type_preview {
&:hover,
&:focus {
box-shadow: inset 0 0 0 2px var(--color9);
color: var(--color15);
}
}
@@ -1,9 +0,0 @@
.comment-form__button_type_send {
background: var(--color15);
color: var(--color6);
&:hover,
&:focus {
background: var(--color9);
}
}
@@ -1,8 +0,0 @@
.comment-form__button {
margin-right: 8px;
align-self: flex-start;
&:last-child {
margin-right: 20px;
}
}
@@ -1,4 +0,0 @@
.comment-form__control-panel {
height: 30px;
background-color: var(--color5);
}
@@ -1,8 +0,0 @@
.comment-form__counter {
position: absolute;
right: 4px;
bottom: 4px;
font-size: 10px;
font-weight: 700;
color: var(--color38);
}
@@ -1,6 +0,0 @@
.comment-form__error {
margin: 0;
padding: 10px 12px;
font-size: 14px;
line-height: 18px;
}
@@ -1,3 +0,0 @@
.comment-form__field-wrapper {
position: relative;
}
@@ -1,29 +0,0 @@
.comment-form__field {
/* font-size * line-height * lines * vertical-padding */
--height: calc(16px * 1.4 * 4 + 10px * 2);
display: block;
box-sizing: border-box;
width: 100%;
height: var(--height);
min-height: var(--height);
padding: 10px 12px;
margin: 0;
font-size: 16px;
line-height: 1.4;
border: 0;
resize: none;
overflow: hidden; /* prevent scrollbar from appearing */
backface-visibility: hidden; /* let's try to fix blinking in Safari */
transform: translateZ(0); /* let's try to fix blinking in Safari, again */
&:focus {
box-shadow: 0 0 0 2px var(--color47);
border-color: var(--color15);
outline: none;
}
&:disabled {
color: var(--color10);
}
}
@@ -1,4 +0,0 @@
.comment-form__markdown-link {
font-weight: 700;
white-space: nowrap;
}
@@ -1,4 +0,0 @@
.comment-form__markdown {
margin-bottom: 5px;
font-size: 12px;
}
@@ -1,3 +0,0 @@
.comment-form__preview-wrapper {
overflow: hidden;
}
@@ -1,9 +0,0 @@
.comment-form__preview {
margin-top: 8px;
padding: 7px 11px;
overflow: hidden;
font-size: 16px;
line-height: 1.2;
border: 1px dashed;
border-radius: 2px;
}
@@ -1,4 +0,0 @@
.comment-form__rss {
font-size: 12px;
line-height: 1;
}
@@ -19,6 +19,7 @@ import enMessages from 'locales/en.json';
import { SubscribeByEmail, SubscribeByEmailForm } from '.';
import { RequestError } from '../../../utils/errorUtils';
import styles from './subscribe-by-email.module.css';
const emailVerificationForSubscribeMock = emailVerificationForSubscribe as unknown as jest.Mock<
ReturnType<typeof emailVerificationForSubscribe>
@@ -91,7 +92,7 @@ describe('<SubscribeByEmailForm/>', () => {
it('should render email form by default', () => {
const store = mockStore(initialStore);
const wrapper = createWrapper(store);
const title = wrapper.find('.comment-form__subscribe-by-email__title');
const title = wrapper.find(`.${styles.title}`);
const button = wrapper.find(Button);
expect(title.text()).toEqual('Subscribe to replies');
@@ -103,7 +104,7 @@ describe('<SubscribeByEmailForm/>', () => {
const store = mockStore({ ...initialStore, user: { email_subscription: true } });
const wrapper = createWrapper(store);
expect(wrapper.find('.comment-form__subscribe-by-email_subscribed')).toHaveLength(1);
expect(wrapper.find(`.${styles.subscribed}`)).toHaveLength(1);
expect(wrapper.text().startsWith('You are subscribed on updates by email')).toBe(true);
});
@@ -1,7 +1,7 @@
import { h, FunctionComponent, Fragment } from 'preact';
import { useState, useCallback, useRef } from 'preact/hooks';
import { useSelector, useDispatch } from 'react-redux';
import b from 'bem-react-helper';
import clsx from 'clsx';
import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl';
import { User } from 'common/types';
@@ -21,6 +21,8 @@ import { getPersistedEmail } from 'components/auth/auth.utils';
import { isUserAnonymous } from 'utils/isUserAnonymous';
import { isJwtExpired } from 'utils/jwt';
import styles from './subscribe-by-email.module.css';
const emailRegexp = /[^@]+@[^.]+\..+/;
enum Step {
@@ -77,12 +79,11 @@ const renderEmailPart = (
handleChangeEmail: (e: Event) => void
) => (
<>
<div className="comment-form__subscribe-by-email__title">
<div className={styles.title}>
<FormattedMessage id="subscribeByEmail.subscribe-to-replies" defaultMessage="Subscribe to replies" />
</div>
<Input
autofocus
className="comment-form__subscribe-by-email__input"
placeholder={intl.formatMessage(messages.email)}
value={emailAddress}
onInput={handleChangeEmail}
@@ -99,11 +100,11 @@ const renderTokenPart = (
setEmailStep: () => void
) => (
<>
<Button kind="link" mix="auth-email-login-form__back-button" {...getHandleClickProps(setEmailStep)}>
<Button kind="link" {...getHandleClickProps(setEmailStep)}>
<FormattedMessage id="subscribeByEmail.back" defaultMessage="Back" />
</Button>
<TextareaAutosize
className="comment-form__subscribe-by-email__token-input"
className={styles.tokenInput}
placeholder={intl.formatMessage(messages.token)}
autofocus
onInput={handleChangeToken}
@@ -246,15 +247,9 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
: intl.formatMessage(messages.subscribed);
return (
<div className={b('comment-form__subscribe-by-email', { mods: { subscribed: true } })}>
<div className={styles.subscribed}>
{text}
<Button
kind="primary"
size="middle"
mix="comment-form__subscribe-by-email__button"
theme={theme}
onClick={handleUnsubscribe}
>
<Button kind="primary" size="middle" className={styles.button} theme={theme} onClick={handleUnsubscribe}>
<FormattedMessage id="subscribeByEmail.unsubscribe" defaultMessage="Unsubscribe" />
</Button>
</div>
@@ -269,7 +264,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
*/
return (
<div className={b('comment-form__subscribe-by-email', { mods: { unsubscribed: true } })}>
<div className={styles.unsubscribed}>
<FormattedMessage
id="subscribeByEmail.have-been-unsubscribed"
defaultMessage="You have been unsubscribed by email to updates"
@@ -277,7 +272,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
<Button
kind="primary"
size="middle"
mix="comment-form__subscribe-by-email__button"
className={styles.button}
theme={theme}
onClick={() => setStep(Step.Close)}
>
@@ -291,22 +286,25 @@ export const SubscribeByEmailForm: FunctionComponent = () => {
step === Step.Email ? intl.formatMessage(messages.submit) : intl.formatMessage(messages.subscribe);
return (
<form className={b('comment-form__subscribe-by-email', {}, { theme })} onSubmit={handleSubmit}>
<form
className={clsx(styles.root, theme === 'dark' ? styles.themeDark : styles.themeLight)}
onSubmit={handleSubmit}
>
{step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail)}
{step === Step.Token && renderTokenPart(loading, intl, token, handleChangeToken, setEmailStep)}
{error !== null && (
<div className="comment-form__subscribe-by-email__error" role="alert">
<div className={styles.error} role="alert">
{error}
</div>
)}
<Button
mix="comment-form__subscribe-by-email__button"
className={styles.button}
kind="primary"
size="large"
type="submit"
disabled={!isValidEmailAddress || loading}
>
{loading ? <Preloader className="comment-form__subscribe-by-email__preloader" /> : buttonLabel}
{loading ? <Preloader className={styles.preloader} /> : buttonLabel}
</Button>
</form>
);
@@ -320,13 +318,7 @@ export const SubscribeByEmail: FunctionComponent = () => {
const buttonTitle = intl.formatMessage(isAnonymous ? messages.onlyRegisteredUsers : messages.subscribeByEmail);
return (
<Dropdown
mix="comment-form__email-dropdown"
title={intl.formatMessage(messages.email)}
theme={theme}
disabled={isAnonymous}
buttonTitle={buttonTitle}
>
<Dropdown title={intl.formatMessage(messages.email)} theme={theme} disabled={isAnonymous} buttonTitle={buttonTitle}>
<SubscribeByEmailForm />
</Dropdown>
);
@@ -1,4 +1,4 @@
.comment-form__subscribe-by-email {
.root {
display: flex;
flex-wrap: wrap;
flex-direction: column;
@@ -9,31 +9,27 @@
text-align: left;
}
.comment-form__subscribe-by-email_token {
padding-top: 0;
}
.comment-form__subscribe-by-email_subscribed,
.comment-form__subscribe-by-email_unsubscribed {
.subscribed,
.unsubscribed {
padding: 8px 12px;
text-align: left;
font-size: 14px;
}
.comment-form__subscribe-by-email__title {
.title {
margin-bottom: 12px;
}
.comment-form__subscribe-by-email__button {
.button {
margin-top: 10px;
flex-grow: 1;
}
.comment-form__subscribe-by-email__preloader {
.preloader {
margin: 0 auto;
}
.comment-form__subscribe-by-email__token-input {
.tokenInput {
resize: vertical;
border: 1px solid var(--color31);
padding: 4px;
@@ -49,18 +45,18 @@
}
}
.comment-form__subscribe-by-email__error {
.error {
margin-top: 8px;
padding: 6px 8px;
line-height: 1.2;
}
.comment-form__subscribe-by-email_theme_dark .comment-form__subscribe-by-email__error {
.themeDark .error {
background: var(--color28);
color: var(--color27);
}
.comment-form__subscribe-by-email_theme_light .comment-form__subscribe-by-email__error {
.themeLight .error {
background: var(--color26);
color: var(--color25);
}
@@ -1,3 +0,0 @@
.comment-form_simple {
border-width: 12px;
}
@@ -1,50 +0,0 @@
.comment-form_theme_dark {
border-color: var(--color7);
background: var(--color8); /* try to fix textarea blinking in Safari */
& .comment-form__actions {
background: var(--color7);
}
& .comment-form__button_type_preview {
background: var(--color8);
color: var(--color20);
}
& .comment-form__button_type_send {
color: var(--color20);
}
& .comment-form__error {
border-top: 8px solid var(--color7);
background: var(--color28);
color: var(--color27);
}
& .comment-form__field {
background: var(--color8);
color: var(--color5);
}
& .comment-form__preview {
border-color: var(--color8);
background: var(--color8);
color: var(--color20);
}
& .comment-form__preview-wrapper {
background: var(--color7);
}
& .comment-form__toolbar-item {
color: var(--color20);
&:hover {
color: var(--color9);
}
}
& .comment-form__control-panel {
background-color: var(--color7);
}
}
@@ -1,38 +0,0 @@
.comment-form_theme_light {
border-color: var(--color5);
background: var(--color6); /* try to fix textarea blinking in Safari */
& .comment-form__actions {
background: var(--color5);
}
& .comment-form__button_type_preview {
background: var(--color6);
color: var(--color0);
}
& .comment-form__button_type_send {
color: var(--color6);
}
& .comment-form__error {
border-top: 8px solid var(--color5);
background: var(--color26);
color: var(--color25);
}
& .comment-form__field {
background: var(--color6);
color: var(--color0);
}
& .comment-form__preview {
border-color: var(--color5);
background: var(--color6);
color: var(--color7);
}
& .comment-form__preview-wrapper {
background: var(--color5);
}
}
@@ -1,15 +0,0 @@
.comment-form {
position: relative;
display: block;
border-style: solid;
border-width: 6px 12px 12px 12px;
border-radius: 2px;
}
.comment-form__dropdown_rss {
text-align: left;
& .dropdown__content {
width: 8em;
}
}
@@ -0,0 +1,186 @@
.root {
position: relative;
display: block;
border-style: solid;
border-width: 6px 12px 12px 12px;
border-radius: 2px;
}
.simple {
border-width: 12px;
}
.typeReply {
margin-left: 17px;
@media (pointer: coarse) and (max-width: 768px) {
margin-left: 0;
}
}
.controlPanel {
height: 30px;
background-color: var(--color5);
}
.fieldWrapper {
position: relative;
}
.field {
/* font-size * line-height * lines * vertical-padding */
--height: calc(16px * 1.4 * 4 + 10px * 2);
display: block;
box-sizing: border-box;
width: 100%;
height: var(--height);
min-height: var(--height);
padding: 10px 12px;
margin: 0;
font-size: 16px;
line-height: 1.4;
border: 0;
resize: none;
overflow: hidden;
backface-visibility: hidden;
transform: translateZ(0);
&:focus {
box-shadow: 0 0 0 2px var(--color47);
border-color: var(--color15);
outline: none;
}
&:disabled {
color: var(--color10);
}
}
.counter {
position: absolute;
right: 4px;
bottom: 4px;
font-size: 10px;
font-weight: 700;
color: var(--color38);
}
.error {
margin: 0;
padding: 10px 12px;
font-size: 14px;
line-height: 18px;
}
.actions {
display: flex;
align-items: center;
padding-top: 12px;
flex-wrap: wrap;
justify-content: space-between;
min-height: 30px;
gap: 12px;
}
.button {
margin-right: 8px;
align-self: flex-start;
&:last-child {
margin-right: 20px;
}
}
.rss {
font-size: 12px;
line-height: 1;
}
.previewWrapper {
overflow: hidden;
}
.preview {
margin-top: 8px;
padding: 7px 11px;
overflow: hidden;
font-size: 16px;
line-height: 1.2;
border: 1px dashed;
border-radius: 2px;
}
.markdown {
margin-bottom: 5px;
font-size: 12px;
}
.markdownLink {
font-weight: 700;
white-space: nowrap;
}
.themeDark {
border-color: var(--color7);
background: var(--color8);
& .actions {
background: var(--color7);
}
& .error {
border-top: 8px solid var(--color7);
background: var(--color28);
color: var(--color27);
}
& .field {
background: var(--color8);
color: var(--color5);
}
& .preview {
border-color: var(--color8);
background: var(--color8);
color: var(--color20);
}
& .previewWrapper {
background: var(--color7);
}
& .controlPanel {
background-color: var(--color7);
}
}
.themeLight {
border-color: var(--color5);
background: var(--color6);
& .actions {
background: var(--color5);
}
& .error {
border-top: 8px solid var(--color5);
background: var(--color26);
color: var(--color25);
}
& .field {
background: var(--color6);
color: var(--color0);
}
& .preview {
border-color: var(--color5);
background: var(--color6);
color: var(--color7);
}
& .previewWrapper {
background: var(--color5);
}
}
@@ -1,6 +1,6 @@
import { h, Component, createRef, Fragment } from 'preact';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import b, { Mix } from 'bem-react-helper';
import clsx from 'clsx';
import { User, Theme, Image } from 'common/types';
import { StaticStore } from 'common/static-store';
@@ -20,13 +20,14 @@ import { SubscribeByRSS } from './__subscribe-by-rss';
import { MarkdownToolbar } from './markdown-toolbar';
import { TextExpander } from './text-expander';
import { updatePersistedComments, getPersistedComment, removePersistedComment } from './comment-form.persist';
import styles from './comment-form.module.css';
export type Props = {
id: string;
user: User | null;
errorMessage?: string;
value?: string;
mix?: Mix;
mix?: string | string[];
mode?: 'main' | 'edit' | 'reply';
theme: Theme;
autofocus?: boolean;
@@ -362,13 +363,13 @@ export class CommentForm extends Component<Props, State> {
};
renderMarkdownTip = () => (
<div className="comment-form__markdown">
<div className={styles.markdown}>
<FormattedMessage
id="commentForm.notice-about-styling"
defaultMessage="Styling with <a>Markdown</a> is supported"
values={{
a: (title: string) => (
<a class="comment-form__markdown-link" target="_blank" href="markdown-help.html">
<a class={styles.markdownLink} target="_blank" href="markdown-help.html">
{title}
</a>
),
@@ -426,14 +427,13 @@ export class CommentForm extends Component<Props, State> {
return (
<form
className={b('comment-form', {
mods: {
theme,
type: mode || 'reply',
simple: isSimpleView,
},
mix,
})}
className={clsx(
styles.root,
theme === 'dark' ? styles.themeDark : styles.themeLight,
(!mode || mode === 'reply') && styles.typeReply,
isSimpleView && styles.simple,
mix
)}
onSubmit={this.send}
aria-label={intl.formatMessage(messages.newComment)}
onDragOver={this.onDragOver}
@@ -441,7 +441,7 @@ export class CommentForm extends Component<Props, State> {
data-testid={`commentform_${this.props.id}`}
>
{!isSimpleView && (
<div className="comment-form__control-panel" data-testid="markdown-toolbar">
<div className={styles.controlPanel} data-testid="markdown-toolbar">
<MarkdownToolbar
intl={intl}
allowUpload={Boolean(uploadImage)}
@@ -450,13 +450,13 @@ export class CommentForm extends Component<Props, State> {
/>
</div>
)}
<div className="comment-form__field-wrapper">
<div className={styles.fieldWrapper}>
<TextExpander>
<TextareaAutosize
id={this.textareaId}
ref={this.textareaRef}
onPaste={this.onPaste}
className="comment-form__field"
className={styles.field}
placeholder={placeholderMessage}
value={text}
onInput={this.onInput}
@@ -467,17 +467,17 @@ export class CommentForm extends Component<Props, State> {
dir="auto"
/>
</TextExpander>
{charactersLeft < 100 && <span className="comment-form__counter">{charactersLeft}</span>}
{charactersLeft < 100 && <span className={styles.counter}>{charactersLeft}</span>}
</div>
{(isErrorShown || !!errorMessage) &&
(errorMessage || intl.formatMessage(messages.unexpectedError)).split('\n').map((e) => (
<p className="comment-form__error" role="alert" key={e}>
<p className={styles.error} role="alert" key={e}>
{e}
</p>
))}
<div className="comment-form__actions">
<div className={styles.actions}>
{user ? (
<>
<div>
@@ -486,20 +486,20 @@ export class CommentForm extends Component<Props, State> {
kind="secondary"
theme={theme}
size="large"
mix="comment-form__button"
className={styles.button}
disabled={isDisabled}
onClick={this.getPreview}
>
<FormattedMessage id="commentForm.preview" defaultMessage="Preview" />
</Button>
)}
<Button kind="primary" size="large" mix="comment-form__button" type="submit" disabled={isDisabled}>
<Button kind="primary" size="large" className={styles.button} type="submit" disabled={isDisabled}>
{label}
</Button>
</div>
{mode === 'main' && (
<div className="comment-form__rss">
<div className={styles.rss}>
{this.renderMarkdownTip()}
{this.renderSubscribeButtons()}
</div>
@@ -517,9 +517,9 @@ export class CommentForm extends Component<Props, State> {
// TODO: it can be more elegant;
// for example it can render full comment component here (or above textarea on mobile)
!!preview && (
<div className="comment-form__preview-wrapper">
<div className={styles.previewWrapper}>
<div
className="comment-form__preview raw-content"
className={clsx(styles.preview, 'raw-content')}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: preview }}
dir="auto"
@@ -1,28 +1,2 @@
import './comment-form.css';
import './__actions/comment-form__actions.css';
import './__button/comment-form__button.css';
import './__button/_type/_preview/comment-form__button_type_preview.css';
import './__button/_type/_send/comment-form__button_type_send.css';
import './__control-panel/comment-form__control-panel.css';
import './__counter/comment-form__counter.css';
import './__error/comment-form__error.css';
import './__field/comment-form__field.css';
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 './__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 './_theme/_dark/comment-form_theme_dark.css';
import './_theme/_light/comment-form_theme_light.css';
import './_simple/comment-form_simple.css';
export { CommentForm } from './comment-form';
export type { Props } from './comment-form';
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function BoldIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 10 16"
version="1.1"
width="10"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 10 16" version="1.1" width="10" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M1 2h3.83c2.48 0 4.3.75 4.3 2.95 0 1.14-.63 2.23-1.67 2.61v.06c1.33.3 2.3 1.23 2.3 2.86 0 2.39-1.97 3.52-4.61 3.52H1V2zm3.66 4.95c1.67 0 2.38-.66 2.38-1.69 0-1.17-.78-1.61-2.34-1.61H3.13v3.3h1.53zm.27 5.39c1.77 0 2.75-.64 2.75-1.98 0-1.27-.95-1.81-2.75-1.81h-1.8v3.8h1.8v-.01z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function CodeIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 14 16"
version="1.1"
width="14"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M9.5 3L8 4.5 11.5 8 8 11.5 9.5 13 14 8 9.5 3zm-5 0L0 8l4.5 5L6 11.5 2.5 8 6 4.5 4.5 3z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function HeaderIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 18 16"
version="1.1"
width="18"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 18 16" version="1.1" width="18" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M13.62 9.08L12.1 3.66h-.06l-1.5 5.42h3.08zM5.7 10.13S4.68 6.52 4.53 6.02h-.08l-1.13 4.11H5.7zM17.31 14h-2.25l-.95-3.25h-4.07L9.09 14H6.84l-.69-2.33H2.87L2.17 14H0l3.3-9.59h2.5l2.17 6.34L10.86 2h2.52l3.94 12h-.01z"
@@ -1,8 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function ImageIcon() {
return (
<svg className="comment-form__toolbar-icon" width="11.25" height="15" viewBox="0 0 384 512" aria-hidden="true">
<svg className={toolbarStyles.icon} width="11.25" height="15" viewBox="0 0 384 512" aria-hidden="true">
<path
fill-rule="evenodd"
d="M369.9 97.9L286 14C277 5 264.8-.1 252.1-.1H48C21.5 0 0 21.5 0 48v416c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48V131.9c0-12.7-5.1-25-14.1-34zM332.1 128H256V51.9l76.1 76.1zM48 464V48h160v104c0 13.3 10.7 24 24 24h104v288H48zm32-48h224V288l-23.5-23.5c-4.7-4.7-12.3-4.7-17 0L176 352l-39.5-39.5c-4.7-4.7-12.3-4.7-17 0L80 352v64zm48-240c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function ItalicIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 6 16"
version="1.1"
width="6"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 6 16" version="1.1" width="6" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M2.81 5h1.98L3 14H1l1.81-9zm.36-2.7c0-.7.58-1.3 1.33-1.3.56 0 1.13.38 1.13 1.03 0 .75-.59 1.3-1.33 1.3-.58 0-1.13-.38-1.13-1.03z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function LinkIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 16 16"
version="1.1"
width="16"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function OrderedListIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 12 16"
version="1.1"
width="12"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M12.01 13c0 .59 0 1-.59 1H4.6c-.59 0-.59-.41-.59-1 0-.59 0-1 .59-1h6.81c.59 0 .59.41.59 1h.01zM4.6 4h6.81C12 4 12 3.59 12 3c0-.59 0-1-.59-1H4.6c-.59 0-.59.41-.59 1 0 .59 0 1 .59 1zm6.81 3H4.6c-.59 0-.59.41-.59 1 0 .59 0 1 .59 1h6.81C12 9 12 8.59 12 8c0-.59 0-1-.59-1zm-9.4-6h-.72c-.3.19-.58.25-1.03.34V2h.75v2.14H.17V5h2.84v-.86h-1V1zm.392 8.12c-.129 0-.592.04-.802.07.53-.56 1.14-1.25 1.14-1.89C2.72 6.52 2.18 6 1.38 6c-.59 0-.97.2-1.38.64l.58.58c.19-.19.38-.38.64-.38.28 0 .48.16.48.52 0 .53-.77 1.2-1.7 2.06V10h3v-.88h-.598zm-.222 3.79v-.03c.44-.19.64-.47.64-.86 0-.7-.56-1.11-1.44-1.11-.48 0-.89.19-1.28.52l.55.64c.25-.2.44-.31.69-.31.27 0 .42.13.42.36 0 .27-.2.44-.86.44v.75c.83 0 .98.17.98.47 0 .25-.23.38-.58.38-.28 0-.56-.14-.81-.38l-.48.66c.3.36.77.56 1.41.56.83 0 1.53-.41 1.53-1.16 0-.5-.31-.81-.77-.94v.01z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function QuoteIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 14 16"
version="1.1"
width="14"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 14 16" version="1.1" width="14" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M6.16 3.5C3.73 5.06 2.55 6.67 2.55 9.36c.16-.05.3-.05.44-.05 1.27 0 2.5.86 2.5 2.41 0 1.61-1.03 2.61-2.5 2.61-1.9 0-2.99-1.52-2.99-4.25 0-3.8 1.75-6.53 5.02-8.42L6.16 3.5zm7 0c-2.43 1.56-3.61 3.17-3.61 5.86.16-.05.3-.05.44-.05 1.27 0 2.5.86 2.5 2.41 0 1.61-1.03 2.61-2.5 2.61-1.89 0-2.98-1.52-2.98-4.25 0-3.8 1.75-6.53 5.02-8.42l1.14 1.84h-.01z"
@@ -1,15 +1,10 @@
import { h } from 'preact';
import toolbarStyles from '../markdown-toolbar.module.css';
export function UnorderedListIcon() {
return (
<svg
className="comment-form__toolbar-icon"
viewBox="0 0 12 16"
version="1.1"
width="12"
height="16"
aria-hidden="true"
>
<svg className={toolbarStyles.icon} viewBox="0 0 12 16" version="1.1" width="12" height="16" aria-hidden="true">
<path
fill-rule="evenodd"
d="M2 13c0 .59 0 1-.59 1H.59C0 14 0 13.59 0 13c0-.59 0-1 .59-1h.81c.59 0 .59.41.59 1H2zm2.59-9h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1H4.59C4 2 4 2.41 4 3c0 .59 0 1 .59 1zM1.41 7H.59C0 7 0 7.41 0 8c0 .59 0 1 .59 1h.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm0-5H.59C0 2 0 2.41 0 3c0 .59 0 1 .59 1h.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm10 5H4.59C4 7 4 7.41 4 8c0 .59 0 1 .59 1h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01zm0 5H4.59C4 12 4 12.41 4 13c0 .59 0 1 .59 1h6.81c.59 0 .59-.41.59-1 0-.59 0-1-.59-1h.01z"
@@ -1,9 +1,9 @@
.comment-form__toolbar {
.toolbar {
display: block;
float: right;
}
.comment-form__toolbar-file-input {
.fileInput {
position: absolute;
width: 1px;
height: 1px;
@@ -11,7 +11,7 @@
clip-path: inset(50%);
}
.comment-form__toolbar-item {
.item {
background: none;
border: 0;
color: var(--color37);
@@ -28,16 +28,24 @@
}
}
.comment-form__toolbar-icon {
.icon {
display: inline-block;
fill: currentColor;
}
.comment-form__toolbar-group {
.group {
display: inline-block;
margin-left: 20px;
&:first-child {
margin-left: 0;
}
}
.comment-form__toolbar-group:first-child {
margin-left: 0;
:global(.dark) .item {
color: var(--color20);
&:hover {
color: var(--color9);
}
}
@@ -3,6 +3,8 @@ import '@github/markdown-toolbar-element';
import { h, Component } from 'preact';
import { defineMessages, IntlShape } from 'react-intl';
import styles from './markdown-toolbar.module.css';
// TODO: Use SVGR
import { BoldIcon } from './markdown-toolbar-icons/bold-icon';
import { HeaderIcon } from './markdown-toolbar-icons/header-icon';
@@ -95,48 +97,40 @@ export class MarkdownToolbar extends Component<Props> {
const attachImageLabel = intl.formatMessage(messages.attachImage);
return (
<markdown-toolbar className="comment-form__toolbar" for={props.textareaId}>
<div className="comment-form__toolbar-group">
<md-header className="comment-form__toolbar-item" title={headerLabel} aria-label={headerLabel}>
<markdown-toolbar className={styles.toolbar} for={props.textareaId}>
<div className={styles.group}>
<md-header className={styles.item} title={headerLabel} aria-label={headerLabel}>
<HeaderIcon />
</md-header>
<md-bold className="comment-form__toolbar-item" title={boldLabel} aria-label={boldLabel}>
<md-bold className={styles.item} title={boldLabel} aria-label={boldLabel}>
<BoldIcon />
</md-bold>
<md-italic className="comment-form__toolbar-item" title={italicLabel} aria-label={italicLabel}>
<md-italic className={styles.item} title={italicLabel} aria-label={italicLabel}>
<ItalicIcon />
</md-italic>
</div>
<div className="comment-form__toolbar-group">
<md-quote className="comment-form__toolbar-item" title={quoteLabel} aria-label={quoteLabel}>
<div className={styles.group}>
<md-quote className={styles.item} title={quoteLabel} aria-label={quoteLabel}>
<QuoteIcon />
</md-quote>
<md-code className="comment-form__toolbar-item" title={codeLabel} aria-label={codeLabel}>
<md-code className={styles.item} title={codeLabel} aria-label={codeLabel}>
<CodeIcon />
</md-code>
<md-link className="comment-form__toolbar-item" title={linkLabel} aria-label={linkLabel}>
<md-link className={styles.item} title={linkLabel} aria-label={linkLabel}>
<LinkIcon />
</md-link>
{this.props.allowUpload ? (
<label className="comment-form__toolbar-item" title={attachImageLabel} aria-label={attachImageLabel}>
<input multiple className="comment-form__toolbar-file-input" type="file" onChange={this.uploadImages} />
<label className={styles.item} title={attachImageLabel} aria-label={attachImageLabel}>
<input multiple className={styles.fileInput} type="file" onChange={this.uploadImages} />
<ImageIcon />
</label>
) : null}
</div>
<div className="comment-form__toolbar-group">
<md-unordered-list
className="comment-form__toolbar-item"
title={unorderedListLabel}
aria-label={unorderedListLabel}
>
<div className={styles.group}>
<md-unordered-list className={styles.item} title={unorderedListLabel} aria-label={unorderedListLabel}>
<UnorderedListIcon />
</md-unordered-list>
<md-ordered-list
className="comment-form__toolbar-item"
title={orderedListLabel}
aria-label={orderedListLabel}
>
<md-ordered-list className={styles.item} title={orderedListLabel} aria-label={orderedListLabel}>
<OrderedListIcon />
</md-ordered-list>
</div>
@@ -1,3 +0,0 @@
.comment__body {
padding-left: 17px;
}
@@ -1,9 +0,0 @@
.comment__info {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
padding-right: 84px;
font-size: 14px;
position: relative;
}
@@ -1,3 +0,0 @@
.comment__input {
margin-top: 8px;
}
@@ -1,5 +0,0 @@
.comment__status {
margin-left: 8px;
vertical-align: middle;
font-weight: 700;
}
@@ -1,4 +0,0 @@
.comment__text {
margin-bottom: 4px;
overflow: hidden;
}
@@ -1,14 +0,0 @@
.comment__time {
margin-left: 8px;
margin-right: 8px;
vertical-align: middle;
text-decoration: none;
&:first-child {
margin-left: 0;
}
&:hover {
text-decoration: underline;
}
}
@@ -1,3 +0,0 @@
.comment__user-id {
font-weight: 400;
}
@@ -1,9 +0,0 @@
.comment__username {
vertical-align: middle;
font-weight: 700;
text-decoration: none;
&:hover {
opacity: 0.75;
}
}
@@ -1,27 +0,0 @@
.comment_collapsed {
padding: 12px 0 12px 28px;
& > .comment__body {
& .comment__text,
& .comment__actions {
display: none;
}
}
& .comment__info {
margin-bottom: 0;
opacity: 0.8;
min-height: 20px;
}
& .comment__avatar {
width: 20px;
height: 20px;
opacity: 0.6;
}
& .comment__username,
& .comment__time {
font-style: italic;
}
}
@@ -1,22 +0,0 @@
.comment_editing {
& .comment__text {
display: none;
}
/* it isn't mobile first, but it's fine here */
@media (pointer: coarse) and (max-width: 768px) {
border: 8px solid;
padding-bottom: 0;
& .comment__body {
padding: 8px 8px 0;
}
& .comment__input {
border-left-width: 0;
border-right-width: 0;
border-bottom-width: 0;
border-top-width: 8px;
}
}
}
@@ -1,25 +0,0 @@
.comment_replying {
padding-bottom: 0;
position: relative;
z-index: 1;
/* it isn't mobile first, but it's fine here */
@media (pointer: coarse) and (max-width: 768px) {
border: 8px solid;
& .comment__info {
padding-left: 8px;
}
& .comment__body {
padding: 8px 8px 0;
}
& .comment__input {
border-left-width: 0;
border-right-width: 0;
border-bottom-width: 0;
border-top-width: 8px;
}
}
}
@@ -1,98 +0,0 @@
.comment_theme_dark {
& .comment__action_type_collapse {
border-color: var(--color34);
color: var(--color34);
&.comment__action_selected {
background: var(--color34);
color: var(--color8);
}
}
& .comment__action_type_delete,
& .comment__action_type_edit {
color: var(--color14);
}
& .comment__action {
& + .comment__controls {
&::before {
color: var(--color5);
}
}
}
& .comment__control_view_inactive {
&,
&:hover,
&:focus {
color: var(--color11);
}
}
& .comment__edit-timer {
color: var(--color32);
& + .comment__controls {
&::before {
color: var(--color5);
}
}
}
& .comment__status {
color: var(--color11);
}
& .comment__time {
color: var(--color35);
&:hover {
color: var(--color1);
}
}
& .comment__user-id {
color: var(--color13);
}
& .comment__username {
color: var(--color10);
&:hover {
color: var(--color33);
}
}
&.comment_editing {
& .comment__input {
border-color: var(--color7);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color7);
}
}
&.comment_replying {
& .comment__input {
border-color: var(--color7);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color7);
}
}
&.comment_view_preview {
& .comment__info {
&::after {
color: var(--color10);
}
}
}
&.comment_highlighting {
background: var(--color19);
}
}
@@ -1,94 +0,0 @@
.comment_theme_light {
& .comment__action_type_collapse {
border-color: var(--color20);
color: var(--color20);
&.comment__action_selected {
background: var(--color20);
color: var(--color6);
}
}
& .comment__action_type_delete,
& .comment__action_type_edit {
color: var(--color32);
}
& .comment__action {
& + .comment__controls {
&::before {
color: var(--color10);
}
}
}
& .comment__control_view_inactive {
&,
&:hover,
&:focus {
color: var(--color11);
}
}
& .comment__edit-timer {
color: var(--color32);
& + .comment__controls {
&::before {
color: var(--color10);
}
}
}
& .comment__status {
color: var(--color11);
}
& .comment__time {
color: var(--color11);
&:hover {
color: var(--color10);
}
}
& .comment__user-id {
color: var(--color13);
}
& .comment__username {
color: var(--color10);
}
&.comment_editing {
& .comment__input {
border-color: var(--color5);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color5);
}
}
&.comment_replying {
& .comment__input {
border-color: var(--color5);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color5);
}
}
&.comment_view_preview {
& .comment__info {
&::after {
color: var(--color10);
}
}
}
&.comment_highlighting {
background: var(--color4);
}
}
@@ -1,9 +0,0 @@
.comment_useless {
& .comment__body {
opacity: 0.35;
&:hover {
opacity: 1;
}
}
}
@@ -1,9 +0,0 @@
.comment_view_admin {
&,
&.comment_theme_light,
&.comment_theme_dark {
& .comment__username {
color: var(--color29);
}
}
}
@@ -1,44 +0,0 @@
.comment_view_preview {
&,
&.comment_theme_light,
&.comment_theme_dark {
font-size: 14px;
padding-bottom: 0;
& .comment__title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color9);
}
& .comment__title-link {
color: var(--color9);
text-decoration: none;
&:hover {
opacity: 0.75;
}
}
& .comment__info {
display: inline;
padding-right: 0;
font-weight: 700;
color: var(--color10);
&::after {
content: ' ';
}
}
& .comment__username {
color: var(--color9);
}
& .comment__text {
display: inline;
vertical-align: bottom;
}
}
}
@@ -1,30 +0,0 @@
.comment_view_user {
&.comment_collapsed {
& .comment__text,
& .comment__actions {
display: block;
}
}
& .comment__title {
margin-bottom: 0.2rem;
}
& .comment__body {
padding-left: 0;
}
& .comment__title-link {
color: var(--color29);
font-weight: bold;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: block;
&:hover {
opacity: 0.75;
}
}
}
@@ -1,36 +0,0 @@
.comment {
display: block;
padding: 12px 0 8px;
font-size: 16px;
line-height: 1.4;
transition: background 0.3s ease-in-out;
@media (hover: hover) {
&:hover {
& > .comment__body .comment__actions .comment__controls {
opacity: 1;
}
}
}
}
.comment__avatar {
width: 24px;
height: 24px;
margin-right: 8px;
}
.comment_level_6 {
& .comment__text,
& .comment__actions {
padding-left: 0;
}
}
.comment-form_type_reply {
margin-left: 17px;
@media (pointer: coarse) and (max-width: 768px) {
margin-left: 0;
}
}
@@ -1,3 +1,4 @@
/* stylelint-disable no-descending-specificity */
.user {
display: flex;
align-items: center;
@@ -48,3 +49,348 @@
color: var(--color41);
}
}
.root {
display: block;
padding: 12px 0 8px;
font-size: 16px;
line-height: 1.4;
transition: background 0.3s ease-in-out;
}
.avatar {
width: 24px;
height: 24px;
margin-right: 8px;
}
.body {
padding-left: 17px;
}
.info {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
padding-right: 84px;
font-size: 14px;
position: relative;
}
.input {
margin-top: 8px;
}
.status {
margin-left: 8px;
vertical-align: middle;
font-weight: 700;
}
.text {
margin-bottom: 4px;
overflow: hidden;
}
.time {
margin-left: 8px;
margin-right: 8px;
vertical-align: middle;
text-decoration: none;
&:first-child {
margin-left: 0;
}
&:hover {
text-decoration: underline;
}
}
.username {
vertical-align: middle;
font-weight: 700;
text-decoration: none;
&:hover {
opacity: 0.75;
}
}
.level6 .text {
padding-left: 0;
}
.collapsed {
padding: 12px 0 12px 28px;
& > .body {
& .text {
display: none;
}
}
& .info {
margin-bottom: 0;
opacity: 0.8;
min-height: 20px;
}
& .avatar {
width: 20px;
height: 20px;
opacity: 0.6;
}
& .username,
& .time {
font-style: italic;
}
}
.editing {
& .text {
display: none;
}
@media (pointer: coarse) and (max-width: 768px) {
border: 8px solid;
padding-bottom: 0;
& .body {
padding: 8px 8px 0;
}
& .input {
border-left-width: 0;
border-right-width: 0;
border-bottom-width: 0;
border-top-width: 8px;
}
}
}
.replying {
padding-bottom: 0;
position: relative;
z-index: 1;
@media (pointer: coarse) and (max-width: 768px) {
border: 8px solid;
& .info {
padding-left: 8px;
}
& .body {
padding: 8px 8px 0;
}
& .input {
border-left-width: 0;
border-right-width: 0;
border-bottom-width: 0;
border-top-width: 8px;
}
}
}
.useless {
& .body {
opacity: 0.35;
&:hover {
opacity: 1;
}
}
}
.viewAdmin {
&,
&.themeLight,
&.themeDark {
& .username {
color: var(--color29);
}
}
}
.viewPreview {
&,
&.themeLight,
&.themeDark {
font-size: 14px;
padding-bottom: 0;
& .title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color9);
}
& .titleLink {
color: var(--color9);
text-decoration: none;
&:hover {
opacity: 0.75;
}
}
& .info {
display: inline;
padding-right: 0;
font-weight: 700;
color: var(--color10);
&::after {
content: ' ';
}
}
& .username {
color: var(--color9);
}
& .text {
display: inline;
vertical-align: bottom;
}
}
}
.viewUser {
&.collapsed {
& .text {
display: block;
}
}
& .title {
margin-bottom: 0.2rem;
}
& .body {
padding-left: 0;
}
& .titleLink {
color: var(--color29);
font-weight: bold;
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
display: block;
&:hover {
opacity: 0.75;
}
}
}
.themeDark {
& .status {
color: var(--color11);
}
& .time {
color: var(--color35);
&:hover {
color: var(--color1);
}
}
& .username {
color: var(--color10);
&:hover {
color: var(--color33);
}
}
&.editing {
& .input {
border-color: var(--color7);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color7);
}
}
&.replying {
& .input {
border-color: var(--color7);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color7);
}
}
&.viewPreview {
& .info {
&::after {
color: var(--color10);
}
}
}
&:global(.comment_highlighting) {
background: var(--color19);
}
}
.themeLight {
& .status {
color: var(--color11);
}
& .time {
color: var(--color11);
&:hover {
color: var(--color10);
}
}
& .username {
color: var(--color10);
}
&.editing {
& .input {
border-color: var(--color5);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color5);
}
}
&.replying {
& .input {
border-color: var(--color5);
}
@media (pointer: coarse) and (max-width: 768px) {
border-color: var(--color5);
}
}
&.viewPreview {
& .info {
&::after {
color: var(--color10);
}
}
}
&:global(.comment_highlighting) {
background: var(--color4);
}
}
@@ -1,6 +1,5 @@
import { h, JSX, Component, createRef, ComponentType } from 'preact';
import { FormattedMessage, IntlShape, defineMessages } from 'react-intl';
import b from 'bem-react-helper';
import clsx from 'clsx';
import { COMMENT_NODE_CLASSNAME_PREFIX } from 'common/constants';
@@ -283,7 +282,6 @@ export class Comment extends Component<CommentProps, State> {
render(props: CommentProps, state: State): JSX.Element {
const isAdmin = this.isAdmin();
const isGuest = this.isGuest();
const isCurrentUser = this.isCurrentUser();
const isReplying = props.editMode === CommentMode.Reply;
@@ -316,49 +314,58 @@ export class Comment extends Component<CommentProps, State> {
user: props.data.user,
};
const defaultMods = {
disabled: props.disabled,
pinned: props.data.pin,
// TODO: we also have critical_score, so we need to collapse comments with it in future
useless:
!!props.isUserBanned ||
!!props.data.delete ||
(props.view !== 'preview' &&
props.data.score < StaticStore.config.low_score &&
!props.data.pin &&
!props.disabled),
// TODO: add default view mod or don't?
guest: isGuest,
view: props.view === 'main' || props.view === 'pinned' ? props.data.user.admin && 'admin' : props.view,
replying: props.view === 'main' && isReplying,
editing: props.view === 'main' && isEditing,
theme: props.view === 'preview' ? undefined : props.theme,
level: props.level,
collapsed: props.collapsed,
};
// TODO: we also have critical_score, so we need to collapse comments with it in future
const isUseless =
!!props.isUserBanned ||
!!props.data.delete ||
(props.view !== 'preview' &&
props.data.score < StaticStore.config.low_score &&
!props.data.pin &&
!props.disabled);
let viewClass: string | undefined;
if (props.view === 'main' || props.view === 'pinned') {
viewClass = props.data.user.admin ? styles.viewAdmin : undefined;
} else if (props.view === 'user') {
viewClass = styles.viewUser;
} else if (props.view === 'preview') {
viewClass = styles.viewPreview;
}
const rootClassName = clsx(
styles.root,
props.view !== 'preview' && (props.theme === 'dark' ? styles.themeDark : styles.themeLight),
viewClass,
isUseless && styles.useless,
props.view === 'main' && isReplying && styles.replying,
props.view === 'main' && isEditing && styles.editing,
props.level === 6 && styles.level6,
props.collapsed && styles.collapsed,
props.mix
);
if (props.view === 'preview') {
return (
<article className={b('comment', { mix: props.mix }, defaultMods)}>
<div className="comment__body" dir="auto">
<article className={rootClassName}>
<div className={styles.body} dir="auto">
{!!o.title && (
<div className="comment__title">
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
<div className={styles.title}>
<a className={styles.titleLink} href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
{o.title}
</a>
</div>
)}
<div className="comment__info">
<div className={styles.info}>
{!!o.title && o.user.name}
{!o.title && (
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__username">
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className={styles.username}>
{o.user.name}
</a>
)}
</div>{' '}
<div
className="comment__text raw-content"
className={clsx(styles.text, 'raw-content')}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: o.text }}
/>
@@ -384,27 +391,24 @@ export class Comment extends Component<CommentProps, State> {
const goToParentMessage = intl.formatMessage(messages.goToParent);
return (
<article
className={b('comment', { mix: this.props.mix }, defaultMods)}
id={props.disabled ? undefined : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}
>
<article className={rootClassName} id={props.disabled ? undefined : `${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
{props.view === 'user' && o.title && (
<div className="comment__title">
<a className="comment__title-link" href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
<div className={styles.title}>
<a className={styles.titleLink} href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`}>
{o.title}
</a>
</div>
)}
<div className="comment__info">
<div className={styles.info}>
{props.view !== 'user' && !props.collapsed && (
<div className="comment__avatar">
<div className={styles.avatar}>
<Avatar url={o.user.picture} />
</div>
)}
<div className={styles.user}>
{props.view !== 'user' && (
<button onClick={() => this.toggleUserInfoVisibility()} className="comment__username">
<button onClick={() => this.toggleUserInfoVisibility()} className={styles.username}>
{o.user.name}
</button>
)}
@@ -433,7 +437,7 @@ export class Comment extends Component<CommentProps, State> {
)}
</div>
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className="comment__time">
<a href={`${o.locator.url}#${COMMENT_NODE_CLASSNAME_PREFIX}${o.id}`} className={styles.time}>
{getLocalDatetime(this.props.intl, o.time)}
</a>
@@ -454,13 +458,13 @@ export class Comment extends Component<CommentProps, State> {
)}
{props.isUserBanned && props.view !== 'user' && (
<span className="comment__status">
<span className={styles.status}>
<FormattedMessage id="comment.blocked-user" defaultMessage="Blocked" />
</span>
)}
{isAdmin && !props.isUserBanned && props.data.delete && (
<span className="comment__status">
<span className={styles.status}>
<FormattedMessage id="comment.deleted-user" defaultMessage="Deleted" />
</span>
)}
@@ -474,10 +478,10 @@ export class Comment extends Component<CommentProps, State> {
/>
)}
</div>
<div className="comment__body">
<div className={styles.body}>
{(!props.collapsed || props.view === 'pinned') && (
<div
className="comment__text raw-content"
className={clsx(styles.text, 'raw-content')}
ref={this.textNode}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: o.text }}
@@ -520,7 +524,7 @@ export class Comment extends Component<CommentProps, State> {
user={props.user}
theme={props.theme}
mode="reply"
mix="comment__input"
mix={styles.input}
onSubmit={(text: string, title: string) => this.addComment(text, title, o.id)}
onCancel={this.toggleReplying}
getPreview={this.props.getPreview!}
@@ -537,7 +541,7 @@ export class Comment extends Component<CommentProps, State> {
theme={props.theme}
value={o.orig}
mode="edit"
mix="comment__input"
mix={styles.input}
onSubmit={(text: string) => this.updateComment(props.data.id, text)}
onCancel={this.toggleEditing}
getPreview={this.props.getPreview!}
@@ -1,24 +1 @@
import './raw-content.css';
import './comment.css';
import './__body/comment__body.css';
import './__info/comment__info.css';
import './__input/comment__input.css';
import './__status/comment__status.css';
import './__text/comment__text.css';
import './__time/comment__time.css';
import './__user-id/comment__user-id.css';
import './__username/comment__username.css';
import './_collapsed/comment_collapsed.css';
import './_editing/comment_editing.css';
import './_replying/comment_replying.css';
import './_useless/comment_useless.css';
import './_view/_admin/comment_view_admin.css';
import './_view/_preview/comment_view_preview.css';
import './_view/_user/comment_view_user.css';
import './_theme/_dark/comment_theme_dark.css';
import './_theme/_light/comment_theme_light.css';
@@ -1,14 +0,0 @@
.dropdown__content {
position: absolute;
z-index: 20;
outline-width: 0;
display: none;
top: 100%;
left: 0;
transform: translate(-0.5em, 5px);
min-width: 120px;
max-width: 260px;
border: 2px solid var(--color15);
border-radius: 3px;
padding: 0 0 5px;
}
@@ -1,7 +1,7 @@
.root {
& > button,
& > a,
& > :global(.dropdown) {
& > [data-dropdown] {
display: inline-block;
text-align: left;
padding: 5px;
@@ -1,7 +0,0 @@
.dropdown__items {
padding: 5px 0;
}
.dropdown__items:last-child {
padding-bottom: 0;
}
@@ -1,6 +0,0 @@
.dropdown__title {
&::after {
content: '\25BE';
margin-left: 2px;
}
}
@@ -1,5 +0,0 @@
.dropdown_active {
& > .dropdown__content {
display: block;
}
}
@@ -1,5 +0,0 @@
.dropdown_theme_dark {
& .dropdown__content {
background-color: var(--color8);
}
}
@@ -1,5 +0,0 @@
.dropdown_theme_light {
& .dropdown__content {
background-color: var(--color6);
}
}
@@ -1,4 +0,0 @@
.dropdown {
display: inline-block;
position: relative;
}
@@ -0,0 +1,46 @@
.root {
display: inline-block;
position: relative;
}
.content {
position: absolute;
z-index: 20;
outline-width: 0;
display: none;
top: 100%;
left: 0;
transform: translate(-0.5em, 5px);
min-width: 120px;
max-width: 260px;
border: 2px solid var(--color15);
border-radius: 3px;
padding: 0 0 5px;
}
.items {
padding: 5px 0;
&:last-child {
padding-bottom: 0;
}
}
.title {
&::after {
content: '\25BE';
margin-left: 2px;
}
}
.active > .content {
display: block;
}
.themeDark > .content {
background-color: var(--color8);
}
.themeLight > .content {
background-color: var(--color6);
}
@@ -1,15 +1,16 @@
import { h, Component, createRef, RenderableProps } from 'preact';
import b from 'bem-react-helper';
import clsx from 'clsx';
import { Theme } from 'common/types';
import { sleep } from 'utils/sleep';
import { Button } from 'components/button';
import { parseMessage } from 'utils/post-message';
import styles from './dropdown.module.css';
type Props = RenderableProps<{
title: string;
titleClass?: string;
heading?: string;
isActive?: boolean;
disabled?: boolean;
buttonTitle?: string;
@@ -27,6 +28,7 @@ interface State {
export class Dropdown extends Component<Props, State> {
rootNode = createRef<HTMLDivElement>();
contentRef = createRef<HTMLDivElement>();
storedDocumentHeight: string | null = null;
storedDocumentHeightSet = false;
checkInterval: number | undefined = undefined;
@@ -75,7 +77,7 @@ export class Dropdown extends Component<Props, State> {
if (!this.rootNode.current) return false;
let parent = this.rootNode.current.parentElement!;
while (parent !== document.body) {
if (parent.classList.contains('dropdown')) return true;
if (parent.hasAttribute('data-dropdown')) return true;
parent = parent.parentElement!;
}
return false;
@@ -91,8 +93,7 @@ export class Dropdown extends Component<Props, State> {
if (!this.rootNode.current || !this.state.isActive) return;
const windowHeight = window.innerHeight;
const dcBottom = (() => {
// TODO: use ref
const dc = Array.from(this.rootNode.current.children).find((c) => c.classList.contains('dropdown__content'));
const dc = this.contentRef.current;
if (!dc) return 0;
const rect = dc.getBoundingClientRect();
return window.scrollY + Math.abs(rect.top) + dc.scrollHeight + 10;
@@ -114,8 +115,7 @@ export class Dropdown extends Component<Props, State> {
async __adjustDropDownContent() {
if (!this.rootNode.current) return;
// TODO: use ref
const dc = this.rootNode.current.querySelector<HTMLDivElement>('.dropdown__content');
const dc = this.contentRef.current;
if (!dc) return;
await sleep(0);
const rect = dc.getBoundingClientRect();
@@ -177,15 +177,24 @@ export class Dropdown extends Component<Props, State> {
window.removeEventListener('message', this.receiveMessage);
}
render({ title, titleClass = '', heading, children, mix, theme, disabled, buttonTitle }: Props, { isActive }: State) {
render({ title, titleClass = '', children, mix, theme, disabled, buttonTitle }: Props, { isActive }: State) {
return (
<div className={b('dropdown', { mix }, { theme, active: isActive })} ref={this.rootNode}>
<div
className={clsx(
styles.root,
isActive && styles.active,
theme === 'dark' ? styles.themeDark : styles.themeLight,
mix
)}
ref={this.rootNode}
data-dropdown
>
<Button
aria-haspopup="listbox"
aria-expanded={isActive && 'true'}
onClick={this.onTitleClick}
theme={theme}
mix={['dropdown__title', titleClass]}
mix={[styles.title, titleClass]}
kind="link"
disabled={disabled}
title={buttonTitle}
@@ -194,13 +203,13 @@ export class Dropdown extends Component<Props, State> {
</Button>
{isActive && (
<div
className="dropdown__content"
className={styles.content}
ref={this.contentRef}
tabIndex={-1}
role="listbox"
style={{ transform: `translateX(${this.state.contentTranslateX}px)` }}
>
{heading && <div className="dropdown__heading">{heading}</div>}
<div className="dropdown__items">{children}</div>
<div className={styles.items}>{children}</div>
</div>
)}
</div>
@@ -1,12 +1,2 @@
import './dropdown.css';
import './_active/dropdown_active.css';
import './__items/dropdown__items.css';
import './__title/dropdown__title.css';
import './__content/dropdown__content.css';
import './_theme/_dark/dropdown_theme_dark.css';
import './_theme/_light/dropdown_theme_light.css';
export { Dropdown } from './dropdown';
export { DropdownItem } from './__item';
@@ -1,5 +0,0 @@
.root__copyright {
margin: 48px 0 0;
font-size: 12px;
text-align: right;
}
@@ -1,3 +0,0 @@
.root__input {
margin-top: 4px;
}
@@ -1,7 +0,0 @@
.root__pinned-comment {
&:last-child {
margin-bottom: 0;
border-bottom: 0;
padding-bottom: 0;
}
}
@@ -1,5 +0,0 @@
.root__pinned-comments {
margin-top: 20px;
padding: 0 12px 12px;
border-radius: 2px;
}
@@ -1,6 +0,0 @@
.root__preloader {
position: absolute;
left: 0;
right: 0;
margin: 0 auto;
}
@@ -1,5 +0,0 @@
.root__thread {
&:last-child {
margin-bottom: -24px;
}
}
@@ -1,3 +0,0 @@
.root__threads {
margin-top: 4px;
}
@@ -1,19 +0,0 @@
.root_theme_dark {
color: var(--color20);
& .root__copyright {
color: var(--color1);
}
& .root__copyright-link {
color: var(--color1);
}
& .root__pinned-comment {
border-bottom-color: var(--color18);
}
& .root__pinned-comments {
background: var(--color19);
}
}
@@ -1,19 +0,0 @@
.root_theme_light {
color: var(--color0);
& .root__copyright {
color: var(--color1);
}
& .root__copyright-link {
color: var(--color1);
}
& .root__pinned-comment {
border-bottom-color: var(--color3);
}
& .root__pinned-comments {
background: var(--color4);
}
}
@@ -1,14 +1 @@
import './root.css';
import './__copyright/root__copyright.css';
import './__input/root__input.css';
import './__preloader/root__preloader.css';
import './__pinned-comment/root__pinned-comment.css';
import './__pinned-comments/root__pinned-comments.css';
import './__thread/root__thread.css';
import './__threads/root__threads.css';
import './_theme/_dark/root_theme_dark.css';
import './_theme/_light/root_theme_light.css';
export { Root, ConnectedRoot } from './root';
@@ -1,11 +0,0 @@
.root {
position: relative;
&::selection {
background: var(--color42);
}
}
.root_user-info {
position: static;
}
@@ -1,3 +1,52 @@
.root {
position: relative;
&::selection {
background: var(--color42);
}
}
.input {
margin-top: 4px;
}
.pinnedComment {
&:last-child {
margin-bottom: 0;
border-bottom: 0;
padding-bottom: 0;
}
}
.pinnedComments {
margin-top: 20px;
padding: 0 12px 12px;
border-radius: 2px;
}
.preloader {
position: absolute;
left: 0;
right: 0;
margin: 0 auto;
}
.thread {
&:last-child {
margin-bottom: -24px;
}
}
.threads {
margin-top: 4px;
}
.copyright {
margin: 48px 0 0;
font-size: 12px;
text-align: right;
}
.sortPicker {
display: flex;
justify-content: flex-end;
@@ -10,3 +59,43 @@
max-width: 320px;
margin: 20px auto;
}
.themeDark {
color: var(--color20);
& .copyright {
color: var(--color1);
}
& .copyrightLink {
color: var(--color1);
}
& .pinnedComment {
border-bottom-color: var(--color18);
}
& .pinnedComments {
background: var(--color19);
}
}
.themeLight {
color: var(--color0);
& .copyright {
color: var(--color1);
}
& .copyrightLink {
color: var(--color1);
}
& .pinnedComment {
border-bottom-color: var(--color3);
}
& .pinnedComments {
background: var(--color4);
}
}
@@ -1,7 +1,6 @@
import { h, Component, Fragment } from 'preact';
import { useEffect } from 'preact/hooks';
import { useSelector } from 'react-redux';
import b from 'bem-react-helper';
import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl';
import clsx from 'clsx';
@@ -201,7 +200,7 @@ export class Root extends Component<Props, State> {
render(props: Props, { isUserLoading, commentsShown, isSettingsVisible }: State) {
if (isUserLoading) {
return <Preloader className="root__preloader" />;
return <Preloader className={styles.preloader} />;
}
const isCommentsDisabled = props.info.read_only!;
@@ -219,7 +218,7 @@ export class Root extends Component<Props, State> {
onBlockedUsersHide={this.onBlockedUsersHide}
onCommentsChangeReadOnlyMode={this.props.setCommentsReadOnlyState}
/>
<div className="root__main">
<div>
{isSettingsVisible ? (
<Settings
intl={this.props.intl}
@@ -239,7 +238,7 @@ export class Root extends Component<Props, State> {
id={encodeURI(url || '')}
intl={this.props.intl}
theme={props.theme}
mix="root__input"
mix={styles.input}
mode="main"
user={props.user}
onSubmit={(text: string, title: string) => this.props.addComment(text, title)}
@@ -249,7 +248,7 @@ export class Root extends Component<Props, State> {
)}
{this.props.pinnedComments.length > 0 && (
<div
className="root__pinned-comments"
className={styles.pinnedComments}
role="region"
aria-label={this.props.intl.formatMessage(messages.pinnedComments)}
>
@@ -262,12 +261,12 @@ export class Root extends Component<Props, State> {
data={comment}
level={0}
disabled={true}
mix="root__pinned-comment"
mix={styles.pinnedComment}
/>
))}
</div>
)}
<div className={clsx('sort-picker', styles.sortPicker)}>
<div className={styles.sortPicker}>
<SortPicker />
</div>
<Comments
@@ -296,17 +295,17 @@ function Comments({ isLoading, topComments, commentsShown, showMore }: CommentsP
const isShowMoreButtonVisible = IS_MOBILE && commentsShown < topComments.length;
return (
<div className="root__threads" role="list">
<div className={styles.threads} role="list">
{isLoading ? (
<Preloader className="root__preloader" />
<Preloader className={styles.preloader} />
) : (
<>
{topComments.length > 0 &&
renderComments.map((id) => (
<Thread key={`thread-${id}`} id={id} mix="root__thread" level={0} getPreview={getPreview} />
<Thread key={`thread-${id}`} id={id} mix={styles.thread} level={0} getPreview={getPreview} />
))}
{isShowMoreButtonVisible && (
<Button className={clsx('more-comments', styles.moreComments)} onClick={showMore}>
<Button className={styles.moreComments} onClick={showMore}>
<FormattedMessage id="root.show-more" defaultMessage="Show more" />
</Button>
)}
@@ -317,7 +316,7 @@ function Comments({ isLoading, topComments, commentsShown, showMore }: CommentsP
}
const CopyrightLink = (title: string) => (
<a class="root__copyright-link" href="https://remark42.com/">
<a class={styles.copyrightLink} href="https://remark42.com/">
{title}
</a>
);
@@ -337,10 +336,10 @@ export function ConnectedRoot() {
}, []);
return (
<div className={clsx(b('root', {}, { theme: props.theme }), props.theme)}>
<div className={clsx(styles.root, props.theme === 'dark' ? styles.themeDark : styles.themeLight, props.theme)}>
<Root {...props} {...actions} intl={intl} />
{!noFooter && (
<p className="root__copyright" role="contentinfo">
<p className={styles.copyright} role="contentinfo">
<FormattedMessage
id="root.powered-by"
defaultMessage="Powered by <a>Remark42</a>"
@@ -1,11 +0,0 @@
.thread_theme_dark {
& .thread__collapse {
&::after {
border-color: var(--color36);
}
&:hover::after {
border-color: var(--color6);
}
}
}
@@ -1,6 +1 @@
import './thread.css';
import './_theme_dark/thread_theme_dark.css';
import './__collapse/thread__collapse.css';
export { Thread } from './thread';

Some files were not shown because too many files have changed in this diff Show More