@plastic-js/tsumiki 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +838 -65
- package/dist/components/Accordion.js +93 -4
- package/dist/components/Avatar.js +96 -4
- package/dist/components/BottomSheet.js +131 -0
- package/dist/components/Button.js +194 -0
- package/dist/components/CardNumberInput.js +28 -44
- package/dist/components/Carousel.js +71 -5
- package/dist/components/Checkbox.js +178 -4
- package/dist/components/Clipboard.js +135 -5
- package/dist/components/CloseButton.js +97 -0
- package/dist/components/Collapsible.js +22 -4
- package/dist/components/ColorPicker.js +327 -4
- package/dist/components/Combobox.js +86 -13
- package/dist/components/ConfirmDialog.js +174 -19
- package/dist/components/DateInput.js +437 -5
- package/dist/components/{DatePickerMobile/wheel.js → DateWheel.js} +1 -3
- package/dist/components/Dialog.js +214 -110
- package/dist/components/Drawer.js +169 -4
- package/dist/components/Field.js +121 -5
- package/dist/components/Fieldset.js +64 -5
- package/dist/components/FileUpload.js +181 -4
- package/dist/components/FocusTrap.js +0 -2
- package/dist/components/Icon.js +32 -17
- package/dist/components/Input.js +211 -0
- package/dist/components/Listbox.js +47 -4
- package/dist/components/Menu.js +99 -4
- package/dist/components/MoneyInput.js +33 -85
- package/dist/components/NumberInput.js +117 -4
- package/dist/components/Pagination.js +91 -5
- package/dist/components/Popover.js +123 -4
- package/dist/components/Portal.js +0 -2
- package/dist/components/Presence.js +0 -2
- package/dist/components/Progress.js +107 -4
- package/dist/components/RadioGroup.js +104 -4
- package/dist/components/RatingGroup.js +75 -4
- package/dist/components/SearchInput.js +60 -55
- package/dist/components/Select.js +428 -5
- package/dist/components/SelectPc.js +239 -0
- package/dist/components/SignaturePad.js +77 -4
- package/dist/components/Skeleton.js +2 -4
- package/dist/components/Slider.js +112 -4
- package/dist/components/Spinner.js +41 -0
- package/dist/components/Splitter.js +51 -4
- package/dist/components/Steps.js +259 -4
- package/dist/components/SwipeReveal.js +2 -4
- package/dist/components/Switch.js +147 -4
- package/dist/components/Tabs.js +80 -4
- package/dist/components/TagsInput.js +128 -4
- package/dist/components/Toast.js +174 -4
- package/dist/components/Toggle.js +39 -4
- package/dist/components/ToggleGroup.js +103 -4
- package/dist/components/Tour.js +63 -4
- package/dist/components/TreeView.js +113 -4
- package/dist/index.js +44 -45
- package/dist/tsumiki.css +1 -0
- package/docs/api.md +670 -0
- package/docs/component-api-pattern.md +362 -0
- package/package.json +16 -6
- package/src/styles/color.css +112 -0
- package/src/styles/index.css +1 -0
- package/src/styles/radius.css +27 -0
- package/src/styles/reset.css +111 -0
- package/src/styles/shadow.css +5 -0
- package/src/styles/sizing.css +59 -0
- package/src/styles/spacing.css +9 -0
- package/src/styles/theme.css +38 -0
- package/src/styles/tokens.css +22 -0
- package/src/styles/transition.css +5 -0
- package/src/styles/typography.css +8 -0
- package/dist/components/Accordion.js.map +0 -1
- package/dist/components/Avatar.js.map +0 -1
- package/dist/components/CardNumberInput.js.map +0 -1
- package/dist/components/Carousel.js.map +0 -1
- package/dist/components/Checkbox.js.map +0 -1
- package/dist/components/Clipboard.js.map +0 -1
- package/dist/components/Collapsible.js.map +0 -1
- package/dist/components/ColorPicker.js.map +0 -1
- package/dist/components/Combobox.js.map +0 -1
- package/dist/components/ConfirmDialog.js.map +0 -1
- package/dist/components/DateInput.js.map +0 -1
- package/dist/components/DatePicker.js +0 -7
- package/dist/components/DatePicker.js.map +0 -1
- package/dist/components/DatePickerMobile/index.js +0 -479
- package/dist/components/DatePickerMobile/index.js.map +0 -1
- package/dist/components/DatePickerMobile/wheel.js.map +0 -1
- package/dist/components/Dialog.js.map +0 -1
- package/dist/components/Drawer.js.map +0 -1
- package/dist/components/Field.js.map +0 -1
- package/dist/components/Fieldset.js.map +0 -1
- package/dist/components/FileUpload.js.map +0 -1
- package/dist/components/FilterableSelect.js +0 -210
- package/dist/components/FilterableSelect.js.map +0 -1
- package/dist/components/FilterableSelectMobile.js +0 -167
- package/dist/components/FilterableSelectMobile.js.map +0 -1
- package/dist/components/FocusTrap.js.map +0 -1
- package/dist/components/HoverCard.js +0 -7
- package/dist/components/HoverCard.js.map +0 -1
- package/dist/components/Icon.js.map +0 -1
- package/dist/components/Listbox.js.map +0 -1
- package/dist/components/Menu.js.map +0 -1
- package/dist/components/MoneyInput.js.map +0 -1
- package/dist/components/NumberInput.js.map +0 -1
- package/dist/components/Pagination.js.map +0 -1
- package/dist/components/Popover.js.map +0 -1
- package/dist/components/Portal.js.map +0 -1
- package/dist/components/Presence.js.map +0 -1
- package/dist/components/Progress.js.map +0 -1
- package/dist/components/RadioGroup.js.map +0 -1
- package/dist/components/RatingGroup.js.map +0 -1
- package/dist/components/SearchInput.js.map +0 -1
- package/dist/components/Select.js.map +0 -1
- package/dist/components/SelectMobile/anatomy.js +0 -10
- package/dist/components/SelectMobile/anatomy.js.map +0 -1
- package/dist/components/SelectMobile/index.js +0 -310
- package/dist/components/SelectMobile/index.js.map +0 -1
- package/dist/components/SignaturePad.js.map +0 -1
- package/dist/components/Skeleton.js.map +0 -1
- package/dist/components/Slider.js.map +0 -1
- package/dist/components/Splitter.js.map +0 -1
- package/dist/components/Steps.js.map +0 -1
- package/dist/components/SwipeReveal.js.map +0 -1
- package/dist/components/Switch.js.map +0 -1
- package/dist/components/Tabs.js.map +0 -1
- package/dist/components/TagsInput.js.map +0 -1
- package/dist/components/Toast.js.map +0 -1
- package/dist/components/Toggle.js.map +0 -1
- package/dist/components/ToggleGroup.js.map +0 -1
- package/dist/components/Tooltip.js +0 -7
- package/dist/components/Tooltip.js.map +0 -1
- package/dist/components/Tour.js.map +0 -1
- package/dist/components/TreeView.js.map +0 -1
- package/dist/node_modules/framesync/dist/es/create-render-step.js +0 -54
- package/dist/node_modules/framesync/dist/es/create-render-step.js.map +0 -1
- package/dist/node_modules/framesync/dist/es/index.js +0 -61
- package/dist/node_modules/framesync/dist/es/index.js.map +0 -1
- package/dist/node_modules/framesync/dist/es/on-next-frame.js +0 -8
- package/dist/node_modules/framesync/dist/es/on-next-frame.js.map +0 -1
- package/dist/node_modules/hey-listen/dist/hey-listen.es.js +0 -15
- package/dist/node_modules/hey-listen/dist/hey-listen.es.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/generators/decay.js +0 -24
- package/dist/node_modules/popmotion/dist/es/animations/generators/decay.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/generators/keyframes.js +0 -40
- package/dist/node_modules/popmotion/dist/es/animations/generators/keyframes.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/generators/spring.js +0 -96
- package/dist/node_modules/popmotion/dist/es/animations/generators/spring.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/index.js +0 -94
- package/dist/node_modules/popmotion/dist/es/animations/index.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/inertia.js +0 -74
- package/dist/node_modules/popmotion/dist/es/animations/inertia.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/utils/detect-animation-from-options.js +0 -21
- package/dist/node_modules/popmotion/dist/es/animations/utils/detect-animation-from-options.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/utils/elapsed.js +0 -14
- package/dist/node_modules/popmotion/dist/es/animations/utils/elapsed.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/animations/utils/find-spring.js +0 -68
- package/dist/node_modules/popmotion/dist/es/animations/utils/find-spring.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/easing/index.js +0 -28
- package/dist/node_modules/popmotion/dist/es/easing/index.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/easing/utils.js +0 -13
- package/dist/node_modules/popmotion/dist/es/easing/utils.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/clamp.js +0 -6
- package/dist/node_modules/popmotion/dist/es/utils/clamp.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/hsla-to-rgba.js +0 -35
- package/dist/node_modules/popmotion/dist/es/utils/hsla-to-rgba.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/inc.js +0 -6
- package/dist/node_modules/popmotion/dist/es/utils/inc.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/interpolate.js +0 -69
- package/dist/node_modules/popmotion/dist/es/utils/interpolate.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/mix-color.js +0 -45
- package/dist/node_modules/popmotion/dist/es/utils/mix-color.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/mix-complex.js +0 -61
- package/dist/node_modules/popmotion/dist/es/utils/mix-complex.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/mix.js +0 -6
- package/dist/node_modules/popmotion/dist/es/utils/mix.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/pipe.js +0 -7
- package/dist/node_modules/popmotion/dist/es/utils/pipe.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/progress.js +0 -9
- package/dist/node_modules/popmotion/dist/es/utils/progress.js.map +0 -1
- package/dist/node_modules/popmotion/dist/es/utils/velocity-per-second.js +0 -8
- package/dist/node_modules/popmotion/dist/es/utils/velocity-per-second.js.map +0 -1
- package/dist/node_modules/popmotion/node_modules/tslib/tslib.es6.js +0 -13
- package/dist/node_modules/popmotion/node_modules/tslib/tslib.es6.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/color/hex.js +0 -39
- package/dist/node_modules/style-value-types/dist/es/color/hex.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/color/hsla.js +0 -16
- package/dist/node_modules/style-value-types/dist/es/color/hsla.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/color/index.js +0 -20
- package/dist/node_modules/style-value-types/dist/es/color/index.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/color/rgba.js +0 -15
- package/dist/node_modules/style-value-types/dist/es/color/rgba.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/color/utils.js +0 -19
- package/dist/node_modules/style-value-types/dist/es/color/utils.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/complex/index.js +0 -58
- package/dist/node_modules/style-value-types/dist/es/complex/index.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/numbers/index.js +0 -13
- package/dist/node_modules/style-value-types/dist/es/numbers/index.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/numbers/units.js +0 -16
- package/dist/node_modules/style-value-types/dist/es/numbers/units.js.map +0 -1
- package/dist/node_modules/style-value-types/dist/es/utils.js +0 -13
- package/dist/node_modules/style-value-types/dist/es/utils.js.map +0 -1
package/README.md
CHANGED
|
@@ -8,21 +8,35 @@ A UI component library for Plastic JS.
|
|
|
8
8
|
npm install @plastic-js/tsumiki
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
## Styles
|
|
12
|
+
|
|
13
|
+
Import the bundled stylesheet — a single file (`tsumiki.css`) containing the design tokens (`--tsu-*` CSS custom properties) **and** the reset:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import '@plastic-js/tsumiki/styles'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Every component is styled with `--tsu-*` tokens, so this import is required for components to render correctly. If you bring your own reset, the reset portion of `tsumiki.css` is safe to keep anyway (it's minimal); to skip it entirely, compose your own stylesheet from the raw token files, e.g. `@plastic-js/tsumiki/styles/color.css`.
|
|
20
|
+
|
|
21
|
+
> Raw granular imports (`@plastic-js/tsumiki/styles/*.css`) reference Radix Colors scales via `@import '@radix-ui/colors/…'`, so they require your bundler to resolve that package — install it yourself (`npm i @radix-ui/colors`) if you go that route. The bundled `@plastic-js/tsumiki/styles` entry is fully self-contained and needs nothing extra.
|
|
22
|
+
|
|
23
|
+
> Custom theming: override any `--tsu-*` variable in your own CSS **after** the library styles (see §3.2). Your `:root` overrides apply to both light and dark mode; add a `.dark { … }` block for per-mode colors.
|
|
24
|
+
|
|
11
25
|
## Usage
|
|
12
26
|
|
|
13
27
|
```js
|
|
14
|
-
import { Dialog, Combobox,
|
|
28
|
+
import { Dialog, Combobox, Select } from '@plastic-js/tsumiki'
|
|
15
29
|
```
|
|
16
30
|
|
|
17
31
|
## Components
|
|
18
32
|
|
|
19
|
-
###
|
|
33
|
+
### Select
|
|
20
34
|
|
|
21
|
-
A mobile-optimized bottom-sheet select
|
|
35
|
+
A mobile-optimized bottom-sheet select. Renders a trigger button and a draggable sheet overlay — ideal for touch interfaces.
|
|
22
36
|
|
|
23
37
|
```jsx
|
|
24
38
|
import { createSignal } from '@plastic-js/plastic'
|
|
25
|
-
import {
|
|
39
|
+
import { Select, SelectTrigger } from '@plastic-js/tsumiki'
|
|
26
40
|
|
|
27
41
|
function Example(){
|
|
28
42
|
const value = createSignal(null)
|
|
@@ -32,19 +46,18 @@ function Example(){
|
|
|
32
46
|
]
|
|
33
47
|
|
|
34
48
|
return (
|
|
35
|
-
<
|
|
49
|
+
<Select
|
|
36
50
|
value={value}
|
|
37
51
|
onValueChange={v => value(v)}
|
|
38
52
|
items={items}
|
|
39
53
|
>
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
</SelectMobile.Root>
|
|
54
|
+
<SelectTrigger placeholder='Choose a city' />
|
|
55
|
+
</Select>
|
|
43
56
|
)
|
|
44
57
|
}
|
|
45
58
|
```
|
|
46
59
|
|
|
47
|
-
**
|
|
60
|
+
**Select props:**
|
|
48
61
|
|
|
49
62
|
| Prop | Type | Default | Description |
|
|
50
63
|
|---|---|---|---|
|
|
@@ -53,11 +66,16 @@ function Example(){
|
|
|
53
66
|
| `items` | `array \| (() => array)` | `[]` | Data array |
|
|
54
67
|
| `itemToValue` | `(item) => string` | `item.value` | Extract value from an item |
|
|
55
68
|
| `itemToLabel` | `(item) => node` | `item.label` | Extract label from an item |
|
|
56
|
-
| `open` | `(() => boolean)` | — | Controlled open state (getter) |
|
|
57
|
-
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
|
|
69
|
+
| `open` | `(() => boolean)` | — | Controlled open state (getter) — REQUIRED |
|
|
58
70
|
| `onOpenChange` | `(isOpen: boolean) => void` | — | Called when open state changes |
|
|
71
|
+
| `disabled` | `boolean` | `false` | Disable the select |
|
|
72
|
+
| `clearable` | `boolean` | `false` | Show a "clear selection" row at the top |
|
|
73
|
+
| `clearLabel` | `string` | `'None'` | Label for the clear row |
|
|
74
|
+
| `backdropClassName` | `string` | — | CSS class for the backdrop overlay |
|
|
75
|
+
| `backdropStyle` | `object` | — | Inline style for the backdrop overlay |
|
|
76
|
+
| `filter` | `boolean \| (item, query, itemToLabel) => boolean` | — | Enable filter: `true` for substring match, or a custom filter function |
|
|
59
77
|
|
|
60
|
-
**
|
|
78
|
+
**SelectTrigger props:**
|
|
61
79
|
|
|
62
80
|
| Prop | Type | Default | Description |
|
|
63
81
|
|---|---|---|---|
|
|
@@ -65,79 +83,834 @@ function Example(){
|
|
|
65
83
|
| `className` | `string` | — | CSS class for the trigger button |
|
|
66
84
|
| `children` | `node` | — | Custom trigger content (replaces default label + chevron) |
|
|
67
85
|
|
|
68
|
-
> **Note on Trigger element:** The trigger renders `<div role="button" tabIndex={0}>` instead of a native `<button>` as a defense-in-depth measure against a **Chrome iOS (WebKit) focus-lock bug**.
|
|
69
|
-
>
|
|
70
|
-
> The primary fix is in `SelectMobile.Content`: the sheet is rendered inline (no `<Portal>`) so it stays inside the parent Dialog's focus-trap boundary. However, the `<div role="button">` is kept as an additional safeguard — in WebKit, tapping a `<button>` inside a scrollable container with `-webkit-overflow-scrolling: touch` can pin `activeElement` to the button, causing subsequent `focus()` calls on the filter `<input>` to be silently ignored. A `<div role="button">` is semantically equivalent for accessibility but does not trigger this WebKit focus-lock behaviour.
|
|
71
|
-
>
|
|
72
|
-
> Typically a native `<button>` would be preferred for keyboard tab navigation (`tabIndex` works natively). On mobile, however, keyboard tab navigation is irrelevant — the sheet is operated via touch, and the Escape key for closing is handled by the `Dialog`-like overlay, not by tab order. The `tabIndex={0}` on the `<div>` preserves keyboard discoverability for assistive technology while working around the iOS focus trap.
|
|
86
|
+
> **Note on Trigger element:** The trigger renders `<div role="button" tabIndex={0}>` instead of a native `<button>` as a defense-in-depth measure against a **Chrome iOS (WebKit) focus-lock bug**. The sheet is rendered inline (no `<Portal>`) so it stays inside the parent Dialog's focus-trap boundary.
|
|
73
87
|
|
|
74
|
-
**
|
|
88
|
+
**Filter usage:**
|
|
75
89
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
| `backdropClassName` | `string` | — | CSS class for the backdrop overlay |
|
|
82
|
-
| `backdropStyle` | `object` | — | Inline style for the backdrop overlay |
|
|
90
|
+
```jsx
|
|
91
|
+
// Enable default substring filter
|
|
92
|
+
<Select filter items={cities} ...>
|
|
93
|
+
<SelectTrigger placeholder='Search city…' />
|
|
94
|
+
</Select>
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
// Custom filter function
|
|
97
|
+
<Select filter={(item, query, itemToLabel) => itemToLabel(item).toLowerCase().startsWith(query)} ...>
|
|
98
|
+
```
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
The search input is auto-focused when the sheet opens and cleared when it closes. "No results" is shown when no items match the query.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 1. Project Nature
|
|
105
|
+
|
|
106
|
+
**Tsumiki** (`@plastic-js/tsumiki`) is a mobile-first UI component library for the [Plastic JS](https://github.com/plastic-js/plastic) reactive framework. It provides 51 production-grade, accessible components built on top of headless UI primitives from Ark UI, styled with a Radix Colors-based design token system.
|
|
107
|
+
|
|
108
|
+
The name "tsumiki" (積み木) means "building blocks" in Japanese — the library is designed to be composable, tree-shakable, and themeable.
|
|
109
|
+
|
|
110
|
+
### Key characteristics
|
|
111
|
+
|
|
112
|
+
- **Mobile-first** — Components default to touch-friendly sizing, with progressive enhancement for desktop interactions
|
|
113
|
+
- **Accessible by default** — Powered by Zag.js state machines via Ark UI wrappers; all components meet WAI-ARIA authoring practices
|
|
114
|
+
- **Signal-reactive** — All props accept both static values and Plastic reactive getter functions (`() => value`), enabling zero-boilerplate reactive UIs
|
|
115
|
+
- **Design-token driven** — Every visual attribute (size, color, radius, spacing, typography) is exposed via CSS custom properties with the `--tsu-` prefix
|
|
116
|
+
- **Tree-shakable** — ESM build with `preserveModules: true`; consumers only bundle what they import
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 2. Technology Stack
|
|
121
|
+
|
|
122
|
+
| Layer | Library | Role |
|
|
123
|
+
|-------|---------|------|
|
|
124
|
+
| **State machines** | [@zag-js](https://zag-js.com/) | Accessible component logic (focus management, keyboard nav, ARIA attributes) |
|
|
125
|
+
| **Headless UI** | [@plastic-js/ark](https://github.com/plastic-js/ark) | Plastic JS wrappers around Zag.js machines, exposes composable `Root`/`Control`/`Label` etc. sub-components |
|
|
126
|
+
| **Reactive runtime** | [@plastic-js/plastic](https://github.com/plastic-js/plastic) | Signals, `createContext`/`useContext`, `mergeProps`/`splitProps`, JSX runtime |
|
|
127
|
+
| **CSS-in-JS** | [@emotion/css](https://emotion.sh/) | Zero-runtime `css()` template literals for static styles; CSS custom properties for dynamic values |
|
|
128
|
+
| **Color system** | [@radix-ui/colors](https://www.radix-ui.com/colors) | 12-step color scales with automatic light/dark mode via `prefers-color-scheme` |
|
|
129
|
+
| **Build tool** | [Vite](https://vitejs.dev/) | Dev server for `showcase/`, library build for `dist/`, Babel plugin for Plastic JSX transform |
|
|
130
|
+
| **JSX transform** | `@plastic-js/babel-preset-plastic` | Compiles JSX to `h()` calls with signal-aware reconciliation |
|
|
131
|
+
|
|
132
|
+
### Architecture diagram
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
@zag-js/* (state machines)
|
|
136
|
+
│
|
|
137
|
+
▼
|
|
138
|
+
@plastic-js/ark (headless primitives, Plastic-wrapped)
|
|
139
|
+
│
|
|
140
|
+
▼
|
|
141
|
+
tsumiki components (styled, sized, token-connected)
|
|
142
|
+
│
|
|
143
|
+
▼
|
|
144
|
+
Consumer application
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Ark component mapping
|
|
148
|
+
|
|
149
|
+
Every tsumiki component wraps a corresponding `@plastic-js/ark` primitive:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
import { Switch as ArkSwitch } from '@plastic-js/ark'
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The Ark import is always aliased with an `Ark` prefix to distinguish it from the tsumiki component being defined. All 36 Ark module components have corresponding tsumiki implementations, plus 15 additional custom/project-specific components (Button, Input, Skeleton, ConfirmDialog, mobile variants, etc.).
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 3. Design Token System
|
|
160
|
+
|
|
161
|
+
All tokens live in `src/styles/` and cascade from `reset.css` → `tokens.css` → sub-files. The published package ships one bundled stylesheet built from them (`@plastic-js/tsumiki/styles` → `dist/tsumiki.css`), built from `index.css`. Every token uses the `--tsu-` namespace.
|
|
162
|
+
|
|
163
|
+
### 3.1 File structure
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
src/styles/
|
|
167
|
+
├── index.css # Bundle entry: imports reset.css (→ tokens.css)
|
|
168
|
+
├── reset.css # Modern CSS reset, imports tokens.css
|
|
169
|
+
├── tokens.css # Aggregator: @import all sub-files + root tokens
|
|
170
|
+
├── theme.css # Radix Colors bridge (generated by `npm run theme`)
|
|
171
|
+
├── color.css # Semantic color tokens + legacy aliases
|
|
172
|
+
├── sizing.css # Component sizing (6 tiers × 2 level sequences)
|
|
173
|
+
├── radius.css # Border radius (L1/L2/L3 per size + round)
|
|
174
|
+
├── spacing.css # 8-step spacing scale
|
|
175
|
+
├── typography.css # Font sizes, family, line-height
|
|
176
|
+
├── transition.css # Timing presets
|
|
177
|
+
└── shadow.css # Elevation shadows
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 3.2 Color System
|
|
181
|
+
|
|
182
|
+
Powered by Radix Colors 12-step scales. The active theme (generated by `scripts/theme.js`) maps:
|
|
183
|
+
|
|
184
|
+
- **Accent**: Indigo scale → `--tsu-accent-1` through `--tsu-accent-12`
|
|
185
|
+
- **Neutral**: Olive scale → `--tsu-neutral-1` through `--tsu-neutral-12`
|
|
186
|
+
|
|
187
|
+
Additionally, semantic color categories alias Radix scales:
|
|
188
|
+
|
|
189
|
+
| Category | Radix Source | Token Range | Usage |
|
|
190
|
+
|----------|-------------|-------------|-------|
|
|
191
|
+
| `accent` | Indigo | `--tsu-accent-1..12` | Primary brand color, focus rings |
|
|
192
|
+
| `neutral` | Olive | `--tsu-neutral-1..12` | Text, surfaces, borders |
|
|
193
|
+
| `danger` | Red | `--tsu-danger-1..12` | Error states, destructive actions |
|
|
194
|
+
| `success` | Green | `--tsu-success-1..12` | Success states, positive feedback |
|
|
195
|
+
| `warning` | Amber | `--tsu-warning-1..12` | Warning states |
|
|
196
|
+
| `neutral-alpha` | Mauve Alpha | `--tsu-neutral-alpha-1..12` | Shadows, backdrops, translucent overlays |
|
|
197
|
+
|
|
198
|
+
#### Color scale semantics (standard Radix convention)
|
|
199
|
+
|
|
200
|
+
- Steps **1–2**: App background / subtle surfaces
|
|
201
|
+
- Steps **3–5**: Component backgrounds (hover, active, selected)
|
|
202
|
+
- Steps **6–8**: Borders, separators, subtle interactive states
|
|
203
|
+
- Steps **9–10**: Solid backgrounds (primary action, checked state)
|
|
204
|
+
- Steps **11–12**: High-contrast text, icons
|
|
205
|
+
|
|
206
|
+
#### Global surface/text primitives
|
|
207
|
+
|
|
208
|
+
```css
|
|
209
|
+
--tsu-bg: var(--tsu-neutral-1); /* Page & component surfaces */
|
|
210
|
+
--tsu-fg: var(--tsu-neutral-12); /* Primary text */
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
#### State tokens
|
|
214
|
+
|
|
215
|
+
```css
|
|
216
|
+
/* Disabled */
|
|
217
|
+
--tsu-disabled-bg: var(--tsu-neutral-3);
|
|
218
|
+
--tsu-disabled-fg: var(--tsu-neutral-9);
|
|
219
|
+
--tsu-disabled-border: var(--tsu-neutral-7);
|
|
220
|
+
|
|
221
|
+
/* Focus */
|
|
222
|
+
--tsu-focus-bg: var(--tsu-neutral-2);
|
|
223
|
+
--tsu-focus-border: var(--tsu-accent-8);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### Legacy aliases (backward compatibility)
|
|
227
|
+
|
|
228
|
+
```css
|
|
229
|
+
--ink: var(--tsu-fg);
|
|
230
|
+
--muted: var(--tsu-neutral-11);
|
|
231
|
+
--accent: var(--tsu-accent-9);
|
|
232
|
+
--surface: var(--tsu-bg);
|
|
233
|
+
--bg: var(--tsu-neutral-2);
|
|
234
|
+
--border: var(--tsu-neutral-7);
|
|
235
|
+
--danger: var(--tsu-danger-9);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### Dark mode
|
|
239
|
+
|
|
240
|
+
Radix Colors ships both light and dark variants. Dark values are applied when the `.dark` class is present on the root element (`<html>`):
|
|
241
|
+
|
|
242
|
+
```html
|
|
243
|
+
<html class="dark">
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Toggle it at runtime:
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
document.documentElement.classList.toggle('dark')
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
When `.dark` is active, every token re-resolves to the dark Radix scale automatically (`--tsu-neutral-1..12`, `--tsu-accent-1..12`, `--tsu-danger-*`, etc.). Tsumiki's theme-level dark overrides (e.g. the lighter page background) live under `:root.dark` in `src/styles/color.css`.
|
|
253
|
+
|
|
254
|
+
For native form controls (date pickers, scrollbars, etc.) to render in dark mode, also set `color-scheme: dark`:
|
|
255
|
+
|
|
256
|
+
```css
|
|
257
|
+
html.dark {
|
|
258
|
+
color-scheme: dark;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### 3.3 Sizing System
|
|
263
|
+
|
|
264
|
+
Six tiers (`xs` → `xxl`) with two level sequences:
|
|
265
|
+
|
|
266
|
+
| Tier | Primary Height | Secondary Height | Font Size | Line Height | Padding Y | Padding X |
|
|
267
|
+
|------|---------------|-------------------|-----------|-------------|-----------|-----------|
|
|
268
|
+
| **xs** | 32px | 16px | 12px | 16px | 7px | 8px |
|
|
269
|
+
| **sm** | 36px | 18px | 14px | 20px | 7px | 12px |
|
|
270
|
+
| **md** | 40px | 20px | 14px | 20px | 9px | 16px |
|
|
271
|
+
| **lg** | 44px | 22px | 16px | 24px | 9px | 20px |
|
|
272
|
+
| **xl** | 48px | 24px | 18px | 28px | 9px | 24px |
|
|
273
|
+
| **xxl** | 64px | 32px | 20px | 30px | 16px | 32px |
|
|
274
|
+
|
|
275
|
+
- **Primary height** (`--tsu-comp-height-{size}`): Used for buttons, inputs, selects — the full component height
|
|
276
|
+
- **Secondary height** (`--tsu-comp-secondary-height-{size}`): Used for checkboxes, radio indicators, switch thumbs — the inner control size
|
|
277
|
+
|
|
278
|
+
#### Token naming pattern
|
|
279
|
+
|
|
280
|
+
```
|
|
281
|
+
--tsu-comp-height-{size} # Full component height (e.g. Button)
|
|
282
|
+
--tsu-comp-secondary-height-{size} # Icon/indicator/thumb size (e.g. Checkbox control)
|
|
283
|
+
--tsu-comp-font-size-{size} # Component text size
|
|
284
|
+
--tsu-comp-line-height-{size} # Component text line height
|
|
285
|
+
--tsu-comp-padding-y-{size} # Vertical padding
|
|
286
|
+
--tsu-comp-padding-x-{size} # Horizontal padding
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### 3.4 Border Radius
|
|
290
|
+
|
|
291
|
+
Three nesting levels per size tier, plus a fully-round utility:
|
|
292
|
+
|
|
293
|
+
```
|
|
294
|
+
--tsu-radius-l1-{size} # Atomic: buttons, inputs, toggles (2px–16px)
|
|
295
|
+
--tsu-radius-l2-{size} # Card: cards, popovers, menus
|
|
296
|
+
--tsu-radius-l3-{size} # Modal: dialogs, drawers, bottom sheets
|
|
297
|
+
--tsu-radius-round # 9999px — pill shapes, avatars
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### 3.5 Spacing
|
|
301
|
+
|
|
302
|
+
8-step scale for gaps and margins:
|
|
303
|
+
|
|
304
|
+
| xxs | xs | sm | md | lg | xl | xxl |
|
|
305
|
+
|-----|----|----|----|----|----|-----|
|
|
306
|
+
| 2px | 4px | 8px | 12px | 16px | 24px | 32px |
|
|
307
|
+
|
|
308
|
+
Tokens: `--tsu-spacing-{step}`
|
|
309
|
+
|
|
310
|
+
### 3.6 Typography
|
|
311
|
+
|
|
312
|
+
General text sizes (separate from component font sizes):
|
|
313
|
+
|
|
314
|
+
| xs | sm | md | lg | xl | xxl |
|
|
315
|
+
|----|----|----|----|----|-----|
|
|
316
|
+
| 10px | 12px | 14px | 16px | 20px | 24px |
|
|
317
|
+
|
|
318
|
+
Global defaults:
|
|
319
|
+
```css
|
|
320
|
+
--tsu-font-family: system-ui, -apple-system, sans-serif;
|
|
321
|
+
--tsu-line-height: 1.5;
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### 3.7 Transitions
|
|
325
|
+
|
|
326
|
+
Three timing presets:
|
|
327
|
+
|
|
328
|
+
```css
|
|
329
|
+
--tsu-transition-fast: 0.15s ease; /* Quick interactions: hover, focus, toggle */
|
|
330
|
+
--tsu-transition-normal: 0.3s ease; /* Medium transitions: expand, slide */
|
|
331
|
+
--tsu-transition-overlay: 0.3s ease; /* Overlays/backdrops: dialog, drawer, popover */
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### 3.8 Shadows
|
|
335
|
+
|
|
336
|
+
Three elevation levels using alpha overlay tokens for automatic dark mode adaptation:
|
|
337
|
+
|
|
338
|
+
```css
|
|
339
|
+
--tsu-shadow-sm: 0 1px 2px var(--tsu-neutral-alpha-6);
|
|
340
|
+
--tsu-shadow-md: 0 4px 12px var(--tsu-neutral-alpha-8);
|
|
341
|
+
--tsu-shadow-lg: 0 8px 24px var(--tsu-neutral-alpha-10);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### 3.9 Z-Index Layers
|
|
345
|
+
|
|
346
|
+
```css
|
|
347
|
+
--tsu-z-base: 0; /* Default */
|
|
348
|
+
--tsu-z-dropdown: 100; /* Popovers, menus, listboxes */
|
|
349
|
+
--tsu-z-modal: 200; /* Dialogs, drawers */
|
|
350
|
+
--tsu-z-toast: 300; /* Toast notifications */
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 4. Controlled-Only Policy
|
|
356
|
+
|
|
357
|
+
Tsumiki enforces a **controlled-only** pattern for all components. Uncontrolled usage (via `defaultValue`, `defaultOpen`, `defaultChecked`, etc.) is **not supported** at the tsumiki component level, even if the underlying Ark component supports it.
|
|
358
|
+
|
|
359
|
+
### Rationale
|
|
360
|
+
|
|
361
|
+
- **Predictability** — Controlled state is explicit and deterministic; uncontrolled state creates sources of truth that are hard to reason about
|
|
362
|
+
- **Consistency** — Every component follows the same `value`/`onValueChange` pattern, making the API uniform
|
|
363
|
+
- **Testability** — Controlled components are easier to test because state is always owned by the consumer
|
|
364
|
+
|
|
365
|
+
### What this means
|
|
366
|
+
|
|
367
|
+
- Components take a `value` (or `checked`, `open`, etc.) signal as a controlled prop
|
|
368
|
+
- Components call an `onValueChange` (or `onCheckedChange`, `onOpenChange`, etc.) callback when the value changes
|
|
369
|
+
- No `defaultValue`, `defaultOpen`, `defaultChecked`, or similar props exist on tsumiki components
|
|
370
|
+
- If a consumer wants an initial value, they set it in their own `createSignal`:
|
|
371
|
+
|
|
372
|
+
```jsx
|
|
373
|
+
// CORRECT: Controlled
|
|
374
|
+
const isOpen = createSignal(false)
|
|
375
|
+
<Dialog open={isOpen} onOpenChange={v => isOpen(v.open)} />
|
|
376
|
+
|
|
377
|
+
// WRONG: Uncontrolled (not supported)
|
|
378
|
+
<Dialog defaultOpen />
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Enforcement
|
|
382
|
+
|
|
383
|
+
- The `src/components/Select.jsx` component **does not** accept `defaultOpen` — `open` is always required
|
|
384
|
+
- All Ark-wrapped components pass through their `...rest` props to the underlying Ark primitives. While the Ark primitive may technically accept `default*` props internally, consumers **must not** rely on this behavior as tsumiki reserves the right to break uncontrolled usage in future versions
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## 5. Component Authoring Patterns
|
|
389
|
+
|
|
390
|
+
Every tsumiki component follows a consistent set of conventions. Understanding these patterns is essential for adding new components or writing showcase pages.
|
|
391
|
+
|
|
392
|
+
### 5.1 Standard Import Block
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
import { css, keyframes } from '@emotion/css'
|
|
396
|
+
import { ComponentName as ArkComponentName } from '@plastic-js/ark'
|
|
397
|
+
import { mergeProps, splitProps } from '@plastic-js/plastic'
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- `css` / `keyframes` — from `@emotion/css` for static CSS generation
|
|
401
|
+
- Ark primitive — always aliased with `Ark` prefix to avoid name collision with the tsumiki component
|
|
402
|
+
- `mergeProps` / `splitProps` — from `@plastic-js/plastic` for layered defaults + prop splitting
|
|
403
|
+
- Additional imports (`createContext`, `useContext`) from `@plastic-js/plastic` for compound components
|
|
404
|
+
|
|
405
|
+
### 5.2 The `read()` Helper
|
|
406
|
+
|
|
407
|
+
Defined locally in every component file (not imported from a shared location):
|
|
408
|
+
|
|
409
|
+
```js
|
|
410
|
+
const read = (v) => typeof v === 'function' ? v() : v
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
All props can be either **static values** or **Plastic reactive getter functions**. The `read()` helper unwraps them:
|
|
414
|
+
|
|
415
|
+
```js
|
|
416
|
+
// Consumers can pass either:
|
|
417
|
+
<Switch size="md" /> // static
|
|
418
|
+
<Switch size={() => mySignal()} /> // reactive
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
This is used wherever a prop value is consumed outside of JSX attribute position.
|
|
422
|
+
|
|
423
|
+
### 5.3 Props Merging Pattern
|
|
424
|
+
|
|
425
|
+
```js
|
|
426
|
+
const [local, rest] = splitProps(
|
|
427
|
+
mergeProps({ size: 'md' /* defaults */ }, props),
|
|
428
|
+
['size', 'className', 'children' /* consumed props */],
|
|
429
|
+
)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
1. **`mergeProps(defaults, props)`** — layers defaults under user-provided props
|
|
433
|
+
2. **`splitProps(merged, keys)`** — separates props into `[consumed, rest]`
|
|
434
|
+
3. **`local`** — the tsumiki component's own props (size, className, children, etc.)
|
|
435
|
+
4. **`rest`** — everything else, forwarded to the Ark primitive via spread
|
|
436
|
+
|
|
437
|
+
### 5.4 CSS Architecture: Static Classes + Custom Properties
|
|
438
|
+
|
|
439
|
+
Tsumiki uses a hybrid approach:
|
|
440
|
+
|
|
441
|
+
#### Approach A: Direct token references (simple components)
|
|
442
|
+
|
|
443
|
+
```js
|
|
444
|
+
const sizeClasses = {
|
|
445
|
+
md: css({
|
|
446
|
+
height: 'var(--tsu-comp-height-md)',
|
|
447
|
+
padding: 'var(--tsu-comp-padding-y-md) var(--tsu-comp-padding-x-md)',
|
|
448
|
+
fontSize: 'var(--tsu-comp-font-size-md)',
|
|
449
|
+
}),
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
#### Approach B: Scoped custom properties (complex components)
|
|
454
|
+
|
|
455
|
+
Components that need to compute derived dimensions (e.g., track width = 2× secondary height) use private CSS variables:
|
|
456
|
+
|
|
457
|
+
```js
|
|
458
|
+
// Size class defines scoped variables
|
|
459
|
+
const sizeClasses = {
|
|
460
|
+
md: css({
|
|
461
|
+
'--_sw-track-height': 'var(--tsu-comp-secondary-height-md)',
|
|
462
|
+
'--_sw-track-width': 'calc(2 * var(--tsu-comp-secondary-height-md))',
|
|
463
|
+
'--_sw-thumb-travel': 'var(--tsu-comp-secondary-height-md)',
|
|
464
|
+
}),
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Base class references them with fallbacks
|
|
468
|
+
const controlClass = css({
|
|
469
|
+
height: 'var(--_sw-track-height, var(--tsu-comp-secondary-height-md))',
|
|
470
|
+
width: 'var(--_sw-track-width, calc(2 * var(--tsu-comp-secondary-height-md)))',
|
|
471
|
+
})
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
**Naming convention**: Private variables use `--_{initials}-` prefix:
|
|
475
|
+
- Button: `--_btn-*`
|
|
476
|
+
- Checkbox: `--_cb-*`
|
|
477
|
+
- Switch: `--_sw-*`
|
|
478
|
+
- Toggle: `--_tgl-*`
|
|
479
|
+
- Progress: `--_prog-*`
|
|
480
|
+
|
|
481
|
+
#### Variant / Intent pattern (Button style)
|
|
482
|
+
|
|
483
|
+
```js
|
|
484
|
+
const intentClasses = {
|
|
485
|
+
default: css({
|
|
486
|
+
'--_btn-solid': 'var(--tsu-accent-9)',
|
|
487
|
+
'--_btn-solid-active': 'var(--tsu-accent-8)',
|
|
488
|
+
'--_btn-border': 'var(--tsu-accent-9)',
|
|
489
|
+
}),
|
|
490
|
+
danger: css({
|
|
491
|
+
'--_btn-solid': 'var(--tsu-danger-9)',
|
|
492
|
+
'--_btn-solid-active': 'var(--tsu-danger-8)',
|
|
493
|
+
'--_btn-border': 'var(--tsu-danger-9)',
|
|
494
|
+
}),
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
### 5.5 Class Name Composition (Reactive)
|
|
499
|
+
|
|
500
|
+
```js
|
|
501
|
+
className={() => [
|
|
502
|
+
baseClass,
|
|
503
|
+
sizeClasses[read(local.size)],
|
|
504
|
+
read(local.rounded) && roundedClass,
|
|
505
|
+
local.className,
|
|
506
|
+
].filter(Boolean).join(' ')}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
This uses a **Plastic reactive getter function** so the className list is re-evaluated whenever signals change. Consumer-provided `className` is appended last for overrides.
|
|
510
|
+
|
|
511
|
+
### 5.6 Base Style Conventions
|
|
512
|
+
|
|
513
|
+
All interactive components share these base styles:
|
|
514
|
+
|
|
515
|
+
```js
|
|
516
|
+
const rootClass = css({
|
|
517
|
+
display: 'inline-flex',
|
|
518
|
+
alignItems: 'center',
|
|
519
|
+
cursor: 'pointer',
|
|
520
|
+
fontFamily: 'inherit',
|
|
521
|
+
userSelect: 'none',
|
|
522
|
+
touchAction: 'manipulation',
|
|
523
|
+
WebkitTapHighlightColor: 'transparent',
|
|
524
|
+
'&[data-disabled]': { cursor: 'not-allowed' },
|
|
525
|
+
})
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
Transitions always reference the design tokens:
|
|
529
|
+
```css
|
|
530
|
+
transition: 'background-color var(--tsu-transition-fast)'
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### 5.7 Disabled State Handling
|
|
534
|
+
|
|
535
|
+
Disabled is applied via the `disabled` prop on the Ark Root, which sets `data-disabled` attribute internally. Components style it with:
|
|
536
|
+
|
|
537
|
+
```css
|
|
538
|
+
'&[data-disabled]': {
|
|
539
|
+
opacity: 0.5, /* or use --tsu-disabled-* tokens */
|
|
540
|
+
cursor: 'not-allowed',
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### 5.8 Focus State Styling
|
|
545
|
+
|
|
546
|
+
Focus states use the focus tokens defined in `color.css`:
|
|
547
|
+
|
|
548
|
+
```css
|
|
549
|
+
'&:focus-visible': {
|
|
550
|
+
backgroundColor: 'var(--tsu-focus-bg)',
|
|
551
|
+
outline: '2px solid var(--tsu-focus-border)',
|
|
552
|
+
outlineOffset: '2px',
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
### 5.9 Compound Component Pattern
|
|
557
|
+
|
|
558
|
+
Complex components (Menu, Dialog, Accordion, etc.) expose tsumiki-wrapped sub-components as **named exports**. The default export is always the root render component:
|
|
559
|
+
|
|
560
|
+
```jsx
|
|
561
|
+
import { Menu as ArkMenu } from '@plastic-js/ark'
|
|
562
|
+
|
|
563
|
+
// Default export: the root wrapper
|
|
564
|
+
const Menu = (props = {}) => (
|
|
565
|
+
<ArkMenu.Root className={rootClass} {...rest}>
|
|
566
|
+
{children}
|
|
567
|
+
</ArkMenu.Root>
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
// Named exports: tsumiki-wrapped sub-components
|
|
571
|
+
export const MenuTrigger = (props = {}) => (
|
|
572
|
+
<ArkMenu.Trigger className={triggerClass} {...rest}>
|
|
573
|
+
{children}
|
|
574
|
+
</ArkMenu.Trigger>
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
export const MenuContent = (props = {}) => (
|
|
578
|
+
<ArkMenu.Content className={contentClass} {...rest}>
|
|
579
|
+
{children}
|
|
580
|
+
</ArkMenu.Content>
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
Menu.origin = {
|
|
584
|
+
Root: ArkMenu.Root,
|
|
585
|
+
Trigger: ArkMenu.Trigger,
|
|
586
|
+
Content: ArkMenu.Content,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export default Menu
|
|
590
|
+
export { Menu }
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Sub-components are **never** attached as static properties on the default export — no `Menu.Trigger = ...`, no `Object.assign`.
|
|
594
|
+
|
|
595
|
+
Self-contained components like Progress auto-render their internals (Track + Range), exposing only Progress + ProgressLabel:
|
|
596
|
+
|
|
597
|
+
```jsx
|
|
598
|
+
<Progress size="md" value={75} label="Loading" />
|
|
599
|
+
// or with ProgressLabel as child
|
|
600
|
+
<Progress size="md" value={75}>
|
|
601
|
+
<ProgressLabel>Loading</ProgressLabel>
|
|
602
|
+
</Progress>
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
Internal state sharing uses `createContext` / `useContext` from `@plastic-js/plastic`:
|
|
606
|
+
|
|
607
|
+
```js
|
|
608
|
+
const ProgressContext = createContext()
|
|
609
|
+
// In ProgressRoot:
|
|
610
|
+
<ProgressContext.Provider value={{ size: read(size) }}>
|
|
611
|
+
// In child:
|
|
612
|
+
const ctx = useContext(ProgressContext)
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
### 5.10 Ark Pass-through Export (the `.origin` escape hatch)
|
|
616
|
+
|
|
617
|
+
Components that wrap Ark primitives (Checkbox, Dialog, Switch, etc.) expose the raw Ark sub-components via the `.origin` object. `.origin` is the **only** static property on the component — it maps tsumiki-friendly names to their raw `@plastic-js/ark` counterparts:
|
|
618
|
+
|
|
619
|
+
```js
|
|
620
|
+
import { Checkbox as ArkCheckbox } from '@plastic-js/ark'
|
|
621
|
+
|
|
622
|
+
const Checkbox = (props = {}) => (
|
|
623
|
+
<ArkCheckbox.Root className={rootClass} {...rest}>
|
|
624
|
+
{children}
|
|
625
|
+
</ArkCheckbox.Root>
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
Checkbox.origin = {
|
|
629
|
+
Root: ArkCheckbox.Root,
|
|
630
|
+
Control: ArkCheckbox.Control,
|
|
631
|
+
Indicator: ArkCheckbox.Indicator,
|
|
632
|
+
Label: ArkCheckbox.Label,
|
|
633
|
+
HiddenInput: ArkCheckbox.HiddenInput,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export default Checkbox
|
|
637
|
+
export { Checkbox }
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
Key rules:
|
|
641
|
+
|
|
642
|
+
- `.origin` (lowercase) is an **object**, never a component — do not write `Component.origin = Root`
|
|
643
|
+
- Only mount what exists on the Ark component
|
|
644
|
+
- No `Object.assign`; the component carries nothing except `.origin`
|
|
645
|
+
- Raw Ark parts live in `.origin`; tsumiki-wrapped sub-components are named exports
|
|
646
|
+
|
|
647
|
+
#### Consumer usage
|
|
648
|
+
|
|
649
|
+
```jsx
|
|
650
|
+
{/* Primary usage: tsumiki-styled */}
|
|
651
|
+
<Checkbox>Accept terms</Checkbox>
|
|
652
|
+
|
|
653
|
+
{/* Escape hatch: raw Ark sub-parts */}
|
|
654
|
+
<Checkbox.origin.Root>
|
|
655
|
+
<Checkbox.origin.Control>
|
|
656
|
+
<Checkbox.origin.Indicator />
|
|
657
|
+
</Checkbox.origin.Control>
|
|
658
|
+
</Checkbox.origin.Root>
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
#### File-level example (Dialog.jsx)
|
|
662
|
+
|
|
663
|
+
```js
|
|
664
|
+
import { Dialog as ArkDialog } from '@plastic-js/ark'
|
|
665
|
+
|
|
666
|
+
const Dialog = (props = {}) => {
|
|
667
|
+
const [local, rest] = splitProps(props, ['className'])
|
|
668
|
+
return <ArkDialog.Root {...rest} className={[rootClass, local.className].filter(Boolean).join(' ')} />
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
Dialog.origin = {
|
|
672
|
+
Root: ArkDialog.Root,
|
|
673
|
+
Backdrop: ArkDialog.Backdrop,
|
|
674
|
+
Positioner: ArkDialog.Positioner,
|
|
675
|
+
Content: ArkDialog.Content,
|
|
676
|
+
Title: ArkDialog.Title,
|
|
677
|
+
Description: ArkDialog.Description,
|
|
678
|
+
CloseTrigger: ArkDialog.CloseTrigger,
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export default Dialog
|
|
682
|
+
export { Dialog }
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Higher-level presets (like `DrawerContent`) are named exports, never static properties.
|
|
686
|
+
|
|
687
|
+
### 5.11 Component Structure Summary
|
|
688
|
+
|
|
689
|
+
| Pattern | Example | When to use |
|
|
690
|
+
|---------|---------|-------------|
|
|
691
|
+
| Simple single-component | Switch, Button, Toggle, Tabs | One interactive element |
|
|
692
|
+
| Self-contained compound | Progress, Steps | Auto-renders internals; exposes only the root (+ optional named parts) |
|
|
693
|
+
| Multi-part with named exports | Menu, Accordion, TreeView, Drawer | Tsumiki-wrapped sub-components exported by name |
|
|
694
|
+
| Ark pass-through + `.origin` | Checkbox, Dialog, Switch | Wraps Ark primitives; raw Ark parts exposed via `.origin` object |
|
|
695
|
+
| Pure re-export | Portal, Presence, FocusTrap | No styling, direct passthrough |
|
|
696
|
+
|
|
697
|
+
### 5.11.1 Forbidden Patterns
|
|
698
|
+
|
|
699
|
+
Do **not** use `Object.assign` to assemble components, and do **not** attach sub-components as static properties:
|
|
700
|
+
|
|
701
|
+
```js
|
|
702
|
+
// ❌ Forbidden — Object.assign spreading sub-components
|
|
703
|
+
const Component = Object.assign(Root, { Trigger: X, Content: Y })
|
|
704
|
+
|
|
705
|
+
// ❌ Forbidden — static sub-properties
|
|
706
|
+
Component.Root = Component
|
|
707
|
+
Component.Trigger = SomeTrigger
|
|
708
|
+
|
|
709
|
+
// ✅ Approved: only .origin (object mapping to raw Ark parts) + named exports
|
|
710
|
+
Component.origin = { Root: ArkPrimitive.Root, Trigger: ArkPrimitive.Trigger }
|
|
711
|
+
export const ComponentTrigger = ArkPrimitive.Trigger
|
|
712
|
+
export default Root
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
The component function carries **nothing** except `.origin`. Any tsumiki-wrapped sub-components must be named exports — never mount points.
|
|
716
|
+
|
|
717
|
+
### 5.12 File Organization
|
|
718
|
+
|
|
719
|
+
```
|
|
720
|
+
src/components/
|
|
721
|
+
├── Accordion.jsx
|
|
722
|
+
├── Avatar.jsx
|
|
723
|
+
├── Button.jsx
|
|
724
|
+
├── Checkbox.jsx
|
|
725
|
+
├── Dialog.jsx
|
|
726
|
+
├── ...
|
|
727
|
+
├── Select.jsx
|
|
728
|
+
└── index.js # Barrel export file
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
All components are single `.jsx` files.
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
## 6. Responsive Design
|
|
736
|
+
|
|
737
|
+
### Mobile-first approach
|
|
738
|
+
|
|
739
|
+
Components default to mobile-appropriate sizing. Desktop adaptations are layered on top:
|
|
740
|
+
|
|
741
|
+
```css
|
|
742
|
+
/* Default: bottom sheet on mobile */
|
|
743
|
+
'@media (min-width: 640px)': {
|
|
744
|
+
position: 'fixed',
|
|
745
|
+
top: '50%', left: '50%',
|
|
746
|
+
transform: 'translate(-50%, -50%)',
|
|
747
|
+
/* Centered modal on desktop */
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
The breakpoint (`640px`) is hard-coded per component, not currently a design token.
|
|
92
752
|
|
|
93
753
|
---
|
|
94
754
|
|
|
95
|
-
|
|
755
|
+
## 7. Showcase Application
|
|
96
756
|
|
|
97
|
-
|
|
757
|
+
### Structure
|
|
758
|
+
|
|
759
|
+
```
|
|
760
|
+
showcase/
|
|
761
|
+
├── index.html
|
|
762
|
+
├── global.css # Showcase-specific styles
|
|
763
|
+
├── App.jsx # SPA shell with client-side routing
|
|
764
|
+
└── pages/
|
|
765
|
+
├── ButtonPage.jsx
|
|
766
|
+
├── SwitchPage.jsx
|
|
767
|
+
└── ...
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
### Routing
|
|
771
|
+
|
|
772
|
+
Pure client-side SPA via `window.history.pushState` — no router library. Routes are defined in `allShowcases` array in `App.jsx`:
|
|
773
|
+
|
|
774
|
+
```js
|
|
775
|
+
{ href: '/button', label: 'Button', page: ButtonPage }
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
### Page Template Pattern
|
|
779
|
+
|
|
780
|
+
Every showcase page follows this structure:
|
|
98
781
|
|
|
99
782
|
```jsx
|
|
100
783
|
import { createSignal } from '@plastic-js/plastic'
|
|
101
|
-
import
|
|
784
|
+
import ComponentName from '../../src/components/ComponentName.jsx'
|
|
102
785
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{ value: 'kxg', label: 'Kaohsiung' },
|
|
108
|
-
]
|
|
786
|
+
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl']
|
|
787
|
+
|
|
788
|
+
function ComponentNamePage() {
|
|
789
|
+
const someSignal = createSignal(defaultValue)
|
|
109
790
|
|
|
110
791
|
return (
|
|
111
|
-
<
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
792
|
+
<div className='container'>
|
|
793
|
+
{/* Hero */}
|
|
794
|
+
<div className='hero'>
|
|
795
|
+
<p className='eyebrow'>Category</p>
|
|
796
|
+
<h1>Component Name</h1>
|
|
797
|
+
<p className='hero-copy'>Description. Uses <span className='tag'>--tsu-*</span> tokens.</p>
|
|
798
|
+
</div>
|
|
799
|
+
|
|
800
|
+
{/* Sizes demo */}
|
|
801
|
+
<div className='feature-card'>
|
|
802
|
+
<p className='demo-label'>Sizes</p>
|
|
803
|
+
<div className='demo-row'>
|
|
804
|
+
{SIZES.map(s => <Component key={s} size={s} />)}
|
|
805
|
+
</div>
|
|
806
|
+
</div>
|
|
807
|
+
|
|
808
|
+
{/* Variants demo */}
|
|
809
|
+
<div className='feature-card'>
|
|
810
|
+
<p className='demo-label'>Variants</p>
|
|
811
|
+
<div className='demo-row'>
|
|
812
|
+
{/* ... */}
|
|
813
|
+
</div>
|
|
814
|
+
</div>
|
|
815
|
+
|
|
816
|
+
{/* Interactive playground */}
|
|
817
|
+
<div className='feature-card'>
|
|
818
|
+
<p className='demo-label'>Interactive</p>
|
|
819
|
+
<div className='demo-check-row'>
|
|
820
|
+
<label>
|
|
821
|
+
<select className='demo-select' onChange={...}>
|
|
822
|
+
{SIZES.map(s => <option value={s}>{s}</option>)}
|
|
823
|
+
</select>
|
|
824
|
+
</label>
|
|
825
|
+
</div>
|
|
826
|
+
<Component size={sizeSignal} ... />
|
|
827
|
+
</div>
|
|
828
|
+
</div>
|
|
119
829
|
)
|
|
120
830
|
}
|
|
831
|
+
|
|
832
|
+
export default ComponentNamePage
|
|
121
833
|
```
|
|
122
834
|
|
|
123
|
-
|
|
835
|
+
### Showcase CSS conventions
|
|
124
836
|
|
|
125
|
-
|
|
|
126
|
-
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
837
|
+
| Class | Purpose |
|
|
838
|
+
|-------|---------|
|
|
839
|
+
| `.container` | Page padding (`0 16px 16px`) |
|
|
840
|
+
| `.hero` / `.eyebrow` / `.hero-copy` | Page header with category label |
|
|
841
|
+
| `.feature-card` | Demo section card (`border`, `border-radius`, `padding: 24px`) |
|
|
842
|
+
| `.demo-label` | Section label (12px, uppercase, `letter-spacing: 0.06em`, `color: var(--tsu-neutral-11)`) |
|
|
843
|
+
| `.demo-row` | Horizontal flex layout (`display: flex; flex-wrap: wrap; gap: 12px; align-items: center`) |
|
|
844
|
+
| `.demo-check-row` | Vertical flex for playground controls |
|
|
845
|
+
| `.demo-select` | Styled `<select>` for interactive size/variant pickers |
|
|
846
|
+
| `.tag` | Inline code-like label for token references (monospace, accent background) |
|
|
847
|
+
|
|
848
|
+
---
|
|
849
|
+
|
|
850
|
+
## 8. Theme Customization
|
|
851
|
+
|
|
852
|
+
### For consumers
|
|
853
|
+
|
|
854
|
+
Override any `--tsu-*` CSS custom property at any level of specificity:
|
|
855
|
+
|
|
856
|
+
```css
|
|
857
|
+
:root {
|
|
858
|
+
--tsu-accent-9: #your-color;
|
|
859
|
+
--tsu-comp-height-md: 44px;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.my-component {
|
|
863
|
+
--tsu-radius-round: 12px;
|
|
864
|
+
}
|
|
865
|
+
```
|
|
138
866
|
|
|
139
|
-
|
|
867
|
+
### Regenerating the default theme (maintainers only)
|
|
868
|
+
|
|
869
|
+
`npm run theme` runs `scripts/theme.js` — an interactive CLI for picking new accent and neutral colors. It regenerates `src/styles/theme.css`, which is the **default theme shipped with the library**. This tool is internal, for library maintainers or anyone forking the library; it is **not** part of the published package.
|
|
870
|
+
|
|
871
|
+
**Consumers never need this.** To theme the library in your own app, override the `--tsu-*` variables after importing the styles (see "For consumers" above).
|
|
872
|
+
|
|
873
|
+
### Adding new colors (maintainers only)
|
|
874
|
+
|
|
875
|
+
Add to `src/styles/color.css` following the existing pattern:
|
|
876
|
+
|
|
877
|
+
```css
|
|
878
|
+
--tsu-info-1: var(--tsu-blue-1);
|
|
879
|
+
--tsu-info-2: var(--tsu-blue-2);
|
|
880
|
+
/* ... */
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Then define the Radix scale in `theme.css` by importing the desired color:
|
|
884
|
+
|
|
885
|
+
```css
|
|
886
|
+
@import '@radix-ui/colors/blue.css';
|
|
887
|
+
@import '@radix-ui/colors/blue-dark.css';
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
## 9. Adding a New Component — Checklist
|
|
893
|
+
|
|
894
|
+
1. **Create `src/components/NewComponent.jsx`** following the patterns in §5
|
|
895
|
+
2. **Export from `src/index.js`** — add to the barrel export
|
|
896
|
+
3. **Create `showcase/pages/NewComponentPage.jsx`** following the template in §7
|
|
897
|
+
4. **Register the route** in `showcase/App.jsx` `allShowcases` array
|
|
898
|
+
5. **Verify**: run `npm run dev` and navigate to `/new-component`
|
|
899
|
+
|
|
900
|
+
---
|
|
901
|
+
|
|
902
|
+
## 10. Commands Cheatsheet
|
|
903
|
+
|
|
904
|
+
| Command | Purpose |
|
|
905
|
+
|---------|---------|
|
|
906
|
+
| `npm run dev` | Start showcase dev server on port 3456 |
|
|
907
|
+
| `npm run build` | Build library to `dist/` |
|
|
908
|
+
| `npm run theme` | Regenerate default theme CSS (maintainers only) |
|
|
909
|
+
| `npm publish` | Publish (runs `prepublishOnly` → `npm run build`) |
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
---
|
|
140
913
|
|
|
141
914
|
## License
|
|
142
915
|
|
|
143
|
-
MIT
|
|
916
|
+
MIT
|