diff --git a/packages/@ourworldindata/components/src/Checkbox.scss b/packages/@ourworldindata/components/src/Checkbox.scss index 4072cc34d0e..422e6e98b5c 100644 --- a/packages/@ourworldindata/components/src/Checkbox.scss +++ b/packages/@ourworldindata/components/src/Checkbox.scss @@ -3,7 +3,7 @@ $light-stroke: #dadada; $hover-stroke: #a4b6ca; - $active-fill: #dbe5f0; + $active-fill: #a4b6ca; position: relative; @@ -27,7 +27,7 @@ pointer-events: none; border-radius: 2px; border: 1px solid $light-stroke; - color: $dark-text; + color: #fff; display: flex; align-items: center; @@ -36,7 +36,6 @@ svg { font-size: 10px; padding-left: 0.75px; - color: $active-text; } } diff --git a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts index 3c9199a295c..11890352ef1 100644 --- a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts +++ b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts @@ -76,6 +76,10 @@ export abstract class AbstractCoreColumn { return this instanceof MissingColumn } + @imemo get hasNumberFormatting(): boolean { + return this instanceof AbstractColumnWithNumberFormatting + } + get sum(): number | undefined { return this.summary.sum } diff --git a/packages/@ourworldindata/grapher/src/controls/Dropdown.scss b/packages/@ourworldindata/grapher/src/controls/Dropdown.scss new file mode 100644 index 00000000000..9174c638314 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/Dropdown.scss @@ -0,0 +1,92 @@ +.grapher-dropdown { + $option-checkmark: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIiIGhlaWdodD0iOSIgdmlld0JveD0iMCAwIDEyIDkiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGQ9Ik0xMS4wMTU2IDAuOTg0Mzc1QzExLjMyMDMgMS4yNjU2MiAxMS4zMjAzIDEuNzU3ODEgMTEuMDE1NiAyLjAzOTA2TDUuMDE1NjIgOC4wMzkwNkM0LjczNDM4IDguMzQzNzUgNC4yNDIxOSA4LjM0Mzc1IDMuOTYwOTQgOC4wMzkwNkwwLjk2MDkzOCA1LjAzOTA2QzAuNjU2MjUgNC43NTc4MSAwLjY1NjI1IDQuMjY1NjIgMC45NjA5MzggMy45ODQzOEMxLjI0MjE5IDMuNjc5NjkgMS43MzQzOCAzLjY3OTY5IDIuMDE1NjIgMy45ODQzOEw0LjQ3NjU2IDYuNDQ1MzFMOS45NjA5NCAwLjk4NDM3NUMxMC4yNDIyIDAuNjc5Njg4IDEwLjczNDQgMC42Nzk2ODggMTEuMDE1NiAwLjk4NDM3NVoiIGZpbGw9IiMxRDNENjMiLz4KPC9zdmc+"; + $menu-caret-up: "data:image/svg+xml;base64, PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI1IiB2aWV3Qm94PSIwIDAgOCA1IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMC40NjA5MzggMy43MzQzOEwzLjQzNzUgMC43MzQzNzVDMy42MDE1NiAwLjU5Mzc1IDMuNzg5MDYgMC41IDQgMC41QzQuMTg3NSAwLjUgNC4zNzUgMC41OTM3NSA0LjUxNTYyIDAuNzM0Mzc1TDcuNDkyMTkgMy43MzQzOEM3LjcwMzEyIDMuOTQ1MzEgNy43NzM0NCA0LjI3MzQ0IDcuNjU2MjUgNC41NTQ2OUM3LjUzOTA2IDQuODM1OTQgNy4yODEyNSA1IDYuOTc2NTYgNUgxQzAuNjk1MzEyIDUgMC40MTQwNjIgNC44MzU5NCAwLjI5Njg3NSA0LjU1NDY5QzAuMTc5Njg4IDQuMjczNDQgMC4yNSAzLjk0NTMxIDAuNDYwOTM4IDMuNzM0MzhaIiBmaWxsPSIjNUI1QjVCIi8+Cjwvc3ZnPg=="; + $menu-caret-down: "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iOCIgaGVpZ2h0PSI1IiB2aWV3Qm94PSIwIDAgOCA1IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNNy41MTU2MiAxLjI4OTA2TDQuNTM5MDYgNC4yODkwNkM0LjM3NSA0LjQyOTY5IDQuMTg3NSA0LjUgNCA0LjVDMy43ODkwNiA0LjUgMy42MDE1NiA0LjQyOTY5IDMuNDYwOTQgNC4yODkwNkwwLjQ4NDM3NSAxLjI4OTA2QzAuMjUgMS4wNzgxMiAwLjE3OTY4OCAwLjc1IDAuMjk2ODc1IDAuNDY4NzVDMC40MTQwNjIgMC4xODc1IDAuNjk1MzEyIDAgMSAwSDYuOTc2NTZDNy4yODEyNSAwIDcuNTM5MDYgMC4xODc1IDcuNjU2MjUgMC40Njg3NUM3Ljc3MzQ0IDAuNzUgNy43MjY1NiAxLjA3ODEyIDcuNTE1NjIgMS4yODkwNloiIGZpbGw9IiM1QjVCNUIiLz4KPC9zdmc+Cg=="; + + $medium: 400; + $lato: $sans-serif-font-stack; + + $light-stroke: #e7e7e7; + $active-stroke: #a4b6ca; + + $active-fill: #dbe5f0; + $hover-fill: #f2f2f2; + $selected-fill: #c7ced7; + + $light-text: #858585; + + font: $medium 13px/16px $lato; + + // fixes a bug in Firefox where long labels would cause the dropdown to resize, + // see https://github.com/JedWatson/react-select/issues/5170 + display: grid; + grid-template-columns: minmax(0, 1fr); + + .control { + min-height: auto; + font: $medium 13px/16px $lato; + letter-spacing: 0.01em; + display: flex; + align-items: center; + border: 1px solid $light-stroke; + border-radius: 4px; + padding: 7px; + color: $dark-text; + + &:hover { + background: $hover-fill; + cursor: pointer; + } + + &:after { + content: " "; + background: url($menu-caret-down) no-repeat center; + width: 16px; + height: 16px; + } + + &.active { + border-color: $active-stroke; + &:after { + background: url($menu-caret-up) no-repeat center; + } + } + } + + .menu { + margin-top: 3px; + border-radius: 4px; + background: white; + box-shadow: 0px 4px 40px 0px rgba(0, 0, 0, 0.15); + z-index: $zindex-controls-popup; + color: $dark-text; + + .option { + padding: 8px 18px; + &:hover { + cursor: pointer; + background: $hover-fill; + } + &:active, + &.active { + color: $active-text; + background: $active-fill; + } + &.active { + position: relative; + &:hover { + background: $selected-fill; + } + &:after { + content: " "; + background: url($option-checkmark) no-repeat; + width: 12px; + height: 9px; + position: absolute; + right: 18px; + bottom: 11px; + } + } + } + } +} diff --git a/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx b/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx new file mode 100644 index 00000000000..ec3b019f3b9 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/controls/Dropdown.tsx @@ -0,0 +1,26 @@ +import React from "react" +import Select, { Props } from "react-select" + +export function Dropdown(props: Props): React.JSX.Element { + return ( + - state.menuIsOpen ? "active control" : "control", - option: (state) => - state.isSelected ? "active option" : "option", - menu: () => "menu", - }} /> ) : null diff --git a/packages/@ourworldindata/grapher/src/core/grapher.scss b/packages/@ourworldindata/grapher/src/core/grapher.scss index d9063ac2a7d..4f31c86febb 100644 --- a/packages/@ourworldindata/grapher/src/core/grapher.scss +++ b/packages/@ourworldindata/grapher/src/core/grapher.scss @@ -88,6 +88,7 @@ $zindex-controls-drawer: 150; @import "../../../components/src/Checkbox.scss"; @import "../closeButton/CloseButton.scss"; @import "../controls/RadioButton.scss"; +@import "../controls/Dropdown.scss"; .grapher_dark { color: $dark-text; diff --git a/packages/@ourworldindata/grapher/src/core/typography.scss b/packages/@ourworldindata/grapher/src/core/typography.scss index 149e3b11d66..0ba748d14c4 100644 --- a/packages/@ourworldindata/grapher/src/core/typography.scss +++ b/packages/@ourworldindata/grapher/src/core/typography.scss @@ -134,3 +134,12 @@ .grapher_label-2-regular { @include grapher_label-2-regular; } + +@mixin grapher_label-2-medium { + @include grapher_label-2-regular; + font-weight: 500; +} + +.grapher_label-2-medium { + @include grapher_label-2-medium; +} diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss index e9b7eb7c3f8..383bb11bd6a 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.scss @@ -17,7 +17,66 @@ background-color: #fff; z-index: $zindex-header; padding: 0 var(--padding) 8px var(--padding); + margin-bottom: 8px; + } + + .entity-selector__sort-bar { + padding: 0 var(--padding); + display: flex; + align-items: center; margin-bottom: 16px; + + .grapher-dropdown { + flex-grow: 1; + } + + .label { + flex-shrink: 0; + margin-right: 8px; + color: $dark-text; + } + + button.sort { + flex-shrink: 0; + margin-left: 16px; + + $size: 32px; + + display: flex; + align-items: center; + justify-content: center; + + position: relative; + height: $size; + width: $size; + padding: 7px; + + color: $dark-text; + background: #f2f2f2; + border: none; + border-radius: 4px; + + svg { + height: 14px; + width: 14px; + } + + &:hover:not(:disabled) { + background: #e7e7e7; + cursor: pointer; + } + + &:active:not(:disabled) { + color: #1d3d63; + background: #dbe5f0; + border: 1px solid #dbe5f0; + } + + &:disabled { + background: #f2f2f2; + color: #a1a1a1; + } + } } .entity-selector__footer { @@ -74,7 +133,7 @@ } .entity-selector__content { - margin: -8px var(--padding) ($footer-height + 8px) var(--padding); + margin: 0 var(--padding) ($footer-height + 8px) var(--padding); } &.entity-selector--single { @@ -83,19 +142,36 @@ } } - .entity-search-results { + .entity-section + .entity-section { margin-top: 16px; } - .section-title { + .entity-section__title { letter-spacing: 0.01em; - margin-top: 16px; margin-bottom: 8px; } .selectable-entity { padding: 9px 8px 9px 16px; + display: flex; + justify-content: space-between; + position: relative; cursor: pointer; + + .value { + color: #a1a1a1; + white-space: nowrap; + margin-left: 8px; + } + + .bar { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: #ebeef2; + z-index: -1; + } } .animated-entity { diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index 28e0601746c..217dbaafd05 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -5,9 +5,15 @@ import cx from "classnames" import a from "indefinite" import { isTouchDevice, - sortBy, partition, capitalize, + SortOrder, + orderBy, + keyBy, + isFiniteWithGuard, + CoreValueType, + clamp, + maxBy, } from "@ourworldindata/utils" import { Checkbox } from "@ourworldindata/components" import { FuzzySearch } from "../controls/FuzzySearch" @@ -26,27 +32,46 @@ import { GRAPHER_ENTITY_SELECTOR_CLASS, GRAPHER_SCROLLABLE_CONTAINER_CLASS, } from "../core/GrapherConstants" +import { CoreColumn, OwidTable } from "@ourworldindata/core-table" +import { SortIcon } from "../controls/SortIcon" +import { Dropdown } from "../controls/Dropdown" +import { scaleLinear, type ScaleLinear } from "d3-scale" +import { ColumnSlug } from "@ourworldindata/types" export interface EntitySelectorState { searchInput: string + sortConfig: SortConfig mostRecentlySelectedEntityName?: string } export interface EntitySelectorManager { entitySelectorState: Partial + tableForSelection: OwidTable selection: SelectionArray canChangeEntity: boolean entityType?: string entityTypePlural?: string + activeColumnSlugs?: string[] } -interface SearchableEntity { - name: string +interface SortConfig { + slug: ColumnSlug + order: SortOrder } +type SearchableEntity = { name: string } & Record< + ColumnSlug, + CoreValueType | undefined +> + interface PartitionedEntities { - selected: string[] - unselected: string[] + selected: SearchableEntity[] + unselected: SearchableEntity[] +} + +interface DropdownOption { + value: string + label: string } @observer @@ -58,6 +83,11 @@ export class EntitySelector extends React.Component<{ container: React.RefObject = React.createRef() searchField: React.RefObject = React.createRef() + private defaultSortConfig = { + slug: this.table.entityNameSlug, + order: SortOrder.asc, + } + componentDidMount(): void { if (this.props.autoFocus && !isTouchDevice()) this.searchField.current?.focus() @@ -75,10 +105,31 @@ export class EntitySelector extends React.Component<{ ) } - private set(state: Partial): void { + private set(newState: Partial): void { + const correctedState = { ...newState } + + if (newState.sortConfig !== undefined) { + const correctedSortConfig = { ...newState.sortConfig } + + const shouldBeSortedByName = + newState.sortConfig.slug === this.table.entityNameSlug + + // sort names in ascending order by default + if (shouldBeSortedByName && !this.isSortedByName) { + correctedSortConfig.order = SortOrder.asc + } + + // sort values in descending order by default + if (!shouldBeSortedByName && this.isSortedByName) { + correctedSortConfig.order = SortOrder.desc + } + + correctedState.sortConfig = correctedSortConfig + } + this.manager.entitySelectorState = { ...this.manager.entitySelectorState, - ...state, + ...correctedState, } } @@ -86,6 +137,28 @@ export class EntitySelector extends React.Component<{ this.set({ searchInput: "" }) } + private updateSortSlug(newSlug: ColumnSlug) { + this.set({ + sortConfig: { + slug: newSlug, + order: this.sortConfig.order, + }, + }) + } + + private toggleSortOrder() { + const newOrder = + this.sortConfig.order === SortOrder.asc + ? SortOrder.desc + : SortOrder.asc + this.set({ + sortConfig: { + slug: this.sortConfig.slug, + order: newOrder, + }, + }) + } + @computed private get manager(): EntitySelectorManager { return this.props.manager } @@ -98,6 +171,65 @@ export class EntitySelector extends React.Component<{ return this.manager.entitySelectorState.mostRecentlySelectedEntityName } + @computed private get sortConfig(): SortConfig { + return ( + this.manager.entitySelectorState.sortConfig ?? + this.defaultSortConfig + ) + } + + @computed private get table(): OwidTable { + return this.manager.tableForSelection + } + + @computed private get sortColumns(): CoreColumn[] { + const activeSlugs = this.manager.activeColumnSlugs ?? [] + + const sortColumns = activeSlugs + .map((slug) => this.table.get(slug)) + .filter( + (column) => column.hasNumberFormatting && !column.isProjection + ) + + return [this.table.entityNameColumn, ...sortColumns] + } + + @computed private get sortColumnsBySlug(): Record { + return keyBy(this.sortColumns, (column: CoreColumn) => column.slug) + } + + @computed get sortOptions(): DropdownOption[] { + const options: DropdownOption[] = [] + + const getDropdownLabel = (column: CoreColumn): string => { + if (column.slug === this.table.entityNameSlug) return "Name" + return column.titlePublicOrDisplayName.title + } + + options.push( + ...this.sortColumns.map((column) => { + return { + value: column.slug, + label: getDropdownLabel(column), + } + }) + ) + + return options + } + + @computed get sortValue(): DropdownOption | null { + return ( + this.sortOptions.find( + (option) => option.value === this.sortConfig.slug + ) ?? null + ) + } + + @computed private get isSortedByName(): boolean { + return this.sortConfig.slug === this.table.entityNameSlug + } + @computed private get entityType(): string { return this.manager.entityType || DEFAULT_GRAPHER_ENTITY_TYPE } @@ -112,12 +244,63 @@ export class EntitySelector extends React.Component<{ return makeSelectionArray(this.manager.selection) } - @computed get sortedAvailableEntities(): string[] { - return sortBy(this.selectionArray.availableEntityNames) + @computed private get availableEntityNames(): string[] { + return this.table.availableEntityNames + } + + @computed private get availableEntities(): SearchableEntity[] { + return this.availableEntityNames.map((entityName) => { + const searchableEntity: SearchableEntity = { name: entityName } + + for (const column of this.sortColumns) { + const rows = column.owidRowsByEntityName.get(entityName) ?? [] + searchableEntity[column.slug] = maxBy( + rows, + (row) => row.time + )?.value + } + + return searchableEntity + }) + } + + private sortEntities(entities: SearchableEntity[]): SearchableEntity[] { + const { sortConfig } = this + + const shouldBeSortedByName = + sortConfig.slug === this.table.entityNameSlug + + // sort by name + if (shouldBeSortedByName) { + return orderBy( + entities, + (entity: SearchableEntity) => entity.name, + sortConfig.order + ) + } + + // sort by number column, with missing values at the end + const [withValues, withoutValues] = partition( + entities, + (entity: SearchableEntity) => + isFiniteWithGuard(entity[sortConfig.slug]) + ) + const sortedEntitiesWithValues = orderBy( + withValues, + (entity: SearchableEntity) => entity[sortConfig.slug], + sortConfig.order + ) + const sortedEntitiesWithoutValues = orderBy( + withoutValues, + (entity: SearchableEntity) => entity.name, + SortOrder.asc + ) + + return [...sortedEntitiesWithValues, ...sortedEntitiesWithoutValues] } - @computed private get searchableEntities(): SearchableEntity[] { - return this.sortedAvailableEntities.map((name) => ({ name })) + @computed private get sortedAvailableEntities(): SearchableEntity[] { + return this.sortEntities(this.availableEntities) } @computed get isMultiMode(): boolean { @@ -125,13 +308,12 @@ export class EntitySelector extends React.Component<{ } @computed get fuzzy(): FuzzySearch { - return new FuzzySearch(this.searchableEntities, "name") + return new FuzzySearch(this.sortedAvailableEntities, "name") } @computed get searchResults(): SearchableEntity[] | undefined { - return this.searchInput - ? this.fuzzy.search(this.searchInput) - : undefined + if (!this.searchInput) return undefined + return this.fuzzy.search(this.searchInput) } @computed get partitionedSearchResults(): PartitionedEntities | undefined { @@ -140,8 +322,8 @@ export class EntitySelector extends React.Component<{ if (!searchResults) return undefined const [selected, unselected] = partition( - searchResults.map((entity) => entity.name), - (name) => this.isEntitySelected(name) + searchResults, + (entity: SearchableEntity) => this.isEntitySelected(entity) ) return { selected, unselected } @@ -150,10 +332,13 @@ export class EntitySelector extends React.Component<{ @computed get partitionedAvailableEntities(): PartitionedEntities { const [selected, unselected] = partition( this.sortedAvailableEntities, - (name) => this.isEntitySelected(name) + (entity: SearchableEntity) => this.isEntitySelected(entity) ) - return { selected, unselected } + return { + selected: this.sortEntities(selected), + unselected: this.sortEntities(unselected), + } } @computed get partitionedVisibleEntities(): PartitionedEntities { @@ -184,14 +369,28 @@ export class EntitySelector extends React.Component<{ } @action.bound onClear(): void { - const { partitionedSearchResults: searchResults } = this + const { partitionedSearchResults } = this if (this.searchInput) { - this.selectionArray.deselectEntities(searchResults?.selected ?? []) + const { selected = [] } = partitionedSearchResults ?? {} + this.selectionArray.deselectEntities( + selected.map((entity) => entity.name) + ) } else { this.selectionArray.clearSelection() } } + @action.bound onChangeSortSlug(selected: unknown): void { + if (selected) { + const selectedOption = selected as DropdownOption + this.updateSortSlug(selectedOption.value) + } + } + + @action.bound onChangeSortOrder(): void { + this.toggleSortOrder() + } + private renderSearchBar(): JSX.Element { return (
@@ -230,6 +429,29 @@ export class EntitySelector extends React.Component<{ ) } + private renderSortBar(): JSX.Element { + return ( +
+ Sort by + + +
+ ) + } + private renderSearchResults(): JSX.Element { if (!this.searchResults || this.searchResults.length === 0) { return ( @@ -243,13 +465,14 @@ export class EntitySelector extends React.Component<{ return (
    - {this.searchResults.map(({ name }) => ( -
  • + {this.searchResults.map((entity) => ( +
  • this.onChange(name)} + checked={this.isEntitySelected(entity)} + bar={this.getBarConfigForEntity(entity)} + onChange={() => this.onChange(entity.name)} />
  • ))} @@ -260,13 +483,14 @@ export class EntitySelector extends React.Component<{ private renderAllEntitiesInSingleMode(): JSX.Element { return (
      - {this.sortedAvailableEntities.map((name) => ( -
    • + {this.sortedAvailableEntities.map((entity) => ( +
    • this.onChange(name)} + checked={this.isEntitySelected(entity)} + bar={this.getBarConfigForEntity(entity)} + onChange={() => this.onChange(entity.name)} />
    • ))} @@ -274,8 +498,37 @@ export class EntitySelector extends React.Component<{ ) } - private isEntitySelected(name: string): boolean { - return this.selectionArray.selectedSet.has(name) + @computed private get displayColumn(): CoreColumn | undefined { + const { sortConfig } = this + if (this.isSortedByName) return undefined + return this.sortColumnsBySlug[sortConfig.slug] + } + + @computed private get barScale(): ScaleLinear { + return scaleLinear() + .domain([0, this.displayColumn?.maxValue ?? 1]) + .range([0, 1]) + } + + private getBarConfigForEntity( + entity: SearchableEntity + ): BarConfig | undefined { + const { displayColumn, barScale } = this + + if (!displayColumn) return undefined + + const value = entity[displayColumn.slug] + + if (!isFiniteWithGuard(value)) return { formattedValue: "No data" } + + return { + formattedValue: displayColumn.formatValueShort(value), + width: clamp(barScale(value), 0, 1), + } + } + + private isEntitySelected(entity: SearchableEntity): boolean { + return this.selectionArray.selectedSet.has(entity.name) } private renderAllEntitiesInMultiMode(): JSX.Element { @@ -289,62 +542,84 @@ export class EntitySelector extends React.Component<{ }} flipKey={this.selectionArray.selectedEntityNames.join(",")} > - {selected.length > 0 && ( - -
      - Selection -
      -
      - )} -
        - {selected.map((name) => ( - -
      • - this.onChange(name)} - /> -
      • +
        + {selected.length > 0 && ( + +
        + Selection +
        - ))} -
      + )} +
        + {selected.map((entity) => ( + +
      • + + this.onChange(entity.name) + } + /> +
      • +
        + ))} +
      +
- {selected.length > 0 && unselected.length > 0 && ( - -
- {capitalize(this.entityTypePlural)} -
-
- )} +
+ {selected.length > 0 && unselected.length > 0 && ( + +
+ {capitalize(this.entityTypePlural)} +
+
+ )} -
    - {unselected.map((name) => ( - -
  • + {unselected.map((entity) => ( + - this.onChange(name)} - /> -
  • -
    - ))} -
+
  • + + this.onChange(entity.name) + } + /> +
  • + + ))} + +
    ) } @@ -391,6 +666,10 @@ export class EntitySelector extends React.Component<{ > {this.renderSearchBar()} + {!this.searchInput && + this.sortOptions.length > 1 && + this.renderSortBar()} +
    {this.searchInput ? this.renderSearchResults() @@ -405,15 +684,19 @@ export class EntitySelector extends React.Component<{ } } +type BarConfig = { formattedValue: string; width?: number } + function SelectableEntity({ name, checked, type, + bar, onChange, }: { name: React.ReactNode checked: boolean type: "checkbox" | "radio" + bar?: BarConfig onChange: () => void }) { const Input = { @@ -431,7 +714,15 @@ function SelectableEntity({ onChange() }} > + {bar && bar.width !== undefined && ( +
    + )} + {bar && ( + + {bar.formattedValue} + + )}
    ) } diff --git a/packages/@ourworldindata/grapher/src/modal/ModalHeader.scss b/packages/@ourworldindata/grapher/src/modal/ModalHeader.scss new file mode 100644 index 00000000000..3f459919690 --- /dev/null +++ b/packages/@ourworldindata/grapher/src/modal/ModalHeader.scss @@ -0,0 +1,12 @@ +.modal-header { + --padding: var(--modal-padding, 16px); + + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--padding) var(--padding) 16px; + + button { + margin-left: 8px; + } +} diff --git a/packages/@ourworldindata/grapher/src/modal/ModalHeader.tsx b/packages/@ourworldindata/grapher/src/modal/ModalHeader.tsx new file mode 100644 index 00000000000..fbc8db3a5cd --- /dev/null +++ b/packages/@ourworldindata/grapher/src/modal/ModalHeader.tsx @@ -0,0 +1,17 @@ +import React from "react" +import { CloseButton } from "../closeButton/CloseButton.js" + +export function ModalHeader({ + title, + onDismiss, +}: { + title: string + onDismiss?: () => void +}) { + return ( +
    +

    {title}

    + {onDismiss && } +
    + ) +} diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index dea0d4a261c..24edd3ee899 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1847,6 +1847,7 @@ export function checkIsAuthor(x: unknown): x is OwidGdocAuthorInterface { const type = get(x, "content.type") return type === OwidGdocType.Author } + /** * Returns the cartesian product of the given arrays. * @@ -1881,3 +1882,7 @@ export function isElementHidden(element: Element | null): boolean { export function roundDownToNearestHundred(value: number): number { return Math.floor(value / 100) * 100 } + +export function isFiniteWithGuard(value: unknown): value is number { + return isFinite(value as any) +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index a662cefcf61..7fdcfd5e308 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -123,6 +123,7 @@ export { removeTrailingParenthetical, isElementHidden, roundDownToNearestHundred, + isFiniteWithGuard, } from "./Util.js" export {