diff --git a/packages/assets/src/scss/_input.scss b/packages/assets/src/scss/_input.scss index f3143939..25a0a67b 100644 --- a/packages/assets/src/scss/_input.scss +++ b/packages/assets/src/scss/_input.scss @@ -1,7 +1,7 @@ @use 'sass:map'; @use 'functions' as *; @use 'variables' as *; -@use 'mixins/icons' as mixins; +@use 'mixins/inputs' as inputs-mixins; $input-sizes: ( 'small': ( @@ -15,11 +15,7 @@ $input-sizes: ( ); .ids-input { - border-radius: $border-radius-medium; - background: var(--ids-input-default-bg-color, #{$color-neutral-10}); - border: calculateRem(1px) solid var(--ids-input-default-border-color, #{$color-neutral-80}); - color: var(--ids-input-default-text-color, #{$color-neutral-240}); - outline: 0; + @include inputs-mixins.input-base; @each $name, $properties in $input-sizes { &--#{$name} { @@ -27,33 +23,4 @@ $input-sizes: ( padding: map.get($properties, 'padding'); } } - - &.ids-input--error { - background: var(--ids-input-error-bg-color, #{$color-error-20}); - border-color: var(--ids-input-error-border-color, #{$color-error-80}); - color: var(--ids-input-error-text-color, #{$color-error-90}); - } - - &.ids-input--disabled, - &.disabled, - &[disabled], - &:disabled { - color: var(--ids-input-disabled-text-color, #{$color-neutral-150}); - background: var(--ids-input-disabled-bg-color, #{$color-neutral-40}); - border-color: var(--ids-input-disabled-border-color, #{$color-neutral-80}); - pointer-events: none; - } - - &:hover { - border-color: var(--ids-input-hover-border-color, #{$color-primary-80}); - } - - &:active { - border-color: var(--ids-input-active-border-color, #{$color-primary-90}); - } - - &:focus { - border-color: var(--ids-input-focus-border-color, #{$color-primary-80}); - box-shadow: var(--ids-input-focus-box-shadow, #{$box-shadow-focus-primary}); - } } diff --git a/packages/assets/src/scss/inputs/_alt-radio.scss b/packages/assets/src/scss/inputs/_alt-radio.scss new file mode 100644 index 00000000..5d8d4356 --- /dev/null +++ b/packages/assets/src/scss/inputs/_alt-radio.scss @@ -0,0 +1,27 @@ +@use '../functions' as *; +@use '../variables' as *; +@use '../mixins/inputs' as inputs-mixins; +@use '../mixins/utils' as utils-mixins; + +.ids-alt-radio { + &__source { + @include utils-mixins.hidden; + } + + &__tile { + @include inputs-mixins.input-base; + + & { + cursor: pointer; + padding: calculateRem(8px) calculateRem(16px); + } + + &--checked { + background-color: var(--ids-input-checked-bg-color, #{$color-neutral-50}); + } + + &--focused { + @include inputs-mixins.input-focus; + } + } +} diff --git a/packages/assets/src/scss/mixins/_inputs.scss b/packages/assets/src/scss/mixins/_inputs.scss new file mode 100644 index 00000000..204d6790 --- /dev/null +++ b/packages/assets/src/scss/mixins/_inputs.scss @@ -0,0 +1,43 @@ +@use '../functions' as *; +@use '../variables' as *; + +@mixin input-focus { + border-color: var(--ids-input-focus-border-color, #{$color-primary-80}); + box-shadow: var(--ids-input-focus-box-shadow, #{$box-shadow-focus-primary}); +} + +@mixin input-base { + border-radius: $border-radius-medium; + background: var(--ids-input-default-bg-color, #{$color-neutral-10}); + border: calculateRem(1px) solid var(--ids-input-default-border-color, #{$color-neutral-80}); + color: var(--ids-input-default-text-color, #{$color-neutral-240}); + outline: 0; + + &--error { + background: var(--ids-input-error-bg-color, #{$color-error-20}); + border-color: var(--ids-input-error-border-color, #{$color-error-80}); + color: var(--ids-input-error-text-color, #{$color-error-90}); + } + + &--disabled, + &.disabled, + &[disabled], + &:disabled { + color: var(--ids-input-disabled-text-color, #{$color-neutral-150}); + background: var(--ids-input-disabled-bg-color, #{$color-neutral-40}); + border-color: var(--ids-input-disabled-border-color, #{$color-neutral-80}); + pointer-events: none; + } + + &:hover { + border-color: var(--ids-input-hover-border-color, #{$color-primary-80}); + } + + &:active { + border-color: var(--ids-input-active-border-color, #{$color-primary-90}); + } + + &:focus { + @include input-focus; + } +} diff --git a/packages/assets/src/scss/mixins/_utils.scss b/packages/assets/src/scss/mixins/_utils.scss new file mode 100644 index 00000000..98eb736d --- /dev/null +++ b/packages/assets/src/scss/mixins/_utils.scss @@ -0,0 +1,8 @@ +@use '../functions' as *; + +@mixin hidden { + width: 0; + height: 0; + overflow: hidden; + opacity: 0; +} diff --git a/packages/assets/src/scss/styles.scss b/packages/assets/src/scss/styles.scss index ee8748aa..79062698 100644 --- a/packages/assets/src/scss/styles.scss +++ b/packages/assets/src/scss/styles.scss @@ -16,6 +16,7 @@ @use 'inputs-list'; @use 'label'; +@use 'inputs/alt-radio'; @use 'inputs/checkbox'; @use 'inputs/input-text'; @use 'inputs/radio-button'; diff --git a/packages/components/src/inputs/AltRadio/AltRadio.stories.tsx b/packages/components/src/inputs/AltRadio/AltRadio.stories.tsx new file mode 100644 index 00000000..f8508f8c --- /dev/null +++ b/packages/components/src/inputs/AltRadio/AltRadio.stories.tsx @@ -0,0 +1,87 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { action } from 'storybook/actions'; + +import { AltRadioStateful } from './AltRadio'; + +const meta: Meta = { + component: AltRadioStateful, + parameters: { + layout: 'centered', + }, + tags: ['autodocs', 'foundation', 'inputs'], + argTypes: { + className: { + control: 'text', + }, + tileClassName: { + control: 'text', + }, + title: { + control: 'text', + }, + }, + args: { + label: '1:1', + name: 'default-input', + onBlur: action('on-blur'), + onChange: action('on-change'), + onFocus: action('on-focus'), + onInput: action('on-input'), + }, + decorators: [ + (Story) => { + return ( +
+ + + ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + name: 'Empty', +}; + +export const EmptyDisabled: Story = { + name: 'Empty (Disabled)', + args: { + disabled: true, + }, +}; + +export const EmptyError: Story = { + name: 'Empty (Error)', + args: { + error: true, + }, +}; + +export const Checked: Story = { + name: 'Checked', + args: { + checked: true, + }, +}; + +export const CheckedDisabled: Story = { + name: 'Checked (Disabled)', + args: { + disabled: true, + checked: true, + }, +}; + +export const CheckedError: Story = { + name: 'Checked (Error)', + args: { + error: true, + checked: true, + }, +}; diff --git a/packages/components/src/inputs/AltRadio/AltRadio.test.stories.tsx b/packages/components/src/inputs/AltRadio/AltRadio.test.stories.tsx new file mode 100644 index 00000000..9f4bbc8c --- /dev/null +++ b/packages/components/src/inputs/AltRadio/AltRadio.test.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from 'storybook/test'; + +import { AltRadioStateful } from './AltRadio'; + +const meta: Meta = { + component: AltRadioStateful, + parameters: { + layout: 'centered', + }, + tags: ['!dev'], + args: { + label: '1:1', + name: 'default-input', + onBlur: fn(), + onChange: fn(), + onFocus: fn(), + onInput: fn(), + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Default', + play: async ({ canvasElement, step, args }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('button'); + + await step('Radio Button handles focus event', async () => { + await expect(args.onFocus).not.toHaveBeenCalled(); + + await userEvent.click(input); + + await expect(args.onFocus).toHaveBeenCalledOnce(); + await expect(args.onChange).toHaveBeenCalledOnce(); + await expect(args.onInput).toHaveBeenCalledOnce(); + }); + + await step('Radio Button handles blur event', async () => { + await expect(args.onBlur).not.toHaveBeenCalled(); + + await userEvent.click(canvasElement); + + await expect(args.onBlur).toHaveBeenCalledOnce(); + }); + }, +}; diff --git a/packages/components/src/inputs/AltRadio/AltRadio.tsx b/packages/components/src/inputs/AltRadio/AltRadio.tsx new file mode 100644 index 00000000..81e1b4a4 --- /dev/null +++ b/packages/components/src/inputs/AltRadio/AltRadio.tsx @@ -0,0 +1,70 @@ +import React, { useRef, useState } from 'react'; + +import BaseChoiceInput from '@ids-internal/partials/BaseChoiceInput'; +import { createCssClassNames } from '@ibexa/ids-core/helpers/cssClassNames'; +import withStateChecked from '@ids-internal/hoc/withStateChecked'; + +import { AltRadioProps } from './AltRadio.types'; + +const AltRadio = ({ className = '', label, tileClassName = '', title = '', ...inputProps }: AltRadioProps) => { + const { checked = false, disabled = false, error = false, onBlur, onChange, onFocus, onInput } = inputProps; + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + const altRadioClassName = createCssClassNames({ + 'ids-alt-radio': true, + [className]: !!className, + }); + const altRadioTileClassName = createCssClassNames({ + 'ids-alt-radio__tile': true, + 'ids-alt-radio__tile--checked': checked, + 'ids-alt-radio__tile--disabled': disabled, + 'ids-alt-radio__tile--error': error, + 'ids-alt-radio__tile--focused': isFocused, + [tileClassName]: !!tileClassName, + }); + const onTileClick = () => { + inputRef.current?.focus(); + + if (!checked) { + onChange?.(true); + onInput?.(true); + } + }; + const onInputFocus = (event: React.FocusEvent) => { + setIsFocused(true); + onFocus?.(event); + }; + const onInputBlur = (event: React.FocusEvent) => { + setIsFocused(false); + onBlur?.(event); + }; + + return ( +
+
+ { + inputRef.current = node; + + if (typeof inputProps.ref === 'function') { + inputProps.ref(node); + } else if (inputProps.ref) { + inputProps.ref.current = node; // eslint-disable-line no-param-reassign + } + }} + type="radio" + /> +
+
+ {label} +
+
+ ); +}; + +export default AltRadio; + +export const AltRadioStateful = withStateChecked(AltRadio); diff --git a/packages/components/src/inputs/AltRadio/AltRadio.types.ts b/packages/components/src/inputs/AltRadio/AltRadio.types.ts new file mode 100644 index 00000000..931190f5 --- /dev/null +++ b/packages/components/src/inputs/AltRadio/AltRadio.types.ts @@ -0,0 +1,8 @@ +import { ReactNode } from 'react'; + +import { BaseChoiceInputProps } from '@ids-internal/partials/BaseChoiceInput'; + +export interface AltRadioProps extends Omit { + label: ReactNode; + tileClassName?: string; +} diff --git a/packages/components/src/inputs/AltRadio/index.ts b/packages/components/src/inputs/AltRadio/index.ts new file mode 100644 index 00000000..a3daf31b --- /dev/null +++ b/packages/components/src/inputs/AltRadio/index.ts @@ -0,0 +1,6 @@ +import AltRadio, { AltRadioStateful } from './AltRadio'; +import { AltRadioProps } from './AltRadio.types'; + +export default AltRadio; +export { AltRadioStateful }; +export type { AltRadioProps };