Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .agents/skills/bump/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
name: 'bump'
description: 'Bump package versions and update CHANGELOG.md so consumers can see what happens between releases. Use when a feature or fix branch is ready.'
---

# Changelog & version bump

`CHANGELOG.md` at the monorepo root tells package consumers what changed. Every branch that changes a published package (`styles`, `react`, `icons`) must bump the affected versions and document the changes before being merged.

## Workflow

1. **Identify affected packages**: `git diff origin/main...HEAD --stat` and `git log origin/main..HEAD` show which packages changed. Ignore packages with no consumer-facing change.
2. **Bump versions**: increment the patch number (`0.0.x` + 1) in each affected `<package>/package.json`. The repository convention is one bump per feature branch, folded into the feature commit.
3. **Align internal peer dependencies**: if a package now relies on something introduced in another package of the same branch (e.g. `react` consuming new `styles` CSS classes), update the corresponding `peerDependencies` range (e.g. `"@ippon-ui/styles": "~0.0.7"`).
4. **Reinstall**: run `mise setup` so the lockfile stays consistent.
5. **Update `CHANGELOG.md`**: add a release entry right after the introduction, above the previous entries.
6. **Verify**: `mise build`, `mise lint-ci` and `mise test-unit-ci` must pass.

## Release entry format

```markdown
## YYYY-MM-DD — @ippon-ui/styles X.Y.Z · @ippon-ui/react X.Y.Z

### Added

- `component` organism: what it brings to the consumer.

### Changed

- What changed and what it means for the consumer.
```

- The heading lists only the packages bumped by the branch, each with its new version, separated by `·`.
- Use the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) categories: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. Only include the categories that apply.
- One line per change, in English, consumer-focused: describe what the consumer gets or must adapt, not internal details (CI, tests, tooling stay out unless they affect consumers).
- Wrap component, class, prop and token names in backticks.

## Conventions

- A same branch usually produces a single entry, updated as the branch evolves.
- The changelog update and the version bumps belong to the feature commit (branches follow a single squashed commit convention).
- Never rewrite past release entries; a correction is a new entry.
31 changes: 31 additions & 0 deletions .agents/skills/pattern-library/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,37 @@ In HTML:
</button>
```

## Ion

Beyond CAP, a component can expose an **ion**: a class that starts with `---`, followed by the name of the ion in `kebab-case` (e.g. `ippon-dropdown---buttons`).

An ion is set on a container and _ionizes_ the descendants that opt in. Unlike a part, the ionized behavior is **not declared by the container**: each affected component declares, in its own SCSS, how it reacts when it is ionized. This inverts the dependency, so a container never reaches into a descendant (no `> .ippon-child` selector).

An example with a `dropdown` organism that ionizes the `button` atom it contains:

The container carries the ion (in the `dropdown` mixin):

```html
<div class="ippon-dropdown ippon-dropdown---buttons" popover>
<button class="ippon-button -text">Item</button>
</div>
```

The atom declares its ionized behavior (in `atom/button/_button.scss`, **not** in the organism):

```scss
.ippon-button {
// button styles

.ippon-dropdown---buttons & {
justify-content: flex-start;
width: 100%;
}
}
```

Note: an ion is a naming convention, unrelated to the `atom/ion` component (which renders Ionicons icons).

## Tikui

To document with Tikui, simply include the component's Markdown file in the file where you want to document it.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Run from the **monorepo root** unless noted otherwise.
| Format (check only) | `mise format-ci` |
| Lint (fix) | `mise lint` |
| Lint (check only) | `mise lint-ci` |
| Unit tests (not interactive) | `mise unit-test-ci` |
| Unit tests (not interactive) | `mise test-unit-ci` |

## Pattern Library (`styles`)

Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

All notable changes to the Ippon UI packages are documented in this file, so consumers can see what happens between releases.

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-03 — @ippon-ui/styles 0.0.7 · @ippon-ui/react 0.0.6

### Added

- `dropdown` organism: floating action panel built on the native popover API, opened through a button using `command`/`commandfor`, anchored to its trigger and flipping to stay within the viewport.
- `separator` atom: thin dividing line, used to group dropdown items.
- `IpponDropdown` React component, behavior-free, forwarding `onKeyDown` and `onToggle`; the `KeyboardNavigation` Storybook story shows how to wire a select-like keyboard flow.
- `IpponSeparator` React component.
- `popoverTarget` and `popoverTargetAction` props on `IpponButton` to trigger popovers without JavaScript.
- Ion convention (`component---ion`): a class set on a container that ionizes descendants, each component declaring its own ionized behavior (see the Pattern Library documentation).

### Changed

- Buttons apply their hover style on `:focus-visible`, making keyboard focus as visible as mouse hover.

Changes released before this file was introduced are not listed here.
7 changes: 4 additions & 3 deletions react/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@ippon-ui/react",
"description": "Ippon UI React Component Library",
"version": "0.0.5",
"version": "0.0.6",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand All @@ -15,7 +15,8 @@
"build": "pnpm run build:lib && pnpm run build:storybook",
"build:lib": "tsc -b && vite build",
"build:storybook": "storybook build",
"lint": "eslint .",
"lint": "eslint . --fix",
"lint:ci": "eslint .",
"preview": "vite preview",
"test:unit": "vitest",
"test:unit:ci": "vitest run"
Expand All @@ -38,7 +39,7 @@
},
"peerDependencies": {
"@ippon-ui/icons": "~0.0.2",
"@ippon-ui/styles": "~0.0.5",
"@ippon-ui/styles": "~0.0.7",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
4 changes: 4 additions & 0 deletions react/src/IpponButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type IpponButtonVanillaProps = {
iconLeft?: IpponButtonIcon;
iconRight?: IpponButtonIcon;
onClick?: () => void | Promise<void>;
popoverTarget?: string;
popoverTargetAction?: 'toggle' | 'show' | 'hide';
};

export type IpponButtonProps = DataSelectableWithChildren<IpponButtonVanillaProps>;
Expand Down Expand Up @@ -79,6 +81,8 @@ export const IpponButton = (props: IpponButtonProps) => {
disabled={isDisabled}
aria-busy={loading || undefined}
data-selector={props.dataSelector}
popoverTarget={props.popoverTarget}
popoverTargetAction={props.popoverTargetAction}
onClick={handleClick}
>
<OptionalButtonIcon icon={props.iconLeft} />
Expand Down
23 changes: 23 additions & 0 deletions react/src/IpponDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { clsx } from 'clsx';
import type { KeyboardEventHandler, ToggleEventHandler } from 'react';
import type { DataSelectableWithChildren } from './DataSelectable.ts';

type IpponDropdownProps = DataSelectableWithChildren<{
id: string;
className?: string;
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
onToggle?: ToggleEventHandler<HTMLDivElement>;
}>;

export const IpponDropdown = (props: IpponDropdownProps) => (
<div
id={props.id}
popover="auto"
className={clsx('ippon-dropdown', 'ippon-dropdown---buttons', props.className)}
data-selector={props.dataSelector}
onKeyDown={props.onKeyDown}
onToggle={props.onToggle}
>
{props.children}
</div>
);
10 changes: 10 additions & 0 deletions react/src/IpponSeparator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { clsx } from 'clsx';
import type { DataSelectable } from './DataSelectable.ts';

type IpponSeparatorProps = DataSelectable<{
className?: string;
}>;

export const IpponSeparator = (props: IpponSeparatorProps) => (
<hr className={clsx('ippon-separator', props.className)} data-selector={props.dataSelector} />
);
2 changes: 2 additions & 0 deletions react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ export { IpponButton } from './IpponButton.tsx';
export { IpponButtonCard } from './IpponButtonCard.tsx';
export { IpponCard } from './IpponCard.tsx';
export { IpponContainer } from './IpponContainer.tsx';
export { IpponDropdown } from './IpponDropdown.tsx';
export { IpponGrid, IpponGridSlot } from './IpponGrid.tsx';
export { IpponHSpace, IpponHSpaceSlot } from './IpponHSpace.tsx';
export { IpponIcon } from './IpponIcon.tsx';
export { IpponImportFile } from './IpponImportFile.tsx';
export { IpponIon } from './IpponIon.tsx';
export { IpponMeter } from './IpponMeter.tsx';
export { IpponProgress } from './IpponProgress.tsx';
export { IpponSeparator } from './IpponSeparator.tsx';
export { IpponText } from './IpponText.tsx';
export { IpponTitle } from './IpponTitle.tsx';
export { IpponVSpace, IpponVSpaceSlot } from './IpponVSpace.tsx';
Expand Down
115 changes: 115 additions & 0 deletions react/stories/IpponDropdown.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { KeyboardEvent, ToggleEvent } from 'react';
import { IpponDropdown } from '../src/IpponDropdown.tsx';
import { IpponButton } from '../src/IpponButton.tsx';
import { IpponSeparator } from '../src/IpponSeparator.tsx';

const moveFocusOnArrowKey = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}
const buttons = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'),
];
if (buttons.length === 0) {
return;
}
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
const delta = event.key === 'ArrowDown' ? 1 : -1;
buttons[(index + delta + buttons.length) % buttons.length].focus();
event.preventDefault();
};

const focusFirstButtonOnOpen = (event: ToggleEvent<HTMLElement>) => {
if (event.newState !== 'open') {
return;
}
event.currentTarget.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
};

const meta = {
title: 'Organism/Dropdown',
component: IpponDropdown,
args: {
id: 'dropdown',
},
argTypes: {
children: { control: false },
onKeyDown: { control: false },
onToggle: { control: false },
},
render: (args) => (
<>
<IpponButton
variant="outline"
popoverTarget={args.id}
popoverTargetAction="toggle"
iconRight={{ name: 'chevron-down' }}
>
Dropdown
</IpponButton>
<IpponDropdown {...args}>
<IpponButton variant="text" color="neutral" iconLeft={{ name: 'ellipse' }}>
Item
</IpponButton>
<IpponSeparator />
<IpponButton variant="text" color="error" iconLeft={{ name: 'trash' }}>
Destructive item
</IpponButton>
</IpponDropdown>
</>
),
} satisfies Meta<typeof IpponDropdown>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const KeyboardNavigation: Story = {
args: {
id: 'dropdown-keyboard',
onKeyDown: moveFocusOnArrowKey,
onToggle: focusFirstButtonOnOpen,
},
parameters: {
docs: {
description: {
story:
'The dropdown ships no keyboard behavior: it only forwards `onKeyDown` and `onToggle` to the panel. To get a select-like flow — focus the first item when the popover opens, then move with the arrow keys (wrap-around, disabled buttons skipped) — wire your own handlers:',
},
source: {
language: 'tsx',
code: `const moveFocusOnArrowKey = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}
const buttons = [
...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'),
];
if (buttons.length === 0) {
return;
}
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
const delta = event.key === 'ArrowDown' ? 1 : -1;
buttons[(index + delta + buttons.length) % buttons.length].focus();
event.preventDefault();
};

const focusFirstButtonOnOpen = (event: ToggleEvent<HTMLElement>) => {
if (event.newState !== 'open') {
return;
}
event.currentTarget.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
};

<IpponDropdown id="dropdown" onKeyDown={moveFocusOnArrowKey} onToggle={focusFirstButtonOnOpen}>
<IpponButton variant="text" color="neutral">
Item
</IpponButton>
</IpponDropdown>;`,
},
},
},
};
24 changes: 24 additions & 0 deletions react/test/IpponButton.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,30 @@ describe('IpponButton', () => {
});
});

describe('Popover trigger', () => {
it('should not set popover attributes by default', () => {
render(<IpponButton dataSelector="ippon-button">Default</IpponButton>);

const button = getIpponButton();

expect(button).not.toHaveAttribute('popovertarget');
expect(button).not.toHaveAttribute('popovertargetaction');
});

it('should set popover target and action', () => {
render(
<IpponButton popoverTarget="menu" popoverTargetAction="toggle" dataSelector="ippon-button">
Open
</IpponButton>,
);

const button = getIpponButton();

expect(button).toHaveAttribute('popovertarget', 'menu');
expect(button).toHaveAttribute('popovertargetaction', 'toggle');
});
});

describe('Combinations', () => {
it('should combine color, variant and size', () => {
render(
Expand Down
Loading
Loading