From f2bad9807cfa66b151df85a1eb20ec9fc2e923cb Mon Sep 17 00:00:00 2001 From: Simon Jentsch Date: Mon, 2 Dec 2024 11:56:38 +0100 Subject: [PATCH 1/5] Move Flex to own file --- src/Box/Box.tsx | 25 ------------------------- src/Box/Flex.tsx | 27 +++++++++++++++++++++++++++ src/Box/index.ts | 1 + 3 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 src/Box/Flex.tsx diff --git a/src/Box/Box.tsx b/src/Box/Box.tsx index fb8345e..cd358dd 100644 --- a/src/Box/Box.tsx +++ b/src/Box/Box.tsx @@ -4,7 +4,6 @@ import { flexbox } from '@styled-system/flexbox'; import { position } from '@styled-system/position'; import type { LayoutProps, FlexboxProps, PositionProps } from 'styled-system'; import { system } from '@styled-system/core'; -import type * as CSS from 'csstype'; import { color, ColorProps } from './color'; import { CssFunctionReturn } from '../types'; @@ -13,7 +12,6 @@ import { getByPath } from '../utils/getByPath'; import { themeVars } from '../theme/themeVars'; import { interpolateCssProp } from '../utils/interpolateCssProp'; import { margin, MarginProps, PaddingProps, padding } from './spacingInterpolation'; -import { ifProp } from '../styleHelpers/styleProp'; export interface BoxCssProps { css?: CssFunctionReturn; @@ -66,26 +64,3 @@ export const LayoutBox = styled.div` ${baseStyle} ${layoutInterpolationFn} `; - -export type FlexProps = LayoutBoxProps & { - center?: boolean; - equal?: boolean; - end?: boolean; - start?: boolean; - between?: boolean; - stretch?: boolean; - direction?: CSS.Property.FlexDirection; -}; - -const justifyContent = (where: CSS.Property.JustifyContent) => `justify-content: ${where};`; - -export const Flex = styled(Box)` - display: flex; - ${ifProp('center', 'justify-content: center; align-items: center;')} - ${ifProp('equal', '> * { flex-basis: 100%; flex-grow: 1; flex-shrink: 1; }')} - ${ifProp('between', justifyContent('space-between'))} - ${ifProp('end', justifyContent('flex-end'))} - ${ifProp('start', justifyContent('flex-start'))} - ${ifProp('stretch', 'align-items: stretch;')} - ${ifProp('direction', (_, value) => `flex-direction: ${value};`)} -`; diff --git a/src/Box/Flex.tsx b/src/Box/Flex.tsx new file mode 100644 index 0000000..0b0adb3 --- /dev/null +++ b/src/Box/Flex.tsx @@ -0,0 +1,27 @@ +import type * as CSS from 'csstype'; +import { ifProp } from '../styleHelpers/styleProp'; +import { Box, type BoxProps } from './Box'; +import styled from '@emotion/styled'; + +export type FlexProps = BoxProps & { + center?: boolean; + equal?: boolean; + end?: boolean; + start?: boolean; + between?: boolean; + stretch?: boolean; + direction?: CSS.Property.FlexDirection; +}; + +const justifyContent = (where: CSS.Property.JustifyContent) => `justify-content: ${where};`; + +export const Flex = styled(Box)` + display: flex; + ${ifProp('center', 'justify-content: center; align-items: center;')} + ${ifProp('equal', '> * { flex-basis: 100%; flex-grow: 1; flex-shrink: 1; }')} + ${ifProp('between', justifyContent('space-between'))} + ${ifProp('end', justifyContent('flex-end'))} + ${ifProp('start', justifyContent('flex-start'))} + ${ifProp('stretch', 'align-items: stretch;')} + ${ifProp('direction', (_, value) => `flex-direction: ${value};`)} +`; diff --git a/src/Box/index.ts b/src/Box/index.ts index 305f81d..5fa9f5e 100644 --- a/src/Box/index.ts +++ b/src/Box/index.ts @@ -1 +1,2 @@ export * from './Box'; +export * from './Flex'; From a82757ccc457b4703c5f5a8edc51a9c43081ce42 Mon Sep 17 00:00:00 2001 From: Simon Jentsch Date: Mon, 2 Dec 2024 23:40:05 +0100 Subject: [PATCH 2/5] Add slider --- eslint.config.js | 3 + src/Box/Box.tsx | 16 +- src/Slider/Slider.spec.tsx | 146 +++++ src/Slider/Slider.stories.tsx | 45 ++ src/Slider/Slider.tsx | 100 ++++ src/Slider/__snapshots__/Slider.spec.tsx.snap | 553 ++++++++++++++++++ src/Slider/index.ts | 1 + src/Slider/styles.ts | 60 ++ src/styleHelpers/componentPrimitive.tsx | 43 ++ src/styleHelpers/getComponentStyle.ts | 31 +- src/theme/defaultComponentStyles.ts | 2 + src/theme/types.ts | 6 + src/types.ts | 4 + src/utils/useEventListener.ts | 22 + 14 files changed, 1021 insertions(+), 11 deletions(-) create mode 100644 src/Slider/Slider.spec.tsx create mode 100644 src/Slider/Slider.stories.tsx create mode 100644 src/Slider/Slider.tsx create mode 100644 src/Slider/__snapshots__/Slider.spec.tsx.snap create mode 100644 src/Slider/index.ts create mode 100644 src/Slider/styles.ts create mode 100644 src/styleHelpers/componentPrimitive.tsx create mode 100644 src/utils/useEventListener.ts diff --git a/eslint.config.js b/eslint.config.js index fc1ee50..e2d110a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -57,6 +57,9 @@ export default [ // 'import/no-unresolved': 'off', 'no-unused-vars': 'off', 'react-hooks/rules-of-hooks': 'error', + "react-hooks/exhaustive-deps": ["warn", { + "additionalHooks": "(useEventListener)" + }], 'react-hooks/exhaustive-deps': 'warn', }, }, diff --git a/src/Box/Box.tsx b/src/Box/Box.tsx index cd358dd..6f938fc 100644 --- a/src/Box/Box.tsx +++ b/src/Box/Box.tsx @@ -12,6 +12,7 @@ import { getByPath } from '../utils/getByPath'; import { themeVars } from '../theme/themeVars'; import { interpolateCssProp } from '../utils/interpolateCssProp'; import { margin, MarginProps, PaddingProps, padding } from './spacingInterpolation'; +import { ifProp } from '../styleHelpers/styleProp'; export interface BoxCssProps { css?: CssFunctionReturn; @@ -21,17 +22,24 @@ export interface BoxFillableProps { fillColor?: string; } +export interface BoxFlexProps extends FlexboxProps { + grow?: number | boolean; + shrink?: number | boolean; +} + export type BoxProps = MarginProps & PaddingProps & ColorProps & LayoutProps & - FlexboxProps & + BoxFlexProps & PositionProps & BoxFillableProps & BoxCssProps; +const flexGrow = ifProp('grow', (_, value) => `flex-grow: ${value};`); +const flexShrink = ifProp('shrink', (_, value) => `flex-shrink: ${value};`); export const boxInterpolateFn = (props) => - [margin, padding, color, layout, flexbox, position].map((fn) => fn(props)); + [margin, padding, color, layout, flexbox, position, flexGrow, flexShrink].map((fn) => fn(props)); const fill = system({ fillColor: { @@ -50,13 +58,13 @@ export const Box = styled.div` export type LayoutBoxProps = MarginProps & PaddingProps & - FlexboxProps & + BoxFlexProps & LayoutProps & PositionProps & BoxCssProps; export const layoutInterpolationFn = (props) => - [margin, padding, layout, flexbox, position] + [margin, padding, layout, flexbox, position, flexGrow, flexShrink] .map((fn) => fn(props)) .reduce((acc, styles) => ({ ...acc, ...styles }), {}); diff --git a/src/Slider/Slider.spec.tsx b/src/Slider/Slider.spec.tsx new file mode 100644 index 0000000..cf9969b --- /dev/null +++ b/src/Slider/Slider.spec.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import { Slider } from './Slider'; +import { composeStories } from '@storybook/react'; +import * as stories from './Slider.stories'; + +const defaultProps = { + from: 0, + to: 100, + value: 50, + onChange: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('renders correctly', () => { + render(); + expect(screen.getByRole('slider')).toBeInTheDocument(); +}); + +test('calls onChange when dragging thumb', () => { + const { container } = render(); + const thumb = container.querySelector('[data-testid="slider-thumb"]'); + const rail = container.querySelector('[data-testid="slider-rail"]'); + + if (!thumb || !rail) throw new Error('Thumb or rail not found'); + + // Mock getBoundingClientRect + const railRect = { left: 0, width: 200 }; + jest.spyOn(rail, 'getBoundingClientRect').mockImplementation(() => railRect as DOMRect); + // Set bounding rect that the click is exactly in the center + jest + .spyOn(thumb, 'getBoundingClientRect') + .mockImplementation(() => ({ left: 90, width: 20 }) as DOMRect); + + // Simulate drag start + fireEvent.mouseDown(thumb, { clientX: 100 }); + + // Simulate drag movement + fireEvent.mouseMove(window, { clientX: 150 }); + + expect(defaultProps.onChange).toHaveBeenCalledWith(75); +}); + +test('handles custom range correctly', () => { + const customProps = { + ...defaultProps, + from: -100, + to: 100, + value: 0, + }; + + const { container } = render(); + const thumb = container.querySelector('[data-testid="slider-thumb"]'); + const rail = container.querySelector('[data-testid="slider-rail"]'); + + if (!thumb || !rail) throw new Error('Thumb or rail not found'); + + const railRect = { left: 0, width: 200 }; + jest.spyOn(rail, 'getBoundingClientRect').mockImplementation(() => railRect as DOMRect); + // Set bounding rect that the click is exactly in the center + jest + .spyOn(thumb, 'getBoundingClientRect') + .mockImplementation(() => ({ left: 90, width: 20 }) as DOMRect); + + fireEvent.mouseDown(thumb, { clientX: 100 }); + fireEvent.mouseMove(window, { clientX: 150 }); + + expect(customProps.onChange).toHaveBeenCalledWith(50); +}); + +test('constrains value within bounds', () => { + const { container } = render(); + const thumb = container.querySelector('[data-testid="slider-thumb"]'); + const rail = container.querySelector('[data-testid="slider-rail"]'); + + if (!thumb || !rail) throw new Error('Thumb or rail not found'); + + // Mock getBoundingClientRect + const railRect = { left: 0, width: 200 }; + jest.spyOn(rail, 'getBoundingClientRect').mockImplementation(() => railRect as DOMRect); + + // Simulate drag beyond maximum + fireEvent.mouseDown(thumb, { clientX: 100 }); + fireEvent.mouseMove(document, { clientX: 300 }); + + expect(defaultProps.onChange).toHaveBeenCalledWith(100); + + // Simulate drag below minimum + fireEvent.mouseMove(document, { clientX: -100 }); + + expect(defaultProps.onChange).toHaveBeenCalledWith(0); +}); + +test('stops dragging on mouseup', () => { + const { container } = render(); + const thumb = container.querySelector('[data-testid="slider-thumb"]'); + + if (!thumb) throw new Error('Thumb not found'); + + fireEvent.mouseDown(thumb); + fireEvent.mouseUp(document); + + // Simulate move after mouseup + fireEvent.mouseMove(document, { clientX: 150 }); + + expect(defaultProps.onChange).not.toHaveBeenCalled(); +}); + +test('updates track width based on value', () => { + render(); + const track = screen.getByTestId('slider-track'); + expect(track).toHaveStyle({ width: '25%' }); +}); + +test('updates thumb position based on value', () => { + render(); + const thumb = screen.getByTestId('slider-thumb'); + expect(thumb).toHaveStyle({ left: '75%' }); +}); + +test('SimpleSlider story snapshot', () => { + const { container } = render(createStoryComponent(stories.SimpleSlider)); + expect(container.firstChild).toMatchSnapshot(); +}); + +test('OffsetRangeSlider story snapshot', () => { + const { container } = render(createStoryComponent(stories.OffsetRangeSlider)); + expect(container.firstChild).toMatchSnapshot(); +}); + +test('LowerOutOfBounds story snapshot', () => { + const { container } = render(createStoryComponent(stories.LowerOutOfBounds)); + expect(container.firstChild).toMatchSnapshot(); +}); + +test('UpperOutOfBounds story snapshot', () => { + const { container } = render(createStoryComponent(stories.UpperOutOfBounds)); + expect(container.firstChild).toMatchSnapshot(); +}); + +function createStoryComponent(Story) { + return ; +} diff --git a/src/Slider/Slider.stories.tsx b/src/Slider/Slider.stories.tsx new file mode 100644 index 0000000..2883247 --- /dev/null +++ b/src/Slider/Slider.stories.tsx @@ -0,0 +1,45 @@ +import React, { useState } from 'react'; + +import { Slider } from './Slider'; +import { Flex } from '../Box'; +import { Paragraph } from '../Typography'; + +export default { + title: 'Slider', +}; + +const BaseStory = ({ from, to }) => { + const [value, setValue] = useState(50); + return ( + + + + {Math.floor(value)} + + + ); +}; + +export const SimpleSlider = BaseStory.bind(null); +SimpleSlider.args = { + from: 0, + to: 100, +}; + +export const OffsetRangeSlider = BaseStory.bind(null); +OffsetRangeSlider.args = { + from: 23, + to: 184, +}; + +export const LowerOutOfBounds = BaseStory.bind(null); +LowerOutOfBounds.args = { + from: 70, + to: 100, +}; + +export const UpperOutOfBounds = BaseStory.bind(null); +UpperOutOfBounds.args = { + from: 0, + to: 40, +}; diff --git a/src/Slider/Slider.tsx b/src/Slider/Slider.tsx new file mode 100644 index 0000000..0c8df28 --- /dev/null +++ b/src/Slider/Slider.tsx @@ -0,0 +1,100 @@ +import React, { MouseEventHandler, useRef, useState } from 'react'; +import { LayoutBox, LayoutBoxProps } from '../Box'; +import styled from '@emotion/styled'; +import { useEventListener } from '../utils/useEventListener'; +import { getComponentStyle, shadowTransformer } from '../styleHelpers'; +import { componentPrimitive, getPrimitiveStyle } from '../styleHelpers/componentPrimitive'; + +interface SliderProps extends LayoutBoxProps { + from: number; + to: number; + value: number; + onChange: (value: number) => void; +} + +const thickness = getPrimitiveStyle('thickness'); +const borderRadius = getPrimitiveStyle('borderRadius'); +const backgroundColor = getPrimitiveStyle('backgroundColor'); + +const Rail = componentPrimitive(['slider', 'rail'])` + width: 100%; + height: ${thickness}; + border-radius: ${borderRadius}; + background-color: ${backgroundColor}; +`; + +const Track = componentPrimitive(['slider', 'track'])` + position: absolute; + height: ${thickness}; + top: 50%; + transform: translateY(-50%); + border-radius: ${borderRadius}; + background-color: ${backgroundColor}; +`; + +const Thumb = componentPrimitive(['slider', 'thumb'])` + width: ${getPrimitiveStyle('width')}; + height: ${getPrimitiveStyle('height')}; + border-radius: ${borderRadius}; + background-color: ${backgroundColor}; + box-shadow: ${getPrimitiveStyle('shadow', shadowTransformer)}; + position: absolute; + top: 50%; + transform: translateY(-50%) translateX(-50%); + cursor: grab; +`; + +const OuterBox = styled(LayoutBox)` + width: 100%; + height: ${getComponentStyle(['slider', 'thumb', 'height'])}; + position: relative; + display: flex; + align-items: center; +`; + +const Slider = ({ from, to, value, onChange, ...props }: SliderProps) => { + const offsetRef = useRef(0); + const railRef = useRef(null); + const [dragging, setDragging] = useState(false); + const ratio = Math.min(Math.max((value - from) / (to - from), 0), 1); + const percentage = ratio * 100; + + useEventListener('mouseup', () => setDragging(false)); + useEventListener( + 'mousemove', + (e: MouseEvent) => { + if (dragging && railRef.current) { + const { left, width } = railRef.current.getBoundingClientRect(); + const x = e.clientX - left + offsetRef.current; + const ratio = x / width; + const calculatedValue = from + ratio * (to - from); + const newValue = Math.max(Math.min(calculatedValue, to), from); + onChange(newValue); + } + }, + [dragging, onChange, from, to] + ); + + const handleThumbMouseDown: MouseEventHandler = (e) => { + e.preventDefault(); + const target = e.target as HTMLDivElement; + const boundingRect = target.getBoundingClientRect(); + offsetRef.current = boundingRect.left + boundingRect.width * 0.5 - e.clientX; + + setDragging(true); + }; + + return ( + + + + + + ); +}; + +export { Slider }; diff --git a/src/Slider/__snapshots__/Slider.spec.tsx.snap b/src/Slider/__snapshots__/Slider.spec.tsx.snap new file mode 100644 index 0000000..f89b66a --- /dev/null +++ b/src/Slider/__snapshots__/Slider.spec.tsx.snap @@ -0,0 +1,553 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LowerOutOfBounds story snapshot 1`] = ` +.emotion-0 { + box-sizing: border-box; + gap: 16px 16px; + width: 50vw; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-1 { + box-sizing: border-box; + 0: f; + 1: l; + 2: e; + 3: x; + 4: -; + 5: s; + 6: h; + 7: r; + 8: i; + 9: n; + 10: k; + 13: s; + 14: h; + 15: r; + 16: i; + 17: n; + 18: k; + width: 100%; + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-2 { + width: 100%; +} + +.emotion-3 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +.emotion-4 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%) translateX(-50%); + -moz-transform: translateY(-50%) translateX(-50%); + -ms-transform: translateY(-50%) translateX(-50%); + transform: translateY(-50%) translateX(-50%); + cursor: -webkit-grab; + cursor: grab; +} + +.emotion-5 { + box-sizing: border-box; + width: 50px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-6 { + box-sizing: border-box; + font-family: var(--pbl-theme-typography-base-fontFamily); + font-style: normal; + font-weight: var(--pbl-theme-typography-base-fontWeight); + margin: 0; + font-size: var(--pbl-theme-typography-paragraph-fontSize); + line-height: var(--pbl-theme-typography-paragraph-lineHeight); + margin-bottom: 0; +} + +
+
+
+
+
+
+
+

+ 50 +

+
+
+`; + +exports[`OffsetRangeSlider story snapshot 1`] = ` +.emotion-0 { + box-sizing: border-box; + gap: 16px 16px; + width: 50vw; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-1 { + box-sizing: border-box; + 0: f; + 1: l; + 2: e; + 3: x; + 4: -; + 5: s; + 6: h; + 7: r; + 8: i; + 9: n; + 10: k; + 13: s; + 14: h; + 15: r; + 16: i; + 17: n; + 18: k; + width: 100%; + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-2 { + width: 100%; +} + +.emotion-3 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +.emotion-4 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%) translateX(-50%); + -moz-transform: translateY(-50%) translateX(-50%); + -ms-transform: translateY(-50%) translateX(-50%); + transform: translateY(-50%) translateX(-50%); + cursor: -webkit-grab; + cursor: grab; +} + +.emotion-5 { + box-sizing: border-box; + width: 50px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-6 { + box-sizing: border-box; + font-family: var(--pbl-theme-typography-base-fontFamily); + font-style: normal; + font-weight: var(--pbl-theme-typography-base-fontWeight); + margin: 0; + font-size: var(--pbl-theme-typography-paragraph-fontSize); + line-height: var(--pbl-theme-typography-paragraph-lineHeight); + margin-bottom: 0; +} + +
+
+
+
+
+
+
+

+ 50 +

+
+
+`; + +exports[`SimpleSlider story snapshot 1`] = ` +.emotion-0 { + box-sizing: border-box; + gap: 16px 16px; + width: 50vw; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-1 { + box-sizing: border-box; + 0: f; + 1: l; + 2: e; + 3: x; + 4: -; + 5: s; + 6: h; + 7: r; + 8: i; + 9: n; + 10: k; + 13: s; + 14: h; + 15: r; + 16: i; + 17: n; + 18: k; + width: 100%; + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-2 { + width: 100%; +} + +.emotion-3 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +.emotion-4 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%) translateX(-50%); + -moz-transform: translateY(-50%) translateX(-50%); + -ms-transform: translateY(-50%) translateX(-50%); + transform: translateY(-50%) translateX(-50%); + cursor: -webkit-grab; + cursor: grab; +} + +.emotion-5 { + box-sizing: border-box; + width: 50px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-6 { + box-sizing: border-box; + font-family: var(--pbl-theme-typography-base-fontFamily); + font-style: normal; + font-weight: var(--pbl-theme-typography-base-fontWeight); + margin: 0; + font-size: var(--pbl-theme-typography-paragraph-fontSize); + line-height: var(--pbl-theme-typography-paragraph-lineHeight); + margin-bottom: 0; +} + +
+
+
+
+
+
+
+

+ 50 +

+
+
+`; + +exports[`UpperOutOfBounds story snapshot 1`] = ` +.emotion-0 { + box-sizing: border-box; + gap: 16px 16px; + width: 50vw; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-1 { + box-sizing: border-box; + 0: f; + 1: l; + 2: e; + 3: x; + 4: -; + 5: s; + 6: h; + 7: r; + 8: i; + 9: n; + 10: k; + 13: s; + 14: h; + 15: r; + 16: i; + 17: n; + 18: k; + width: 100%; + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-2 { + width: 100%; +} + +.emotion-3 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%); + -moz-transform: translateY(-50%); + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +.emotion-4 { + position: absolute; + top: 50%; + -webkit-transform: translateY(-50%) translateX(-50%); + -moz-transform: translateY(-50%) translateX(-50%); + -ms-transform: translateY(-50%) translateX(-50%); + transform: translateY(-50%) translateX(-50%); + cursor: -webkit-grab; + cursor: grab; +} + +.emotion-5 { + box-sizing: border-box; + width: 50px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.emotion-6 { + box-sizing: border-box; + font-family: var(--pbl-theme-typography-base-fontFamily); + font-style: normal; + font-weight: var(--pbl-theme-typography-base-fontWeight); + margin: 0; + font-size: var(--pbl-theme-typography-paragraph-fontSize); + line-height: var(--pbl-theme-typography-paragraph-lineHeight); + margin-bottom: 0; +} + +
+
+
+
+
+
+
+

+ 50 +

+
+
+`; diff --git a/src/Slider/index.ts b/src/Slider/index.ts new file mode 100644 index 0000000..f48a854 --- /dev/null +++ b/src/Slider/index.ts @@ -0,0 +1 @@ +export * from './Slider'; diff --git a/src/Slider/styles.ts b/src/Slider/styles.ts new file mode 100644 index 0000000..9ef3216 --- /dev/null +++ b/src/Slider/styles.ts @@ -0,0 +1,60 @@ +import { css } from '@emotion/react'; +import type * as CSS from 'csstype'; +import { getComponentStyle } from '../styleHelpers'; +import { themeVars } from '../theme/themeVars'; +import { ComponentPrimitiveStyle, Style } from '../theme/types'; +import { BaseStyles } from '../types'; + +export type SliderStyleProperties = 'root'; + +export interface SliderRailStyles extends ComponentPrimitiveStyle { + borderRadius: Style; + backgroundColor: Style; + thickness: Style; +} + +export interface SliderThumbStyles extends ComponentPrimitiveStyle { + borderRadius: Style; + backgroundColor: Style; + width: Style; + height: Style; + shadow: string[]; + css: Style; + cursor: CSS.Property.Cursor; +} + +export interface SliderTrackStyles extends ComponentPrimitiveStyle { + backgroundColor: Style; + thickness: Style; + borderRadius: Style; +} + +export interface SliderStyles extends BaseStyles { + rail: SliderRailStyles; + thumb: SliderThumbStyles; + track: SliderTrackStyles; +} + +export const sliderStyles: SliderStyles = { + rail: { + borderRadius: getComponentStyle(['slider', 'rail', 'thickness']), + backgroundColor: themeVars.colors.gray[100], + thickness: '8px', + }, + thumb: { + borderRadius: '50%', + backgroundColor: themeVars.colors.common.white, + height: '16px', + width: getComponentStyle(['slider', 'thumb', 'height']), + shadow: ['0px 1px 2px rgba(0, 0, 0, 0.2)', '0px 4px 10px rgba(0, 0, 0, 0.1)'], + css: css` + border: 1px solid ${themeVars.colors.gray[100]}; + `, + cursor: 'grab', + }, + track: { + backgroundColor: themeVars.colors.brand.main, + thickness: '10px', + borderRadius: getComponentStyle(['slider', 'track', 'thickness']), + }, +}; diff --git a/src/styleHelpers/componentPrimitive.tsx b/src/styleHelpers/componentPrimitive.tsx new file mode 100644 index 0000000..d408d8e --- /dev/null +++ b/src/styleHelpers/componentPrimitive.tsx @@ -0,0 +1,43 @@ +import type { Interpolation } from '@emotion/react'; +import { ComponentPath } from '../types'; +import { guaranteeArray } from '../utils/guaranteeArray'; +import { getComponentStyle } from './getComponentStyle'; +import styled from '@emotion/styled'; +import React, { forwardRef } from 'react'; + +interface CreateComponentPrimitiveOptions { + tag?: T; +} + +type ComponentPrimitiveProps

= { + componentPath: ComponentPath

; +} & P; + +const getPrimitiveStyle = +

>( + property: string | string[], + transformFn?: (value: unknown) => string | number + ) => + (props: I) => { + const componentPath = props.componentPath as ComponentPath

; + const propertyArray = guaranteeArray(property); + return getComponentStyle([...componentPath, ...propertyArray], transformFn)(props); + }; + +const componentPrimitive = +

( + componentPath: ComponentPath

, + { tag = 'div' as any }: CreateComponentPrimitiveOptions = {} + ) => + (template: TemplateStringsArray, ...styles: Array>>) => { + const StyledComponent = styled(tag)>( + template, + ...styles, + getPrimitiveStyle('css') + ); + return forwardRef((props: any, ref) => ( + + )); + }; + +export { componentPrimitive, getPrimitiveStyle }; diff --git a/src/styleHelpers/getComponentStyle.ts b/src/styleHelpers/getComponentStyle.ts index 74c748c..91662c5 100644 --- a/src/styleHelpers/getComponentStyle.ts +++ b/src/styleHelpers/getComponentStyle.ts @@ -1,17 +1,34 @@ import type { WithTheme } from '@emotion/react'; import { PabloTheme } from '../theme/types'; +import { ComponentPath } from '../types'; + +const getStringPath = (path: string, props: object) => + path.replace(/\{(.*?)\}/g, (_, val) => props[val] || val).split('.'); + +const getArrayPath =

(path: ComponentPath

, props: object) => + path.map((key) => { + if (typeof key === 'function') { + return key(props as P); + } + + return key; + }); export const getComponentStyle = - (path: string, transformFn: (value: unknown) => string | number = (v) => v as string) => - ({ theme, ...props }: WithTheme) => { - const interpolatedPath = path.replace(/\{(.*?)\}/g, (_, val) => props[val] || val); +

( + path: ComponentPath

, + transformFn: (value: unknown) => string | number = (v) => v as string + ) => + (props: WithTheme) => { + const pathArray = Array.isArray(path) ? getArrayPath(path, props) : getStringPath(path, props); - const value = interpolatedPath - .split('.') - .reduce((acc, key) => (acc && acc[key]) || undefined, theme.componentStyles || {}); + const value = pathArray.reduce( + (acc, key) => (acc && acc[key]) || undefined, + props.theme.componentStyles || {} + ); if (typeof value === 'function') { - return transformFn(value({ theme })); + return transformFn(value(props)); } return transformFn(value); diff --git a/src/theme/defaultComponentStyles.ts b/src/theme/defaultComponentStyles.ts index f716a32..a02d058 100644 --- a/src/theme/defaultComponentStyles.ts +++ b/src/theme/defaultComponentStyles.ts @@ -13,6 +13,7 @@ import { modalStyles } from '../Modal/styles'; import { nativeSelectStyles } from '../NativeSelect/styles'; import { radioStyles } from '../Radio/styles'; import { sidebarNavStyles } from '../SidebarNav/styles'; +import { sliderStyles } from '../Slider/styles'; import { switchStyles } from '../Switch/styles'; import { tabsStyles } from '../Tabs/styles'; import { textareaStyles } from '../TextArea/styles'; @@ -37,6 +38,7 @@ const defaultComponentStyles = { nativeSelect: nativeSelectStyles, radio: radioStyles, sidebarNav: sidebarNavStyles, + slider: sliderStyles, switch: switchStyles, tabs: tabsStyles, textArea: textareaStyles, diff --git a/src/theme/types.ts b/src/theme/types.ts index 8172b8a..1a5fcd6 100644 --- a/src/theme/types.ts +++ b/src/theme/types.ts @@ -25,6 +25,7 @@ import { ToastCardStyles } from '../ToastCard/styles'; import { ImageStyles } from '../Image/styles'; import { NativeSelectStyles } from '../NativeSelect/styles'; import { AnimationStyles } from '../animation/styles'; +import { SliderStyles } from '../Slider/styles'; export type Style

= | CSSInterpolation @@ -48,11 +49,16 @@ type RecursivePartial = { : T[P]; }; +export interface ComponentPrimitiveStyle { + css?: Style; +} + export interface ComponentStyles { animation: AnimationStyles; card: CardStyles; tabs: TabsStyles; sidebarNav: SidebarNavStyles; + slider: SliderStyles; button: ButtonStyles; checkbox: CheckboxStyles; switch: SwitchStyles; diff --git a/src/types.ts b/src/types.ts index 2b62c8d..ce12fb3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,3 +15,7 @@ export interface BaseStyles { export interface BaseProps { customStyles?: CustomStyles; } + +export type ComponentPathResolverFn

= (props: P) => string; + +export type ComponentPath

= (string | ComponentPathResolverFn

)[]; diff --git a/src/utils/useEventListener.ts b/src/utils/useEventListener.ts new file mode 100644 index 0000000..42dfd19 --- /dev/null +++ b/src/utils/useEventListener.ts @@ -0,0 +1,22 @@ +import { useEffect, useMemo } from 'react'; + +type EventListenerFn = (event: E) => void; + +const useEventListener = ( + eventName: K, + handler: EventListenerFn, + dependencies: any[] = [], + element = window +) => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const savedHandler = useMemo(() => handler, [dependencies]); + + useEffect(() => { + element.addEventListener(eventName, savedHandler); + return () => { + element.removeEventListener(eventName, savedHandler); + }; + }, [eventName, element, savedHandler]); +}; + +export { useEventListener }; From a546f3bd294ae8c8147a7b0e8ad18eba8e351c42 Mon Sep 17 00:00:00 2001 From: Simon Jentsch Date: Mon, 2 Dec 2024 23:41:52 +0100 Subject: [PATCH 3/5] Fix lint --- src/Slider/Slider.spec.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Slider/Slider.spec.tsx b/src/Slider/Slider.spec.tsx index cf9969b..e604b15 100644 --- a/src/Slider/Slider.spec.tsx +++ b/src/Slider/Slider.spec.tsx @@ -1,7 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { Slider } from './Slider'; -import { composeStories } from '@storybook/react'; import * as stories from './Slider.stories'; const defaultProps = { From afdae028916fb33d136474c469ff616ab903a588 Mon Sep 17 00:00:00 2001 From: Simon Jentsch Date: Mon, 2 Dec 2024 23:43:20 +0100 Subject: [PATCH 4/5] add string component path type back --- src/styleHelpers/getComponentStyle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styleHelpers/getComponentStyle.ts b/src/styleHelpers/getComponentStyle.ts index 91662c5..40a262b 100644 --- a/src/styleHelpers/getComponentStyle.ts +++ b/src/styleHelpers/getComponentStyle.ts @@ -16,7 +16,7 @@ const getArrayPath =

(path: ComponentPath

, props: object) = export const getComponentStyle =

( - path: ComponentPath

, + path: ComponentPath

| string, transformFn: (value: unknown) => string | number = (v) => v as string ) => (props: WithTheme) => { From 8e7ee0d0178bbcb659f16d417fb22dd304f8aa14 Mon Sep 17 00:00:00 2001 From: Simon Jentsch Date: Mon, 2 Dec 2024 23:47:57 +0100 Subject: [PATCH 5/5] Add slider export --- src/index.ts | 1 + src/styleHelpers/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 978a064..ea19f4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export * from './PopoverArrow'; export * from './Portal'; export * from './Radio'; export * from './Show'; +export * from './Slider'; export * from './SidebarNav'; export * from './Switch'; export * from './Tabs'; diff --git a/src/styleHelpers/index.ts b/src/styleHelpers/index.ts index 6a91d71..5d49407 100644 --- a/src/styleHelpers/index.ts +++ b/src/styleHelpers/index.ts @@ -2,5 +2,6 @@ export type InterpolateFn = (props: any) => T; export * from './getSpacing'; export * from './getComponentStyle'; +export * from './componentPrimitive'; export * from './conditionalStyles'; export * from './breakpoint';