@ntmats/hc-ui 0.1.0 → 0.2.0

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 CHANGED
@@ -5,7 +5,7 @@ Imported as `@hc/ui`: `npm install --save-exact @hc/ui@npm:@ntmats/hc-ui@<versio
5
5
  React presentation primitives, with React and React DOM supplied by each consumer. The initial contract has no router, data, persistence, icon-library or product-specific dependencies.
6
6
 
7
7
  ```ts
8
- import { Button, Chip, IconButton, SegmentedControl, Skeleton, Surface } from '@hc/ui';
8
+ import { Button, Chip, Field, IconButton, SegmentedControl, Skeleton, Surface } from '@hc/ui';
9
9
  import '@hc/ui/styles.css';
10
10
  import '@hc/tokens/css';
11
11
  ```
@@ -20,7 +20,11 @@ Styles are not in a cascade layer: unlayered consumer resets (HCUI's `button` ru
20
20
 
21
21
  HCUI uses `SegmentedControl` through its `ViewToggle` wrapper. During the bootstrap it compiles the source through Vite/TypeScript aliases; switch to the pinned package once published.
22
22
 
23
- `Button` is a pill with `secondary` (default), `primary`, `outline` and `danger` variants, `md`/`lg` sizes, an optional leading `icon` and `fullWidth`. `IconButton` requires `label` (accessible name and tooltip) and comes in 36/40/44px; the 36px size keeps a 44px touch target. `Chip` is a filter/option pill; pass `selected` to make it a toggle (`aria-pressed`), omit it for a plain action. All three read the shared `rond` roundness, default to `type="button"`, forward refs, scale slightly on press, show the shared focus ring and drop motion under `prefers-reduced-motion`.
23
+ `Button` is a pill with `secondary` (default), `primary`, `outline` and `danger` variants, `sm`/`md`/`lg` sizes (36/44/52px), optional `icon` and `iconEnd`, `fullWidth`, and `loading`, which keeps focus and label, shows a spinner and ignores clicks. Long labels are cut with an ellipsis. `IconButton` requires `label` (accessible name and tooltip) and comes in 36/40/44px; the 36px size keeps a 44px touch target. Pass `selected` to make it a toggle. `Chip` is a filter/option pill; pass `selected` to make it a toggle (`aria-pressed`), omit it for a plain action. All three read the shared `rond` roundness, default to `type="button"`, forward refs, scale slightly on press, show the shared focus ring and drop motion under `prefers-reduced-motion`.
24
+
25
+ `Field` is a filled pill text input. `label` is required (`hideLabel` keeps it for screen readers), with `icon`, an `end` slot, `hint`, `error` and comfortable/compact density (44/40px). It forwards its ref and input props to the `input`; value and validation stay with the caller.
26
+
27
+ Focus rings read `--hc-focus-ring-offset` (default 2px). Set it to `-2px` on a control inside an `overflow: hidden` group so the ring is not cut.
24
28
 
25
29
  `Skeleton` is a static shape that pulses, hidden from assistive technology; the enclosing region announces loading once. Its base rule has zero specificity (`:where`), so the consumer sizes each shape with `width`/`height` or its own class.
26
30
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react';
1
+ import { type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from 'react';
2
2
  export type SurfaceTone = 'page' | 'main' | 'content' | 'elevated' | 'overlay';
3
3
  export interface SurfaceProps extends HTMLAttributes<HTMLDivElement> {
4
4
  tone?: SurfaceTone;
@@ -15,7 +15,8 @@ export interface SegmentedControlProps<T extends string> extends Omit<HTMLAttrib
15
15
  label: string;
16
16
  value: T;
17
17
  options: readonly SegmentedOption<T>[];
18
- onChange: (value: T) => void;
18
+ /** NoInfer: `T` comes from `value` and `options`, so a state setter cannot widen it to string. */
19
+ onChange: (value: NoInfer<T>) => void;
19
20
  density?: 'comfortable' | 'compact';
20
21
  orientation?: 'horizontal' | 'vertical';
21
22
  /** Icon-only options: labels stay as accessible names and tooltips. */
@@ -26,10 +27,15 @@ export declare function SegmentedControl<T extends string>({ label, value, optio
26
27
  export type ButtonVariant = 'secondary' | 'primary' | 'outline' | 'danger';
27
28
  export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
28
29
  variant?: ButtonVariant;
29
- size?: 'md' | 'lg';
30
+ /** 36, 44 or 52px. */
31
+ size?: 'sm' | 'md' | 'lg';
30
32
  /** Decorative icon before the label; pass it already sized (18px in HCUI). */
31
33
  icon?: ReactNode;
34
+ /** Decorative icon after the label. */
35
+ iconEnd?: ReactNode;
32
36
  fullWidth?: boolean;
37
+ /** Busy state: keeps focus and label, shows a spinner and ignores clicks. */
38
+ loading?: boolean;
33
39
  }
34
40
  /** Pill button. The caller supplies the label text and handles the action. */
35
41
  export declare const Button: import("react").ForwardRefExoticComponent<ButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -40,6 +46,8 @@ export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonEle
40
46
  /** 36, 40 or 44px. The 36px size keeps a 44px touch target. */
41
47
  size?: 'sm' | 'md' | 'lg';
42
48
  tone?: 'primary' | 'secondary';
49
+ /** Toggle state (`aria-pressed`); omit for a plain action. */
50
+ selected?: boolean;
43
51
  }
44
52
  /** Round icon-only button (back, close, options). */
45
53
  export declare const IconButton: import("react").ForwardRefExoticComponent<IconButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -50,6 +58,22 @@ export interface ChipProps extends ButtonHTMLAttributes<HTMLButtonElement> {
50
58
  }
51
59
  /** Filter or option chip. Selected chips are filled with the accent color. */
52
60
  export declare const Chip: import("react").ForwardRefExoticComponent<ChipProps & import("react").RefAttributes<HTMLButtonElement>>;
61
+ export interface FieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> {
62
+ /** Accessible name. Visible above the field unless `hideLabel` is set. */
63
+ label: string;
64
+ hideLabel?: boolean;
65
+ /** 40 or 44px. */
66
+ density?: 'comfortable' | 'compact';
67
+ /** Decorative icon at the start of the field. */
68
+ icon?: ReactNode;
69
+ /** Slot at the end of the field, such as a clear button. */
70
+ end?: ReactNode;
71
+ hint?: ReactNode;
72
+ /** Error message; also marks the input as invalid. */
73
+ error?: ReactNode;
74
+ }
75
+ /** Filled pill text field. Value, validation and messages stay with the caller. */
76
+ export declare const Field: import("react").ForwardRefExoticComponent<FieldProps & import("react").RefAttributes<HTMLInputElement>>;
53
77
  export interface SkeletonProps extends HTMLAttributes<HTMLSpanElement> {
54
78
  width?: string | number;
55
79
  height?: string | number;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.tsx
2
- import { forwardRef, useRef } from "react";
2
+ import { forwardRef, useId, useRef } from "react";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  function Surface({ tone = "content", className, ...props }) {
5
5
  return /* @__PURE__ */ jsx("div", { ...props, className: ["hc-ui-surface", className].filter(Boolean).join(" "), "data-tone": tone });
@@ -80,7 +80,11 @@ function SegmentedControl({
80
80
  );
81
81
  }
82
82
  var classes = (...names) => names.filter(Boolean).join(" ");
83
- var Button = forwardRef(function Button2({ variant = "secondary", size = "md", icon, fullWidth = false, type = "button", className, children, ...props }, ref) {
83
+ var Button = forwardRef(function Button2({ variant = "secondary", size = "md", icon, iconEnd, fullWidth = false, loading = false, type = "button", className, children, onClick, ...props }, ref) {
84
+ const handleClick = (event) => {
85
+ if (loading) event.preventDefault();
86
+ else onClick?.(event);
87
+ };
84
88
  return /* @__PURE__ */ jsxs(
85
89
  "button",
86
90
  {
@@ -88,17 +92,21 @@ var Button = forwardRef(function Button2({ variant = "secondary", size = "md", i
88
92
  ref,
89
93
  type,
90
94
  className: classes("hc-ui-button", className),
95
+ onClick: handleClick,
96
+ "aria-busy": loading || void 0,
97
+ "aria-disabled": loading || props["aria-disabled"],
91
98
  "data-variant": variant,
92
99
  "data-size": size,
93
100
  "data-full-width": fullWidth || void 0,
94
101
  children: [
95
- icon && /* @__PURE__ */ jsx("span", { className: "hc-ui-button__icon", "aria-hidden": "true", children: icon }),
96
- children
102
+ loading ? /* @__PURE__ */ jsx("span", { className: "hc-ui-button__spinner", "aria-hidden": "true" }) : icon && /* @__PURE__ */ jsx("span", { className: "hc-ui-button__icon", "aria-hidden": "true", children: icon }),
103
+ /* @__PURE__ */ jsx("span", { className: "hc-ui-button__label", children }),
104
+ iconEnd && /* @__PURE__ */ jsx("span", { className: "hc-ui-button__icon", "aria-hidden": "true", children: iconEnd })
97
105
  ]
98
106
  }
99
107
  );
100
108
  });
101
- var IconButton = forwardRef(function IconButton2({ label, icon, size = "md", tone = "primary", type = "button", title, className, ...props }, ref) {
109
+ var IconButton = forwardRef(function IconButton2({ label, icon, size = "md", tone = "primary", selected, type = "button", title, className, ...props }, ref) {
102
110
  return /* @__PURE__ */ jsx(
103
111
  "button",
104
112
  {
@@ -107,6 +115,7 @@ var IconButton = forwardRef(function IconButton2({ label, icon, size = "md", ton
107
115
  type,
108
116
  "aria-label": label,
109
117
  title: title ?? label,
118
+ "aria-pressed": selected ?? props["aria-pressed"],
110
119
  className: classes("hc-ui-icon-button", className),
111
120
  "data-size": size,
112
121
  "data-tone": tone,
@@ -120,6 +129,33 @@ var Chip = forwardRef(function Chip2({ selected, icon, type = "button", classNam
120
129
  children
121
130
  ] });
122
131
  });
132
+ var Field = forwardRef(function Field2({ label, hideLabel = false, density = "comfortable", icon, end, hint, error, id, className, disabled, "aria-describedby": describedBy, ...props }, ref) {
133
+ const generated = useId();
134
+ const inputId = id ?? generated;
135
+ const hintId = hint ? `${inputId}-hint` : void 0;
136
+ const errorId = error ? `${inputId}-error` : void 0;
137
+ return /* @__PURE__ */ jsxs("div", { className: classes("hc-ui-field", className), "data-density": density, "data-invalid": error ? true : void 0, "data-disabled": disabled || void 0, children: [
138
+ /* @__PURE__ */ jsx("label", { className: "hc-ui-field__label", htmlFor: inputId, "data-hidden": hideLabel || void 0, children: label }),
139
+ /* @__PURE__ */ jsxs("div", { className: "hc-ui-field__control", children: [
140
+ icon && /* @__PURE__ */ jsx("span", { className: "hc-ui-field__icon", "aria-hidden": "true", children: icon }),
141
+ /* @__PURE__ */ jsx(
142
+ "input",
143
+ {
144
+ ...props,
145
+ ref,
146
+ id: inputId,
147
+ disabled,
148
+ className: "hc-ui-field__input",
149
+ "aria-invalid": error ? true : props["aria-invalid"],
150
+ "aria-describedby": classes(describedBy, hintId, errorId) || void 0
151
+ }
152
+ ),
153
+ end && /* @__PURE__ */ jsx("span", { className: "hc-ui-field__end", children: end })
154
+ ] }),
155
+ hint && /* @__PURE__ */ jsx("p", { className: "hc-ui-field__hint", id: hintId, children: hint }),
156
+ error && /* @__PURE__ */ jsx("p", { className: "hc-ui-field__error", id: errorId, children: error })
157
+ ] });
158
+ });
123
159
  function Skeleton({ width, height, circle = false, className, style, ...props }) {
124
160
  const size = { ...width !== void 0 && { width }, ...height !== void 0 && { height }, ...style };
125
161
  return /* @__PURE__ */ jsx(
@@ -136,6 +172,7 @@ function Skeleton({ width, height, circle = false, className, style, ...props })
136
172
  export {
137
173
  Button,
138
174
  Chip,
175
+ Field,
139
176
  IconButton,
140
177
  SegmentedControl,
141
178
  Skeleton,
package/dist/styles.css CHANGED
@@ -14,13 +14,11 @@
14
14
  }
15
15
  [data-hc-ds] .hc-ui-surface[data-tone='elevated'] {
16
16
  background: var(--hc-color-bg-elevated);
17
- border: 1px solid var(--hc-color-border);
18
17
  border-radius: var(--hc-radius-md);
19
18
  box-shadow: var(--hc-shadow-card);
20
19
  }
21
20
  [data-hc-ds] .hc-ui-surface[data-tone='overlay'] {
22
21
  background: var(--hc-color-bg-elevated);
23
- border: 1px solid var(--hc-color-border);
24
22
  border-radius: var(--hc-radius-md);
25
23
  box-shadow: var(--hc-shadow-float);
26
24
  }
@@ -29,6 +27,7 @@
29
27
  [data-hc-ds] .hc-ui-segmented {
30
28
  display: inline-flex;
31
29
  align-items: center;
30
+ flex: 0 0 auto;
32
31
  gap: 2px;
33
32
  max-width: 100%;
34
33
  padding: 3px;
@@ -83,7 +82,7 @@
83
82
 
84
83
  [data-hc-ds] .hc-ui-segmented__option:focus-visible {
85
84
  outline: 2px solid var(--hc-focus-ring);
86
- outline-offset: 2px;
85
+ outline-offset: var(--hc-focus-ring-offset, 2px);
87
86
  }
88
87
 
89
88
  [data-hc-ds] .hc-ui-segmented__option:disabled { opacity: 0.48; cursor: not-allowed; }
@@ -116,7 +115,7 @@
116
115
 
117
116
  [data-hc-ds] :is(.hc-ui-button, .hc-ui-icon-button, .hc-ui-chip):focus-visible {
118
117
  outline: 2px solid var(--hc-focus-ring);
119
- outline-offset: 2px;
118
+ outline-offset: var(--hc-focus-ring-offset, 2px);
120
119
  }
121
120
 
122
121
  [data-hc-ds] :is(.hc-ui-button__icon, .hc-ui-icon-button__icon, .hc-ui-chip__icon) { display: inline-flex; flex: 0 0 auto; }
@@ -157,9 +156,23 @@
157
156
  [data-hc-ds] .hc-ui-button[data-variant='primary'] { color: var(--hc-color-text-inverse); background-color: var(--hc-color-accent); }
158
157
  [data-hc-ds] .hc-ui-button[data-variant='outline'] { background-color: transparent; box-shadow: inset 0 0 0 1px var(--hc-color-border-strong); }
159
158
  [data-hc-ds] .hc-ui-button[data-variant='danger'] { color: var(--hc-color-text-on-danger); background-color: var(--hc-color-bg-danger); }
159
+ [data-hc-ds] .hc-ui-button[data-size='sm'] { min-height: var(--hc-control-height-chip); padding: 0 var(--hc-space-4); font-size: var(--hc-font-size-sm); }
160
160
  [data-hc-ds] .hc-ui-button[data-size='lg'] { min-height: var(--hc-control-height-lg); padding: 0 var(--hc-space-6); font-size: var(--hc-font-size-base); }
161
161
  [data-hc-ds] .hc-ui-button[data-full-width] { width: 100%; }
162
162
  [data-hc-ds] .hc-ui-button:disabled { opacity: 0.4; cursor: default; }
163
+ [data-hc-ds] .hc-ui-button { max-width: 100%; }
164
+ [data-hc-ds] .hc-ui-button__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
165
+ [data-hc-ds] .hc-ui-button[aria-busy='true'] { cursor: progress; }
166
+ [data-hc-ds] .hc-ui-button__spinner {
167
+ flex: 0 0 auto;
168
+ width: 16px;
169
+ height: 16px;
170
+ border: 2px solid currentColor;
171
+ border-right-color: transparent;
172
+ border-radius: var(--hc-radius-full);
173
+ animation: hc-ui-spin 700ms linear infinite;
174
+ }
175
+ @keyframes hc-ui-spin { to { transform: rotate(360deg); } }
163
176
 
164
177
  /* IconButton — HCUI icon-button / close-button. */
165
178
  [data-hc-ds] .hc-ui-icon-button {
@@ -175,6 +188,7 @@
175
188
  [data-hc-ds] .hc-ui-icon-button[data-tone='secondary'] { color: var(--hc-color-text-secondary); }
176
189
  /* 36px keeps a 44px touch target; the veil stays clipped to the circle. */
177
190
  [data-hc-ds] .hc-ui-icon-button[data-size='sm']::before { content: ''; position: absolute; inset: -4px; }
191
+ [data-hc-ds] .hc-ui-icon-button[aria-pressed='true'] { color: var(--hc-color-text-inverse); background-color: var(--hc-color-accent); }
178
192
  [data-hc-ds] .hc-ui-icon-button:disabled { opacity: 0.4; cursor: default; }
179
193
 
180
194
  /* Chip — HCUI chip / chip-active. */
@@ -197,6 +211,56 @@
197
211
  [data-hc-ds] .hc-ui-chip[aria-pressed='true'] { color: var(--hc-color-text-inverse); background-color: var(--hc-color-accent); box-shadow: none; }
198
212
  [data-hc-ds] .hc-ui-chip:disabled { opacity: 0.4; cursor: default; }
199
213
 
214
+ /* Field — HCUI field. One focus ring, on the pill, never on the inner input. */
215
+ [data-hc-ds] .hc-ui-field { display: grid; align-content: start; gap: var(--hc-space-1); min-width: 0; font-family: var(--hc-font-body); font-variation-settings: 'ROND' var(--hc-rond); }
216
+ [data-hc-ds] .hc-ui-field__label { color: var(--hc-color-text-secondary); font-size: var(--hc-font-size-sm); font-weight: var(--hc-font-weight-medium); line-height: var(--hc-line-height-sm); }
217
+ [data-hc-ds] .hc-ui-field__label[data-hidden] {
218
+ position: absolute;
219
+ width: 1px;
220
+ height: 1px;
221
+ overflow: hidden;
222
+ clip-path: inset(50%);
223
+ white-space: nowrap;
224
+ }
225
+ [data-hc-ds] .hc-ui-field__control {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: var(--hc-space-2);
229
+ min-height: var(--hc-control-height-md);
230
+ padding: 0 var(--hc-space-4);
231
+ color: var(--hc-color-text-primary);
232
+ background-color: var(--hc-color-bg-muted);
233
+ border-radius: var(--hc-radius-full);
234
+ transition: box-shadow var(--hc-transition-fast), background-color var(--hc-transition-fast);
235
+ }
236
+ [data-hc-ds] .hc-ui-field[data-density='compact'] .hc-ui-field__control { min-height: var(--hc-control-height-sm); padding: 0 var(--hc-space-3); }
237
+ [data-hc-ds] .hc-ui-field__control:focus-within { box-shadow: inset 0 0 0 1.5px var(--hc-color-text-primary); }
238
+ [data-hc-ds] .hc-ui-field[data-invalid] .hc-ui-field__control { box-shadow: inset 0 0 0 1.5px var(--hc-color-danger); }
239
+ [data-hc-ds] .hc-ui-field[data-disabled] .hc-ui-field__control { opacity: 0.4; }
240
+ [data-hc-ds] .hc-ui-field__input {
241
+ appearance: none;
242
+ flex: 1 1 auto;
243
+ width: 100%;
244
+ min-width: 0;
245
+ margin: 0;
246
+ padding: 0;
247
+ color: inherit;
248
+ font-family: inherit;
249
+ font-size: var(--hc-font-size-base);
250
+ font-variation-settings: inherit;
251
+ line-height: var(--hc-line-height-base);
252
+ background: transparent;
253
+ border: 0;
254
+ outline: 0;
255
+ box-shadow: none;
256
+ }
257
+ [data-hc-ds] .hc-ui-field__input::placeholder { color: var(--hc-color-placeholder); opacity: 1; }
258
+ [data-hc-ds] :is(.hc-ui-field__icon, .hc-ui-field__end) { display: inline-flex; flex: 0 0 auto; align-items: center; color: var(--hc-color-text-muted); }
259
+ [data-hc-ds] .hc-ui-field__icon svg { display: block; }
260
+ [data-hc-ds] :is(.hc-ui-field__hint, .hc-ui-field__error) { margin: 0; font-size: var(--hc-font-size-xs); line-height: var(--hc-line-height-xs); }
261
+ [data-hc-ds] .hc-ui-field__hint { color: var(--hc-color-text-muted); }
262
+ [data-hc-ds] .hc-ui-field__error { color: var(--hc-color-text-on-danger); }
263
+
200
264
  /* Skeleton — zero specificity on purpose: the consumer sizes each shape. */
201
265
  @keyframes hc-ui-skeleton-pulse {
202
266
  0%, 100% { opacity: 1; }
@@ -216,6 +280,8 @@
216
280
  @media (prefers-reduced-motion: reduce) {
217
281
  [data-hc-ds] .hc-ui-segmented__option { transition: none; }
218
282
  [data-hc-ds] :is(.hc-ui-button, .hc-ui-icon-button, .hc-ui-chip) { transition: none; }
283
+ [data-hc-ds] .hc-ui-field__control { transition: none; }
284
+ [data-hc-ds] .hc-ui-button__spinner { animation: none; }
219
285
  [data-hc-ds] :is(.hc-ui-button, .hc-ui-icon-button, .hc-ui-chip):active:not(:disabled) { transform: none; }
220
286
  :where([data-hc-ds] .hc-ui-skeleton) { animation: none; }
221
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ntmats/hc-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Shared HCUI/Arena React presentation primitives.",
6
6
  "license": "UNLICENSED",
@@ -12,7 +12,8 @@
12
12
  "exports": {
13
13
  ".": {
14
14
  "types": "./dist/index.d.ts",
15
- "import": "./dist/index.js"
15
+ "import": "./dist/index.js",
16
+ "default": "./dist/index.js"
16
17
  },
17
18
  "./styles.css": "./dist/styles.css"
18
19
  },