diff --git a/CHANGELOG.md b/CHANGELOG.md index 03de627..84ebe0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the Ippon UI packages are documented in this file, so con The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), with one entry per release listing the affected package versions. +## 2026-07-07 — @ippon-ui/styles 0.0.8 · @ippon-ui/react 0.0.7 + +### Added + +- `button` atom and `button-card` organism can be used as links: an `` carrying the same classes renders without underline, and the Pattern Library documentation shows link examples. +- `IpponLinkButton` and `IpponLinkButtonCard` React components: link (``) counterparts of `IpponButton` and `IpponButtonCard`, sharing their look but exposing only link-relevant props (no `disabled`, loading or popover behavior). + ## 2026-07-03 — @ippon-ui/styles 0.0.7 · @ippon-ui/react 0.0.6 ### Added diff --git a/react/README.md b/react/README.md index 7036865..5801651 100644 --- a/react/README.md +++ b/react/README.md @@ -37,6 +37,14 @@ export const SaveButton = () => ( ); ``` +To navigate to another page instead of triggering a callback, use the link counterparts `IpponLinkButton` and `IpponLinkButtonCard`, which render an `` with the same look: + +```tsx +import { IpponLinkButton } from '@ippon-ui/react'; + +export const SettingsLink = () => Settings; +``` + ## Documentation Every component is documented in [Storybook](https://storybook.js.org). Stories live in diff --git a/react/package.json b/react/package.json index 14fc769..e42bdb8 100644 --- a/react/package.json +++ b/react/package.json @@ -1,7 +1,7 @@ { "name": "@ippon-ui/react", "description": "Ippon UI React Component Library", - "version": "0.0.6", + "version": "0.0.7", "license": "Apache-2.0", "repository": { "type": "git", @@ -39,7 +39,7 @@ }, "peerDependencies": { "@ippon-ui/icons": "~0.0.2", - "@ippon-ui/styles": "~0.0.7", + "@ippon-ui/styles": "~0.0.8", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/react/src/Button.tsx b/react/src/Button.tsx new file mode 100644 index 0000000..58b4612 --- /dev/null +++ b/react/src/Button.tsx @@ -0,0 +1,35 @@ +import type { IconClassic, IconLogo, IconVariant } from '@ippon-ui/icons'; +import type { IpponIonProps } from './IpponIon.tsx'; +import { IpponIon } from './IpponIon.tsx'; +import { clsx } from 'clsx'; + +export type IpponButtonIcon = { + name: IconClassic | IconLogo; + variant?: IconVariant; +}; + +export type IpponButtonColor = 'success' | 'error' | 'information' | 'warning' | 'neutral'; + +export type IpponButtonVariant = 'secondary' | 'outline' | 'text'; + +export type IpponButtonSize = 'small' | 'large'; + +const ButtonIcon = ({ icon, loading }: { icon: IpponButtonIcon; loading?: boolean }) => ( + +); + +export const OptionalButtonIcon = ({ + icon, + loading, +}: { + icon?: IpponButtonIcon; + loading?: boolean; +}) => { + if (icon === undefined) { + return null; + } + return ; +}; diff --git a/react/src/IpponButton.tsx b/react/src/IpponButton.tsx index 85a8040..c8d3de6 100644 --- a/react/src/IpponButton.tsx +++ b/react/src/IpponButton.tsx @@ -1,22 +1,15 @@ import type { DataSelectableWithChildren } from './DataSelectable.ts'; -import type { IconClassic, IconLogo, IconVariant } from '@ippon-ui/icons'; -import type { IpponIonProps } from './IpponIon.tsx'; -import { IpponIon } from './IpponIon.tsx'; +import type { + IpponButtonColor, + IpponButtonIcon, + IpponButtonSize, + IpponButtonVariant, +} from './Button.tsx'; +import { OptionalButtonIcon } from './Button.tsx'; import { optionalToAlternativeClass } from './CAP.ts'; import { clsx } from 'clsx'; import { useState } from 'react'; -type IpponButtonIcon = { - name: IconClassic | IconLogo; - variant?: IconVariant; -}; - -type IpponButtonColor = 'success' | 'error' | 'information' | 'warning' | 'neutral'; - -type IpponButtonVariant = 'secondary' | 'outline' | 'text'; - -type IpponButtonSize = 'small' | 'large'; - type IpponButtonVanillaProps = { color?: IpponButtonColor; variant?: IpponButtonVariant; @@ -34,20 +27,6 @@ export type IpponButtonProps = DataSelectableWithChildren => value !== null && value !== undefined && typeof (value as Promise).then === 'function'; -const ButtonIcon = ({ icon, loading }: { icon: IpponButtonIcon; loading?: boolean }) => ( - -); - -const OptionalButtonIcon = ({ icon, loading }: { icon?: IpponButtonIcon; loading?: boolean }) => { - if (icon === undefined) { - return null; - } - return ; -}; - const LOADING_ICON: IpponButtonIcon = { name: 'sync' }; export const IpponButton = (props: IpponButtonProps) => { diff --git a/react/src/IpponLinkButton.tsx b/react/src/IpponLinkButton.tsx new file mode 100644 index 0000000..2fd87c7 --- /dev/null +++ b/react/src/IpponLinkButton.tsx @@ -0,0 +1,44 @@ +import type { DataSelectableWithChildren } from './DataSelectable.ts'; +import type { + IpponButtonColor, + IpponButtonIcon, + IpponButtonSize, + IpponButtonVariant, +} from './Button.tsx'; +import { OptionalButtonIcon } from './Button.tsx'; +import { optionalToAlternativeClass } from './CAP.ts'; +import { clsx } from 'clsx'; + +export type IpponLinkButtonProps = DataSelectableWithChildren<{ + href: string; + color?: IpponButtonColor; + variant?: IpponButtonVariant; + size?: IpponButtonSize; + iconLeft?: IpponButtonIcon; + iconRight?: IpponButtonIcon; +}>; + +export const IpponLinkButton = (props: IpponLinkButtonProps) => { + const hasIcon = [props.iconLeft, props.iconRight].some((icon) => icon !== undefined); + + return ( + + + {hasIcon && props.children ? ( + {props.children} + ) : ( + props.children + )} + + + ); +}; diff --git a/react/src/IpponLinkButtonCard.tsx b/react/src/IpponLinkButtonCard.tsx new file mode 100644 index 0000000..e1be3b8 --- /dev/null +++ b/react/src/IpponLinkButtonCard.tsx @@ -0,0 +1,36 @@ +import type { DataSelectableWithChildren } from './DataSelectable.ts'; +import { + optionalToAlternativeClass, + optionalToPrefixedAlternativeClass, + toAlternativeClass, +} from './CAP.ts'; +import { clsx } from 'clsx'; +import type { IpponCardColor, IpponCardShadowLevel, IpponCardSize } from './Card.ts'; + +export type IpponLinkButtonCardProps = DataSelectableWithChildren<{ + href: string; + shadow?: IpponCardShadowLevel; + border?: boolean; + size?: IpponCardSize; + color?: IpponCardColor; + fullWidth?: boolean; +}>; + +export const IpponLinkButtonCard = (props: IpponLinkButtonCardProps) => ( + + {props.children} + +); diff --git a/react/src/index.ts b/react/src/index.ts index c048a3c..87e0c69 100644 --- a/react/src/index.ts +++ b/react/src/index.ts @@ -9,6 +9,8 @@ export { IpponHSpace, IpponHSpaceSlot } from './IpponHSpace.tsx'; export { IpponIcon } from './IpponIcon.tsx'; export { IpponImportFile } from './IpponImportFile.tsx'; export { IpponIon } from './IpponIon.tsx'; +export { IpponLinkButton } from './IpponLinkButton.tsx'; +export { IpponLinkButtonCard } from './IpponLinkButtonCard.tsx'; export { IpponMeter } from './IpponMeter.tsx'; export { IpponProgress } from './IpponProgress.tsx'; export { IpponSeparator } from './IpponSeparator.tsx'; diff --git a/react/stories/IpponLinkButton.stories.tsx b/react/stories/IpponLinkButton.stories.tsx new file mode 100644 index 0000000..f91532d --- /dev/null +++ b/react/stories/IpponLinkButton.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { IpponLinkButton } from '../src/IpponLinkButton.tsx'; + +const meta = { + title: 'Atom/LinkButton', + component: IpponLinkButton, + args: { + children: 'Link button', + href: '#', + }, + argTypes: { + color: { + control: 'select', + options: ['success', 'error', 'information', 'warning', 'neutral'], + }, + variant: { + control: 'select', + options: ['secondary', 'outline', 'text'], + }, + size: { + control: 'select', + options: ['small', 'large'], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Variants: Story = { + render: (args) => ( + <> + + Primary + {' '} + + Secondary + {' '} + + Outline + {' '} + + Text + + + ), +}; + +export const WithIcons: Story = { + args: { + color: 'information', + iconLeft: { name: 'open' }, + iconRight: { name: 'chevron-forward' }, + }, +}; diff --git a/react/stories/IpponLinkButtonCard.stories.tsx b/react/stories/IpponLinkButtonCard.stories.tsx new file mode 100644 index 0000000..cd7c27e --- /dev/null +++ b/react/stories/IpponLinkButtonCard.stories.tsx @@ -0,0 +1,43 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { IpponLinkButtonCard } from '../src/IpponLinkButtonCard.tsx'; +import { IpponText } from '../src/IpponText.tsx'; + +const meta = { + title: 'Organism/LinkButtonCard', + component: IpponLinkButtonCard, + args: { + children: Card that navigates to a page, + href: '#', + }, + argTypes: { + children: { control: false }, + shadow: { + control: 'select', + options: [undefined, 'l1', 'l2', 'l3', 'l4', 'l5', 'l6'], + }, + size: { + control: 'select', + options: [undefined, 'small', 'large'], + }, + color: { + control: 'select', + options: [undefined, 'success', 'error', 'information', 'warning'], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { border: true }, +}; + +export const WithShadow: Story = { + args: { shadow: 'l2' }, +}; + +export const FullWidth: Story = { + args: { border: true, fullWidth: true }, +}; diff --git a/react/test/IpponLinkButton.spec.tsx b/react/test/IpponLinkButton.spec.tsx new file mode 100644 index 0000000..3be9d15 --- /dev/null +++ b/react/test/IpponLinkButton.spec.tsx @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { render, screen, configure, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IpponLinkButton } from '../src'; + +configure({ + testIdAttribute: 'data-selector', +}); + +const getIpponLinkButton = () => screen.getByTestId('ippon-link-button'); + +describe('IpponLinkButton', () => { + afterEach(cleanup); + + it('should render a link with button classes', () => { + render( + + Settings + , + ); + + const link = getIpponLinkButton(); + + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/settings'); + expect(link).toHaveClass('ippon-button'); + expect(link).toHaveTextContent('Settings'); + }); + + describe('Variant', () => { + it.each(['secondary', 'outline', 'text'] as const)('should be %s', (variant) => { + render( + + {variant} + , + ); + + expect(getIpponLinkButton()).toHaveClass('ippon-button', `-${variant}`); + }); + }); + + describe('Color', () => { + it.each(['success', 'error', 'information', 'warning', 'neutral'] as const)( + 'should be %s', + (color) => { + render( + + {color} + , + ); + + expect(getIpponLinkButton()).toHaveClass('ippon-button', `-${color}`); + }, + ); + }); + + describe('Size', () => { + it('should not have size class by default', () => { + render( + + Default + , + ); + + expect(getIpponLinkButton()).not.toHaveClass('-small'); + expect(getIpponLinkButton()).not.toHaveClass('-large'); + }); + + it.each(['small', 'large'] as const)('should be %s', (size) => { + render( + + {size} + , + ); + + expect(getIpponLinkButton()).toHaveClass(`-${size}`); + }); + }); + + describe('Icons', () => { + it('should wrap children in text part with icons', () => { + render( + + With icons + , + ); + + const link = getIpponLinkButton(); + + expect(link.querySelector('.ippon-button--text')).toHaveTextContent('With icons'); + expect(link.querySelectorAll('.ippon-button--icon')).toHaveLength(2); + }); + + it('should not have text part without icons', () => { + render( + + No icon + , + ); + + expect(getIpponLinkButton().querySelector('.ippon-button--text')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/react/test/IpponLinkButtonCard.spec.tsx b/react/test/IpponLinkButtonCard.spec.tsx new file mode 100644 index 0000000..df2e9f1 --- /dev/null +++ b/react/test/IpponLinkButtonCard.spec.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, configure, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { IpponLinkButtonCard } from '../src'; + +configure({ + testIdAttribute: 'data-selector', +}); + +const getIpponLinkButtonCard = () => screen.getByTestId('ippon-link-button-card'); + +describe('IpponLinkButtonCard', () => { + afterEach(cleanup); + + it('should render a link with button card classes', () => { + render( + + Content + , + ); + + const link = getIpponLinkButtonCard(); + + expect(link.tagName).toBe('A'); + expect(link).toHaveAttribute('href', '/details'); + expect(link).toHaveClass('ippon-button-card'); + expect(link).toHaveTextContent('Content'); + }); + + describe('Shadow', () => { + it.each(['l1', 'l2', 'l3', 'l4', 'l5', 'l6'] as const)('should have shadow %s', (level) => { + render( + + Content + , + ); + + expect(getIpponLinkButtonCard()).toHaveClass(`-shadow-${level}`); + }); + }); + + describe('Border', () => { + it('should not have -border class by default', () => { + render( + + Content + , + ); + + expect(getIpponLinkButtonCard()).not.toHaveClass('-border'); + }); + + it('should have -border class when border is present', () => { + render( + + Content + , + ); + + expect(getIpponLinkButtonCard()).toHaveClass('-border'); + }); + }); + + describe('Full width', () => { + it('should have -full-width class when fullWidth is present', () => { + render( + + Content + , + ); + + expect(getIpponLinkButtonCard()).toHaveClass('-full-width'); + }); + }); + + describe('Size', () => { + it.each(['small', 'large'] as const)('should be %s', (size) => { + render( + + Content + , + ); + + expect(getIpponLinkButtonCard()).toHaveClass(`-${size}`); + }); + }); + + describe('Color', () => { + it.each(['success', 'error', 'information', 'warning'] as const)('should be %s', (color) => { + render( + + Content + , + ); + + expect(getIpponLinkButtonCard()).toHaveClass(`-${color}`); + }); + }); +}); diff --git a/styles/package.json b/styles/package.json index 4c109b9..2f06787 100644 --- a/styles/package.json +++ b/styles/package.json @@ -1,6 +1,6 @@ { "name": "@ippon-ui/styles", - "version": "0.0.7", + "version": "0.0.8", "description": "Ippon UI Pattern Library", "repository": { "type": "git", diff --git a/styles/src/atom/atom.pug b/styles/src/atom/atom.pug index 20cc74d..ec36831 100644 --- a/styles/src/atom/atom.pug +++ b/styles/src/atom/atom.pug @@ -15,7 +15,7 @@ block content .tikui-vertical-spacing--line include:componentDoc(height=620) badge/badge.md .tikui-vertical-spacing--line - include:componentDoc(height=680) button/button.md + include:componentDoc(height=740) button/button.md .tikui-vertical-spacing--line include:componentDoc(height=50) icon/icon.md .tikui-vertical-spacing--line diff --git a/styles/src/atom/button/_button.scss b/styles/src/atom/button/_button.scss index d309766..4ae3c28 100644 --- a/styles/src/atom/button/_button.scss +++ b/styles/src/atom/button/_button.scss @@ -52,6 +52,7 @@ background-color: var(--_background-color); padding: var(--_inner-padding-vertical) var(--_inner-padding-horizontal); vertical-align: middle; + text-decoration: none; color: var(--_color); cursor: pointer; diff --git a/styles/src/atom/button/button.code.pug b/styles/src/atom/button/button.code.pug index 40648a6..bd43add 100644 --- a/styles/src/atom/button/button.code.pug +++ b/styles/src/atom/button/button.code.pug @@ -21,6 +21,12 @@ mixin ippon-button-example-colors each color in colors +ippon-button-example-variants({ color }) +mixin ippon-button-example-links + - const variants = [null, 'secondary', 'outline', 'text']; + each variant in variants + +ippon-button({ href: '#', variant: variant }) + = `Link ${variant || 'primary'}` + mixin ippon-button-example-icons(size) +ippon-button({ size, icon: { name: 'heart' } }) +ippon-button({ size, variant: 'secondary', iconLeft: { name: 'logo-angular' } }) Icon left @@ -36,3 +42,4 @@ mixin ippon-button-example-icons(size) +ippon-button-example-icons('small') +ippon-button-example-icons +ippon-button-example-icons('large') ++ippon-button-example-links diff --git a/styles/src/atom/button/button.md b/styles/src/atom/button/button.md index f3debe2..9964df2 100644 --- a/styles/src/atom/button/button.md +++ b/styles/src/atom/button/button.md @@ -21,6 +21,10 @@ - Loading `-loading` (adds `disabled` and `aria-busy="true"`) - When a right icon is present, replace it with a `sync` icon (the CSS animation is handled by `-loading`) +**Link:** + +Use an `` tag instead of a `