Align and standardize settings data source list layout

Aligns the "Data source" list items in the side panel by resolving a
browser-specific layout discrepancy where icons with different intrinsic
widths (e.g., "edit" vs "calendar") caused uneven text indentation and
vertical misalignment.

- Refactored `SourceHead` in `head.tsx` to wrap all list icons in a
  `list-icon-wrapper` container instead of using `<List.Icon>` directly.
- Added CSS rules in `index.css` to format `.list-icon-wrapper` and its
  sibling `.content` as table cells aligned at the top, with a fixed
  width of `20px` for the icon column.
- Converted relative sizing (`em`) to absolute pixels (`px`) for
  pixel-perfect cross-browser precision.
- Updated Playwright visual regression snapshots to match the newly
  aligned and corrected UI layout.
This commit is contained in:
Przemek Więch
2026-06-25 14:31:25 +02:00
parent 9cb57d1f49
commit 3effe501b7
9 changed files with 335 additions and 297 deletions

View File

@@ -154,6 +154,28 @@ div.zoom {
padding: 0 15px 0 25px; padding: 0 15px 0 25px;
} }
.details .ui.list .item > .list-icon-wrapper {
display: table-cell;
width: 20px;
text-align: center;
vertical-align: top;
padding-right: 4px;
}
.details .ui.list .item > .list-icon-wrapper > i.icon {
margin: 0;
}
.details .ui.list .item > .list-icon-wrapper + .content {
display: table-cell;
vertical-align: top;
padding-left: 8px;
}
.details .item-header { .details .item-header {
justify-content: space-between; justify-content: space-between;
display: flex; display: flex;

View File

@@ -118,247 +118,245 @@ export function ConfigPanel(props: {
onChange: (config: Config) => void; onChange: (config: Config) => void;
}) { }) {
return ( return (
<> <Form className="details">
{SourceHead(props.gedcom)} <Item.Group>
<Form className="details"> {SourceHead(props.gedcom)}
<Item.Group> <Item>
<Item> <Item.Content>
<Item.Content> <Header sub>
<Header sub> <FormattedMessage id="config.colors" defaultMessage="Colors" />
<FormattedMessage id="config.colors" defaultMessage="Colors" /> </Header>
</Header> <Form.Field className="no-margin">
<Form.Field className="no-margin"> <Checkbox
<Checkbox radio
radio label={
label={ <FormattedMessage
<FormattedMessage tagName="label"
tagName="label" id="config.colors.NO_COLOR"
id="config.colors.NO_COLOR" defaultMessage="none"
defaultMessage="none"
/>
}
name="checkboxRadioGroup"
value="none"
checked={props.config.color === ChartColors.NO_COLOR}
onClick={() =>
props.onChange({
...props.config,
color: ChartColors.NO_COLOR,
})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.colors.COLOR_BY_GENERATION"
defaultMessage="by generation"
/>
}
name="checkboxRadioGroup"
value="generation"
checked={
props.config.color === ChartColors.COLOR_BY_GENERATION
}
onClick={() =>
props.onChange({
...props.config,
color: ChartColors.COLOR_BY_GENERATION,
})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.colors.COLOR_BY_SEX"
defaultMessage="by sex"
/>
}
name="checkboxRadioGroup"
value="gender"
checked={props.config.color === ChartColors.COLOR_BY_SEX}
onClick={() =>
props.onChange({
...props.config,
color: ChartColors.COLOR_BY_SEX,
})
}
/>
</Form.Field>
</Item.Content>
</Item>
<Item>
<Item.Content>
<Header sub>
<FormattedMessage id="config.ids" defaultMessage="IDs" />
</Header>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.ids.HIDE"
defaultMessage="hide"
/>
}
name="checkboxRadioGroup"
value="hide"
checked={props.config.id === Ids.HIDE}
onClick={() =>
props.onChange({...props.config, id: Ids.HIDE})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.ids.SHOW"
defaultMessage="show"
/>
}
name="checkboxRadioGroup"
value="show"
checked={props.config.id === Ids.SHOW}
onClick={() =>
props.onChange({...props.config, id: Ids.SHOW})
}
/>
</Form.Field>
</Item.Content>
</Item>
<Item>
<Item.Content>
<Header sub>
<FormattedMessage id="config.sex" defaultMessage="Sex" />
</Header>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.sex.HIDE"
defaultMessage="hide"
/>
}
name="checkboxRadioGroup"
value="hide"
checked={props.config.sex === Sex.HIDE}
onClick={() =>
props.onChange({...props.config, sex: Sex.HIDE})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.sex.SHOW"
defaultMessage="show"
/>
}
name="checkboxRadioGroup"
value="show"
checked={props.config.sex === Sex.SHOW}
onClick={() =>
props.onChange({...props.config, sex: Sex.SHOW})
}
/>
</Form.Field>
</Item.Content>
</Item>
<Item>
<Item.Content>
<Header sub>
<FormattedMessage id="config.places" defaultMessage="Places" />
</Header>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.places.HIDE"
defaultMessage="hide"
/>
}
name="checkboxRadioGroup"
value="hide"
checked={props.config.place === PlaceDisplay.HIDE}
onClick={() =>
props.onChange({...props.config, place: PlaceDisplay.HIDE})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.places.SHORT"
defaultMessage="short"
/>
}
name="checkboxRadioGroup"
value="short"
checked={props.config.place === PlaceDisplay.SHORT}
onClick={() =>
props.onChange({...props.config, place: PlaceDisplay.SHORT})
}
/>
{props.config.place === PlaceDisplay.SHORT && (
<Input
type="number"
min={1}
max={10}
size="mini"
style={{width: '4em', marginLeft: '1.5em'}}
value={props.config.placeCount}
onChange={(_e, {value}) => {
const n = parseInt(value, 10);
if (n >= 1) {
props.onChange({...props.config, placeCount: n});
}
}}
/> />
)} }
</Form.Field> name="checkboxRadioGroup"
<Form.Field className="no-margin"> value="none"
<Checkbox checked={props.config.color === ChartColors.NO_COLOR}
radio onClick={() =>
label={ props.onChange({
<FormattedMessage ...props.config,
tagName="label" color: ChartColors.NO_COLOR,
id="config.places.FULL" })
defaultMessage="full" }
/> />
} </Form.Field>
name="checkboxRadioGroup" <Form.Field className="no-margin">
value="full" <Checkbox
checked={props.config.place === PlaceDisplay.FULL} radio
onClick={() => label={
props.onChange({...props.config, place: PlaceDisplay.FULL}) <FormattedMessage
} tagName="label"
id="config.colors.COLOR_BY_GENERATION"
defaultMessage="by generation"
/>
}
name="checkboxRadioGroup"
value="generation"
checked={
props.config.color === ChartColors.COLOR_BY_GENERATION
}
onClick={() =>
props.onChange({
...props.config,
color: ChartColors.COLOR_BY_GENERATION,
})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.colors.COLOR_BY_SEX"
defaultMessage="by sex"
/>
}
name="checkboxRadioGroup"
value="gender"
checked={props.config.color === ChartColors.COLOR_BY_SEX}
onClick={() =>
props.onChange({
...props.config,
color: ChartColors.COLOR_BY_SEX,
})
}
/>
</Form.Field>
</Item.Content>
</Item>
<Item>
<Item.Content>
<Header sub>
<FormattedMessage id="config.ids" defaultMessage="IDs" />
</Header>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.ids.HIDE"
defaultMessage="hide"
/>
}
name="checkboxRadioGroup"
value="hide"
checked={props.config.id === Ids.HIDE}
onClick={() =>
props.onChange({...props.config, id: Ids.HIDE})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.ids.SHOW"
defaultMessage="show"
/>
}
name="checkboxRadioGroup"
value="show"
checked={props.config.id === Ids.SHOW}
onClick={() =>
props.onChange({...props.config, id: Ids.SHOW})
}
/>
</Form.Field>
</Item.Content>
</Item>
<Item>
<Item.Content>
<Header sub>
<FormattedMessage id="config.sex" defaultMessage="Sex" />
</Header>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.sex.HIDE"
defaultMessage="hide"
/>
}
name="checkboxRadioGroup"
value="hide"
checked={props.config.sex === Sex.HIDE}
onClick={() =>
props.onChange({...props.config, sex: Sex.HIDE})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.sex.SHOW"
defaultMessage="show"
/>
}
name="checkboxRadioGroup"
value="show"
checked={props.config.sex === Sex.SHOW}
onClick={() =>
props.onChange({...props.config, sex: Sex.SHOW})
}
/>
</Form.Field>
</Item.Content>
</Item>
<Item>
<Item.Content>
<Header sub>
<FormattedMessage id="config.places" defaultMessage="Places" />
</Header>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.places.HIDE"
defaultMessage="hide"
/>
}
name="checkboxRadioGroup"
value="hide"
checked={props.config.place === PlaceDisplay.HIDE}
onClick={() =>
props.onChange({...props.config, place: PlaceDisplay.HIDE})
}
/>
</Form.Field>
<Form.Field className="no-margin">
<Checkbox
radio
label={
<FormattedMessage
tagName="label"
id="config.places.SHORT"
defaultMessage="short"
/>
}
name="checkboxRadioGroup"
value="short"
checked={props.config.place === PlaceDisplay.SHORT}
onClick={() =>
props.onChange({...props.config, place: PlaceDisplay.SHORT})
}
/>
{props.config.place === PlaceDisplay.SHORT && (
<Input
type="number"
min={1}
max={10}
size="mini"
style={{width: '4em', marginLeft: '1.5em'}}
value={props.config.placeCount}
onChange={(_e, {value}) => {
const n = parseInt(value, 10);
if (n >= 1) {
props.onChange({...props.config, placeCount: n});
}
}}
/> />
</Form.Field> )}
</Item.Content> </Form.Field>
</Item> <Form.Field className="no-margin">
</Item.Group> <Checkbox
</Form> radio
</> label={
<FormattedMessage
tagName="label"
id="config.places.FULL"
defaultMessage="full"
/>
}
name="checkboxRadioGroup"
value="full"
checked={props.config.place === PlaceDisplay.FULL}
onClick={() =>
props.onChange({...props.config, place: PlaceDisplay.FULL})
}
/>
</Form.Field>
</Item.Content>
</Item>
</Item.Group>
</Form>
); );
} }

View File

@@ -1,5 +1,5 @@
import {FormattedMessage, useIntl} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
import {Divider, Header, List} from 'semantic-ui-react'; import {Divider, Header, Icon, Item, List} from 'semantic-ui-react';
import {getDate} from 'topola'; import {getDate} from 'topola';
import {formatDateOrRange} from '../../util/date_util'; import {formatDateOrRange} from '../../util/date_util';
import {dereference, GedcomData} from '../../util/gedcom_util'; import {dereference, GedcomData} from '../../util/gedcom_util';
@@ -66,63 +66,81 @@ export function SourceHead(gedcom: GedcomData) {
// Icons: https://react.semantic-ui.com/elements/icon/ // Icons: https://react.semantic-ui.com/elements/icon/
return ( return (
<> <Item>
<Header sub> <Item.Content>
<FormattedMessage id="head.source" defaultMessage="Data source" /> <Header sub>
</Header> <FormattedMessage id="head.source" defaultMessage="Data source" />
<List> </Header>
{sour_name && ( <List>
<List.Item> {sour_name && (
<List.Icon name="edit" /> <List.Item>
<List.Content>{sour_name}</List.Content> <div className="list-icon-wrapper">
</List.Item> <Icon name="edit" />
)} </div>
<List.Content>{sour_name}</List.Content>
</List.Item>
)}
{date && ( {date && (
<List.Item> <List.Item>
<List.Icon name="calendar" /> <div className="list-icon-wrapper">
<List.Content>{dateFormatted}</List.Content> <Icon name="calendar" />
</List.Item> </div>
)} <List.Content>{dateFormatted}</List.Content>
{file && ( </List.Item>
<List.Item> )}
<List.Icon name="file" /> {file && (
<List.Content>{filename}</List.Content> <List.Item>
</List.Item> <div className="list-icon-wrapper">
)} <Icon name="file" />
{name && ( </div>
<List.Item> <List.Content>{filename}</List.Content>
<List.Icon name="user" /> </List.Item>
<List.Content>{name}</List.Content> )}
</List.Item> {name && (
)} <List.Item>
{adr1 && ( <div className="list-icon-wrapper">
<List.Item> <Icon name="user" />
<List.Icon name="marker" /> </div>
<List.Content>{location}</List.Content> <List.Content>{name}</List.Content>
</List.Item> </List.Item>
)} )}
{phon && ( {adr1 && (
<List.Item> <List.Item>
<List.Icon name="phone" /> <div className="list-icon-wrapper">
<List.Content>{phon}</List.Content> <Icon name="marker" />
</List.Item> </div>
)} <List.Content>{location}</List.Content>
{email && ( </List.Item>
<List.Item> )}
<List.Icon name="mail" /> {phon && (
<List.Content>{email}</List.Content> <List.Item>
</List.Item> <div className="list-icon-wrapper">
)} <Icon name="phone" />
{copr && ( </div>
<List.Item> <List.Content>{phon}</List.Content>
<List.Icon name="copyright" /> </List.Item>
<List.Content>{copr}</List.Content> )}
</List.Item> {email && (
)} <List.Item>
</List> <div className="list-icon-wrapper">
<Icon name="mail" />
</div>
<List.Content>{email}</List.Content>
</List.Item>
)}
{copr && (
<List.Item>
<div className="list-icon-wrapper">
<Icon name="copyright" />
</div>
<List.Content>{copr}</List.Content>
</List.Item>
)}
</List>
<Divider /> <Divider />
</> </Item.Content>
</Item>
); );
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB