From a0c7f56977889ff89cc0bdf8b4377aeb83ab3cc3 Mon Sep 17 00:00:00 2001 From: Sven van de Scheur Date: Tue, 6 Feb 2024 17:12:36 +0100 Subject: [PATCH] :sparkles: #25 - feat: implement datagrid sort and improve styling --- src/components/badge/badge.tsx | 4 +- src/components/boolean/boolean.scss | 6 +- src/components/boolean/boolean.stories.tsx | 5 +- src/components/boolean/boolean.tsx | 6 +- src/components/button/button.scss | 44 ++- src/components/button/button.stories.tsx | 104 +++++-- src/components/button/button.tsx | 107 ++++++- src/components/datagrid/datagrid.scss | 19 +- src/components/datagrid/datagrid.stories.tsx | 58 +++- src/components/datagrid/datagrid.tsx | 283 +++++++++++++------ src/components/form/input/input.scss | 2 +- src/components/form/select/select.scss | 4 +- src/components/icon/icon.scss | 4 +- src/components/typography/a/a.scss | 3 +- src/lib/array/sortData.ts | 28 ++ src/settings/tokens.css | 4 +- 16 files changed, 525 insertions(+), 156 deletions(-) create mode 100644 src/lib/array/sortData.ts diff --git a/src/components/badge/badge.tsx b/src/components/badge/badge.tsx index 790b0d42..c77cc4f9 100644 --- a/src/components/badge/badge.tsx +++ b/src/components/badge/badge.tsx @@ -13,7 +13,7 @@ export type BadgeProps = React.PropsWithChildren<{ * @constructor */ export const Badge: React.FC = ({ children, ...props }) => ( -
+ {children} -
+ ); diff --git a/src/components/boolean/boolean.scss b/src/components/boolean/boolean.scss index 805c0b42..1c160258 100644 --- a/src/components/boolean/boolean.scss +++ b/src/components/boolean/boolean.scss @@ -12,8 +12,12 @@ color: var(--theme-color-success-body); } - &--explicit#{&}--value-false { + &--value-false { background-color: var(--theme-color-danger-background); color: var(--theme-color-danger-body); } + + &--value-false:not(#{&}--explicit) { + visibility: hidden; + } } diff --git a/src/components/boolean/boolean.stories.tsx b/src/components/boolean/boolean.stories.tsx index 9df50b9f..c70232af 100644 --- a/src/components/boolean/boolean.stories.tsx +++ b/src/components/boolean/boolean.stories.tsx @@ -36,8 +36,8 @@ export const False: Story = { play: ({ canvasElement }) => { const canvas = within(canvasElement); const boolean = canvas.getByLabelText("This value is false"); - expect(boolean).toBeVisible(); - expect(boolean.children).toHaveLength(0); + expect(boolean).not.toBeVisible(); + expect(boolean.children).toHaveLength(1); }, }; @@ -51,6 +51,7 @@ export const ExplicitFalse: Story = { play: ({ canvasElement }) => { const canvas = within(canvasElement); const boolean = canvas.getByLabelText("This value is false"); + expect(boolean).toBeVisible(); expect(boolean.children).toHaveLength(1); }, }; diff --git a/src/components/boolean/boolean.tsx b/src/components/boolean/boolean.tsx index caad5cb6..ee34a6d4 100644 --- a/src/components/boolean/boolean.tsx +++ b/src/components/boolean/boolean.tsx @@ -32,7 +32,7 @@ export const Boolean: React.FC = ({ labelFalse, ...props }) => ( -
= ({ aria-label={value ? labelTrue : labelFalse} > {value && } - {!value && explicit && } -
+ {!value && } + ); diff --git a/src/components/button/button.scss b/src/components/button/button.scss index 3d4507bc..d70ddc5b 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -3,11 +3,14 @@ --mykn-button-color-background: var(--theme-color-primary-800); --mykn-button-color-shadow: var(--theme-shade-1000); --mykn-button-color-text: var(--theme-shade-0); + --mykn-button-font-size: var(--typography-font-size-body-s); + --mykn-button-font-weight: var(--typography-font-weight-normal); + --mykn-button-line-height: var(--typography-line-height-body-s); --mykn-button-height: auto; --mykn-button-width: auto; --mykn-button-offset: 0px; - --mykn-button-padding-v: var(--spacing-v-s); - --mykn-button-padding-h: var(--spacing-h-s); + --mykn-button-padding-v: 0; + --mykn-button-padding-h: 0; align-items: center; appearance: none; @@ -22,13 +25,13 @@ color: var(--mykn-button-color-text); cursor: pointer; display: inline-flex; - flex-wrap: wrap; gap: 0.5em; height: var(--mykn-button-height); font-family: Inter, sans-serif; - font-size: var(--typography-font-size-body-s); + font-size: var(--mykn-button-font-size); + font-weight: var(--mykn-button-font-weight); justify-content: center; - line-height: var(--typography-line-height-body-s); + line-height: var(--mykn-button-line-height); text-align: center; text-decoration: none; transition: all var(--animation-duration-fast) @@ -36,6 +39,31 @@ transform: translateY(var(--mykn-button-offset)); width: var(--mykn-button-width); + &--bold { + --mykn-button-font-weight: var(--typography-font-weight-bold); + } + + &--justify { + --mykn-button-width: 100%; + } + + &--muted { + --mykn-button-color-text: var(--typography-color-muted) !important; + } + + &--pad-h { + --mykn-button-padding-h: var(--spacing-h-s); + } + + &--pad-v { + --mykn-button-padding-v: var(--spacing-v-s); + } + + &--size-xs { + --mykn-button-font-size: var(--typography-font-size-body-xs); + --mykn-button-line-height: var(--typography-line-height-body-xs); + } + &--square { --mykn-button-height: calc( var(--typography-line-height-body-s) + 2 * var(--spacing-v-s) @@ -49,7 +77,7 @@ } &--variant-primary { - &#{$self}--active, // TODO + &#{$self}--active, // TODO &:focus, &:hover { --mykn-button-color-background: var(--theme-color-primary-700); @@ -114,4 +142,8 @@ --mykn-button-offset: 0px; } } + + &--wrap { + flex-wrap: wrap; + } } diff --git a/src/components/button/button.stories.tsx b/src/components/button/button.stories.tsx index 2baba761..b108d263 100644 --- a/src/components/button/button.stories.tsx +++ b/src/components/button/button.stories.tsx @@ -1,66 +1,118 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { userEvent } from "@storybook/test"; import React from "react"; +import { Toolbar } from "../toolbar"; import { Button, ButtonLink } from "./button"; const meta = { title: "Controls/Button", component: Button, + render: ({ ...args }) => ( + + + + + + ), } satisfies Meta; export default meta; type Story = StoryObj; -export const ButtonComponent: Story = { +export const Buttons: Story = { + args: {}, +}; + +export const ActiveButtons: Story = { args: { - children: "The quick brown fox jumps over the lazy dog.", + active: true, }, }; -export const TransparentButton: Story = { +export const BoldButtons: Story = { args: { - children: "The quick brown fox jumps over the lazy dog.", - variant: "transparent", + bold: true, }, }; -export const ButtonAnimatesOnHoverAndClick: Story = { +export const JustifiedButtons: Story = { args: { - children: "The quick brown fox jumps over the lazy dog.", + justify: true, }, - play: async () => { - await userEvent.tab({ delay: 10 }); +}; + +export const MutedButtons: Story = { + args: { + muted: true, }, }; -export const ButtonLinkComponent: StoryObj = { +export const PadlessButtons: Story = { args: { - children: "The quick brown fox jumps over the lazy dog.", - href: "https://www.example.com", - target: "_blank", + pad: false, }, - render: (args) => , }; -export const TransparentButtonLink: StoryObj = { +export const HorizontallyPaddedButtons: Story = { args: { - children: "The quick brown fox jumps over the lazy dog.", - href: "https://www.example.com", - target: "_blank", - variant: "transparent", + pad: "h", }, - render: (args) => , }; -export const ButtonLinkAnimatesOnHoverAndClick: StoryObj = { +export const VerticallyPaddedButtons: Story = { + args: { + pad: "v", + }, +}; + +export const SmallerButtonText: Story = { + args: { + size: "xs", + }, +}; + +export const SquareButtons: Story = { + args: { + square: true, + }, + render: ({ ...args }) => ( + + + + + + ), +}; + +export const ButtonLinkComponent: StoryObj = { args: { children: "The quick brown fox jumps over the lazy dog.", href: "https://www.example.com", target: "_blank", }, - play: async () => { - await userEvent.tab({ delay: 10 }); - }, - render: (args) => , + render: ({ ...args }) => ( + + + Primary ButtonLink + + + Tranparent ButtonLink + + + Outline ButtonLink + + + ), }; diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx index 632244e5..4a6361ec 100644 --- a/src/components/button/button.tsx +++ b/src/components/button/button.tsx @@ -5,8 +5,30 @@ import "./button.scss"; type BaseButtonProps = { active?: boolean; + + /** Whether the text should be presented bold. */ + bold?: boolean; + + /** Whether the buttons width should be set to 100%. */ + justify?: boolean; + + /** Whether the text should be presented in a lighter color. */ + muted?: boolean; + + /** Whether to apply padding to the button. */ + pad?: boolean | "h" | "v"; + + /** The size of the text. */ + size?: "s" | "xs"; + + /** Whether the button should be rendered in a square shape. */ square?: boolean; + + /** The variant (style) of the button. */ variant?: "primary" | "outline" | "transparent"; + + /** Whether wrapping should be allowed. */ + wrap?: boolean; }; export type ButtonProps = React.ButtonHTMLAttributes & @@ -18,20 +40,50 @@ export type ButtonLinkProps = React.AnchorHTMLAttributes & /** * Button component * @param active - * @param variant + * @param bold + * @param justify + * @param muted + * @param pad + * @param size * @param square - * @param props + * @param variant + * @param wrap * @constructor */ export const Button = React.forwardRef( - ({ active, square = false, variant = "primary", ...props }, ref) => { + ( + { + active = false, + bold = false, + justify = false, + muted = false, + pad = true, + size = "s", + square = false, + variant = "primary", + wrap = true, + ...props + }, + ref, + ) => { return ( + ) : ( +

+ {caption} +

+ )} ); })} @@ -195,14 +217,10 @@ export const DataGrid: React.FC = ({ {/* Cells */} - {results.map((rowData, index) => ( - /** - * FIXME: This effectively still uses index as keys which might lead to issues. - * @see {@link https://react.dev/learn/rendering-lists#rules-of-keys|Rules of keys} - */ + {sortedResults.map((rowData, index) => ( {renderableFields.map((field) => - renderCell(rowData, String(field), index), + renderCell(rowData, String(field)), )} ))} @@ -227,3 +245,100 @@ export const DataGrid: React.FC = ({ ); }; + +export type DataGridCellProps = { + aProps: DataGridProps["aProps"]; + badgeProps: DataGridProps["badgeProps"]; + booleanProps: DataGridProps["booleanProps"]; + rowData: RowData; + field: string; + fields: DataGridProps["fields"]; + urlFields: DataGridProps["urlFields"]; +}; + +/** + * DataGrid cell + * @param aProps + * @param badgeProps + * @param booleanProps + * @param field + * @param fields + * @param rowData + * @param urlFields + * @constructor + * @private + */ +export const DataGridCell: React.FC = ({ + aProps, + badgeProps, + booleanProps, + field, + fields = [], + rowData, + urlFields = DEFAULT_URL_FIELDS, +}) => { + const renderableFields = fields.filter((f) => !urlFields.includes(f)); + const fieldIndex = renderableFields.indexOf(field); + const urlField = urlFields.find((f) => isLink(String(rowData[f]))); + const rowUrl = urlField ? rowData[urlField] : null; + const data = rowData[field]; + const type = typeof data; + + // Explicit link: passed as URL without being set as urlField. + const isExplicitLink = isLink(String(data)); + + // Implicit link: first column and `rowUrl` resolved using `urlFields`. + const isImplicitLink = rowUrl && fieldIndex === 0; + + // Cell should be a link is truthy. + const link = isExplicitLink + ? String(data) + : isImplicitLink + ? String(rowUrl) + : ""; + + // Certain types should be rendered with component. + let contents: React.ReactNode = data; + switch (type) { + case "boolean": + contents = ; + break; + case "number": + contents = {data}; + break; + case "string": + if (isExplicitLink) { + contents = ( +

+ + {contents} + +

+ ); + } else if (isImplicitLink) { + contents = ( +

+ + + + {contents} +

+ ); + } else { + contents =

{data}

; + } + break; + } + + return ( + + {contents} + + ); +}; diff --git a/src/components/form/input/input.scss b/src/components/form/input/input.scss index 736263d9..07422e52 100644 --- a/src/components/form/input/input.scss +++ b/src/components/form/input/input.scss @@ -3,7 +3,7 @@ align-items: center; background: var(--typography-color-background); border: 1px solid var(--form-color-border); - border-radius: 6px; + border-radius: var(--border-radius-m); box-sizing: border-box; color: var(--typography-color-body); font-family: Inter, sans-serif; diff --git a/src/components/form/select/select.scss b/src/components/form/select/select.scss index 5a992350..14467bf7 100644 --- a/src/components/form/select/select.scss +++ b/src/components/form/select/select.scss @@ -57,7 +57,7 @@ &__dropdown { background: var(--typography-color-background); - border: 1px solid var(--theme-color-primary-800); + border: 1px solid var(--form-color-border); border-radius: var(--border-radius-m); box-sizing: border-box; min-width: 100%; @@ -85,7 +85,7 @@ white-space: nowrap; &[aria-selected="true"] { - --mykn-option-color-background: var(--typography-color-background-dark); + --mykn-option-color-background: var(--typography-color-background-alt); --mykn-option-font-weight: var(--typography-font-weight-bold); } diff --git a/src/components/icon/icon.scss b/src/components/icon/icon.scss index 86ee3c4c..7d15f2c8 100644 --- a/src/components/icon/icon.scss +++ b/src/components/icon/icon.scss @@ -1,7 +1,7 @@ .mykn-icon { - height: 1.2em; + height: 1em; vertical-align: middle; - width: 1.2em; + width: 1em; &--hidden { visibility: hidden; diff --git a/src/components/typography/a/a.scss b/src/components/typography/a/a.scss index bd52e4f7..4559c292 100644 --- a/src/components/typography/a/a.scss +++ b/src/components/typography/a/a.scss @@ -2,11 +2,12 @@ .mykn-a { @extend .mykn-p; + align-items: center; color: var(--theme-color-primary-800); display: inline-flex; font-size: inherit; justify-content: center; - align-items: center; + font-weight: inherit; gap: 0.5em; width: fit-content; diff --git a/src/lib/array/sortData.ts b/src/lib/array/sortData.ts new file mode 100644 index 00000000..8ed2744d --- /dev/null +++ b/src/lib/array/sortData.ts @@ -0,0 +1,28 @@ +/** + * Sorts `results` by `field` based on `direction`. + * @param results + * @param field + * @param direction can be "ASC" or "DESC". + */ +export const sortData = >( + results: T[], + field: keyof T, + direction: "ASC" | "DESC", +): T[] => { + const multiplier = direction === "ASC" ? 1 : -1; + + return results.sort((a, b) => { + const valueA = a[field]; + const valueB = b[field]; + + // Use String.localeCompare for strings. + if (typeof valueA === "string" && typeof valueB === "string") { + return multiplier * valueA.localeCompare(valueB); + } + + return ( + multiplier * + ((valueA as unknown as number) - (valueB as unknown as number)) + ); + }); +}; diff --git a/src/settings/tokens.css b/src/settings/tokens.css index 8e817263..1b22536a 100644 --- a/src/settings/tokens.css +++ b/src/settings/tokens.css @@ -34,7 +34,7 @@ --theme-color-success-background: #e9ffe9; --theme-color-danger-body: #ff4545; - --theme-color-danger-background: #FFDFDF; + --theme-color-danger-background: #FFEFEF; /* SPACING */ --border-radius-l: 12px; @@ -73,7 +73,7 @@ /* TYPOGRAPHY */ --typography-color-background: var(--theme-shade-0); - --typography-color-background-dark: var(--theme-shade-100); + --typography-color-background-alt: var(--theme-shade-100); --typography-color-h: var(--theme-shade-900); --typography-color-body: var(--theme-shade-700); --typography-color-border: var(--theme-shade-300);