@webjsdev/ui 0.3.16 → 0.3.17

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
@@ -8,7 +8,8 @@ function calls, not for a layered React abstraction over every primitive:
8
8
 
9
9
  - **Tier 1, class-helper functions** (`buttonClass`, `cardClass`,
10
10
  `inputClass`, `labelClass`, `alertClass`, `popoverContentClass`,
11
- `accordionItemClass`, `collapsibleTriggerClass`, …). Pure functions that
11
+ `accordionItemClass`, `collapsibleTriggerClass`, `emptyClass`,
12
+ `fieldClass`, `fieldErrorClass`, `spinnerClass`, …). Pure functions that
12
13
  return Tailwind class strings. You spread them onto raw native elements
13
14
  , including `<button class=${buttonClass({ variant: 'outline' })}>`,
14
15
  `<details name="faq" class=${accordionItemClass()}>`, and
@@ -55,8 +56,9 @@ Tier-1 class helpers return only classes, so the semantic element and ARIA are
55
56
  yours to supply. Each one's JSDoc carries an `A11y (required for accessible
56
57
  output)` block stating exactly what to add: a name on an icon-only button, a
57
58
  role on an alert, `scope` on table headers, `alt` on an avatar image, a
58
- labelled `<nav>` with `aria-current="page"` on pagination and breadcrumb, and
59
- so on. Follow that block and the markup is fully accessible.
59
+ labelled `<nav>` with `aria-current="page"` on pagination and breadcrumb,
60
+ `aria-describedby` from a control to its field description and error, a
61
+ disabled `aria-busy` button around a decorative spinner, and so on. Follow that block and the markup is fully accessible.
60
62
 
61
63
  ## Install
62
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "type": "module",
5
5
  "description": "The AI-first component library for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Class-helper functions for visuals, custom elements only where state matters, source-copied into your repo so you own it.",
6
6
  "bin": {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Empty: the empty state for a list, table, search result, or any region with
3
+ * nothing to show yet. Tier-1 class helpers; compose with `<div>` (or a heading
4
+ * and paragraph) for each subpart.
5
+ *
6
+ * shadcn parity:
7
+ * Empty → emptyClass()
8
+ * EmptyHeader → emptyHeaderClass()
9
+ * EmptyMedia (variant: default | icon) → emptyMediaClass({ variant })
10
+ * EmptyTitle → emptyTitleClass()
11
+ * EmptyDescription → emptyDescriptionClass()
12
+ * EmptyContent → emptyContentClass()
13
+ *
14
+ * The root carries `border-dashed` but no border width, exactly as shadcn
15
+ * does, so it renders borderless by default. Add `border` beside it for the
16
+ * dashed outline: `class=${cn(emptyClass(), 'border')}`.
17
+ *
18
+ * Design tokens used: --muted, --foreground, --muted-foreground, --primary,
19
+ * --border.
20
+ *
21
+ * A11y (required for accessible output):
22
+ * Use a REAL HEADING for `emptyTitleClass()`, at the level the surrounding
23
+ * document wants (`<h2>` under the page `<h1>`, `<h3>` inside a section). The
24
+ * empty state usually replaces the list a heading already introduces, so the
25
+ * title is often a level below it.
26
+ * The media is decorative: put `aria-hidden="true"` on the icon or
27
+ * illustration, since the title already says what is missing.
28
+ * The action in `emptyContentClass()` is a real `<a href>` (navigates, e.g.
29
+ * to the create page) or a real `<button>` (acts in place). A create action
30
+ * that is a form submit keeps working without JavaScript.
31
+ * When the empty state appears in response to a user action (a search or a
32
+ * filter that matched nothing), put `role="status"` on the root so the change
33
+ * is announced; a page that simply loads empty needs no role.
34
+ *
35
+ * @example
36
+ * ```html
37
+ * <div class=${cn(emptyClass(), 'border')}>
38
+ * <div class=${emptyHeaderClass()}>
39
+ * <div class=${emptyMediaClass({ variant: 'icon' })}>
40
+ * <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
41
+ * </div>
42
+ * <h3 class=${emptyTitleClass()}>No projects yet</h3>
43
+ * <p class=${emptyDescriptionClass()}>Create your first project to get started.</p>
44
+ * </div>
45
+ * <div class=${emptyContentClass()}>
46
+ * <a class=${buttonClass()} href="/projects/new">Create project</a>
47
+ * </div>
48
+ * </div>
49
+ * ```
50
+ */
51
+
52
+ const MEDIA_BASE =
53
+ 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0';
54
+
55
+ const MEDIA_VARIANTS = {
56
+ default: 'bg-transparent',
57
+ icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6",
58
+ } as const;
59
+
60
+ export type EmptyMediaVariant = keyof typeof MEDIA_VARIANTS;
61
+
62
+ /** Empty root: centred column with generous padding. Add `border` for the dashed outline. */
63
+ export const emptyClass = (): string =>
64
+ 'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12';
65
+
66
+ /** Empty header: groups the media, title, and description. */
67
+ export const emptyHeaderClass = (): string =>
68
+ 'flex max-w-sm flex-col items-center gap-2 text-center';
69
+
70
+ /**
71
+ * Empty media: the icon or illustration above the title. `icon` puts the icon
72
+ * on a muted tile; `default` leaves it bare (for an avatar or an illustration).
73
+ * Set `data-variant="<variant>"` on the same element for shadcn parity.
74
+ */
75
+ export function emptyMediaClass(opts: { variant?: EmptyMediaVariant } = {}): string {
76
+ return MEDIA_BASE + ' ' + MEDIA_VARIANTS[opts.variant ?? 'default'];
77
+ }
78
+
79
+ /** Empty title: what is missing, in a few words. */
80
+ export const emptyTitleClass = (): string => 'text-lg font-medium tracking-tight';
81
+
82
+ /** Empty description: the muted line under the title, saying what to do next. */
83
+ export const emptyDescriptionClass = (): string =>
84
+ 'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary';
85
+
86
+ /** Empty content: the row for the action button(s) or a small form. */
87
+ export const emptyContentClass = (): string =>
88
+ 'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance';
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Field: the layout system for a form, a label, a control, its helper text,
3
+ * and its error message, plus the fieldset, legend, and group that hold many
4
+ * fields. Tier-1 class helpers; compose with the native elements (`<fieldset>`,
5
+ * `<legend>`, `<label>`, `<p>`) and the kit's control helpers
6
+ * (`inputClass()`, `textareaClass()`, `checkboxClass()`, and the rest).
7
+ *
8
+ * shadcn parity:
9
+ * FieldSet → fieldSetClass()
10
+ * FieldLegend (variant: legend | label) → fieldLegendClass({ variant })
11
+ * FieldGroup → fieldGroupClass()
12
+ * Field (orientation: vertical | horizontal | responsive) → fieldClass({ orientation })
13
+ * FieldContent → fieldContentClass()
14
+ * FieldLabel → fieldLabelClass()
15
+ * FieldTitle → fieldTitleClass()
16
+ * FieldDescription → fieldDescriptionClass()
17
+ * FieldError → fieldErrorClass()
18
+ * FieldSeparator → fieldSeparatorClass() + fieldSeparatorContentClass()
19
+ *
20
+ * Set the shadcn data attributes on the same elements, because the sibling and
21
+ * group rules key on them: `data-slot="field"` + `data-orientation` on the
22
+ * field, `data-slot="field-label"` on the label, `data-slot="field-content"`
23
+ * on the content wrapper, `data-variant` on the legend, and
24
+ * `data-slot="field-group"` on the group (the `responsive` orientation reads
25
+ * the group's container width).
26
+ *
27
+ * Invalid state: put `data-invalid="true"` on the field, which turns the
28
+ * field's text destructive (`data-[invalid=true]:text-destructive`), and
29
+ * `aria-invalid="true"` on the control, which the control helpers style.
30
+ * Disabled state: `data-disabled="true"` on the field fades its label.
31
+ *
32
+ * Not the same as the `fieldClass()` / `fieldLabelClass()` in the `cn()`
33
+ * helper module (`lib/utils/cn.ts`), which are the older single-field rhythm
34
+ * helpers (`grid gap-2`). Both keep working. Import each from its own module,
35
+ * and alias one (`import { fieldClass as fieldStackClass } from ...`) if a file
36
+ * needs both.
37
+ *
38
+ * Design tokens used: --destructive, --muted-foreground, --primary,
39
+ * --background, --border.
40
+ *
41
+ * A11y (required for accessible output):
42
+ * Every control has a real `<label for>` (`fieldLabelClass()`), never a
43
+ * placeholder alone. A field carries `role="group"` (as shadcn's does) only
44
+ * when it holds more than one control; a single-control field needs no role.
45
+ * Wire the helper and error text to the control with `aria-describedby`,
46
+ * listing the description id and, when invalid, the error id
47
+ * (`aria-describedby="email-desc email-error"`). Point it only at ids that
48
+ * EXIST on the page, so render the error element only when there is an error.
49
+ * On a server validation error, re-render the form with the typed values
50
+ * kept (`value=${values.email}`), `data-invalid="true"` on the field,
51
+ * `aria-invalid="true"` on the control, and the message in
52
+ * `fieldErrorClass()` with `role="alert"`, so the user fixes the one field
53
+ * instead of retyping the form. Move focus to the first invalid control.
54
+ * Group related controls (a radio set, a checkbox list) in a `<fieldset>`
55
+ * with a `<legend>` (`fieldSetClass()` / `fieldLegendClass()`), which names
56
+ * the group. A `fieldTitleClass()` element is NOT a label: use it for the
57
+ * title inside a choice card whose `<label>` wraps the whole card.
58
+ * A decorative separator gets `role="none"`; its text (`or`) stays readable.
59
+ *
60
+ * @example
61
+ * ```html
62
+ * <form method="post" class="w-full max-w-md">
63
+ * <fieldset class=${fieldSetClass()}>
64
+ * <legend class=${fieldLegendClass()} data-variant="legend">Profile</legend>
65
+ * <p class=${fieldDescriptionClass()}>This is how others see you.</p>
66
+ * <div class=${fieldGroupClass()} data-slot="field-group">
67
+ * <div class=${fieldClass()} data-slot="field" data-orientation="vertical">
68
+ * <label class=${fieldLabelClass()} data-slot="field-label" for="name">Name</label>
69
+ * <input class=${inputClass()} id="name" name="name" value="Ada" aria-describedby="name-desc">
70
+ * <p class=${fieldDescriptionClass()} id="name-desc">Shown on your public page.</p>
71
+ * </div>
72
+ * <div class=${fieldClass()} data-slot="field" data-orientation="vertical" data-invalid="true">
73
+ * <label class=${fieldLabelClass()} data-slot="field-label" for="email">Email</label>
74
+ * <input class=${inputClass()} id="email" name="email" type="email" value="ada@" aria-invalid="true" aria-describedby="email-error">
75
+ * <div class=${fieldErrorClass()} id="email-error" role="alert">Enter a valid email address.</div>
76
+ * </div>
77
+ * <div class=${fieldSeparatorClass()} role="none"><span class=${fieldSeparatorContentClass()}>Notifications</span></div>
78
+ * <div class=${fieldClass({ orientation: 'horizontal' })} data-slot="field" data-orientation="horizontal">
79
+ * <input class=${checkboxClass()} data-slot="checkbox" type="checkbox" id="digest" name="digest">
80
+ * <label class=${fieldLabelClass()} data-slot="field-label" for="digest">Send me a weekly digest</label>
81
+ * </div>
82
+ * </div>
83
+ * </fieldset>
84
+ * </form>
85
+ * ```
86
+ */
87
+
88
+ export type FieldLegendVariant = 'legend' | 'label';
89
+ export type FieldOrientation = 'vertical' | 'horizontal' | 'responsive';
90
+
91
+ const LEGEND_VARIANTS = {
92
+ legend: 'text-base',
93
+ label: 'text-sm',
94
+ } as const;
95
+
96
+ const ORIENTATIONS = {
97
+ vertical: 'flex-col [&>*]:w-full [&>.sr-only]:w-auto',
98
+ horizontal:
99
+ 'flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[type=checkbox],[type=radio]]:mt-px',
100
+ responsive:
101
+ 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[type=checkbox],[type=radio]]:mt-px',
102
+ } as const;
103
+
104
+ /** Field set: a native `<fieldset>` holding a legend and a group of fields. */
105
+ export const fieldSetClass = (): string =>
106
+ 'flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3';
107
+
108
+ /**
109
+ * Field legend: the `<legend>` naming a field set. `legend` is section-sized,
110
+ * `label` matches a field label (for a radio or checkbox set). Set
111
+ * `data-variant="<variant>"` on the same element so a following description
112
+ * tightens its spacing.
113
+ */
114
+ export function fieldLegendClass(opts: { variant?: FieldLegendVariant } = {}): string {
115
+ return 'mb-3 font-medium ' + LEGEND_VARIANTS[opts.variant ?? 'legend'];
116
+ }
117
+
118
+ /** Field group: stacks fields, and is the container `responsive` fields measure. */
119
+ export const fieldGroupClass = (): string =>
120
+ 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4';
121
+
122
+ /**
123
+ * Field: one label + control + text unit. `vertical` stacks them, `horizontal`
124
+ * puts the control beside the label (checkbox, switch), and `responsive` stacks
125
+ * on a narrow group and goes side by side from the group's `md` width.
126
+ */
127
+ export function fieldClass(opts: { orientation?: FieldOrientation } = {}): string {
128
+ return (
129
+ 'group/field flex w-full gap-3 data-[invalid=true]:text-destructive ' +
130
+ ORIENTATIONS[opts.orientation ?? 'vertical']
131
+ );
132
+ }
133
+
134
+ /** Field content: wraps a label and description beside a horizontal control. */
135
+ export const fieldContentClass = (): string =>
136
+ 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug';
137
+
138
+ /** Field label: the `<label for>` of the control. Fades when the field is data-disabled. */
139
+ export const fieldLabelClass = (): string =>
140
+ 'group/field-label peer/field-label flex w-fit items-center gap-2 text-sm leading-snug font-medium select-none group-data-[disabled=true]/field:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4 has-checked:border-primary has-checked:bg-primary/5 dark:has-checked:bg-primary/10';
141
+
142
+ /** Field title: label-styled text that is not a `<label>` (a choice card's heading). */
143
+ export const fieldTitleClass = (): string =>
144
+ 'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50';
145
+
146
+ /** Field description: the muted helper text under (or beside) the control. */
147
+ export const fieldDescriptionClass = (): string =>
148
+ 'text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5 [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary';
149
+
150
+ /** Field error: the inline validation message. Give it an id the control's aria-describedby lists. */
151
+ export const fieldErrorClass = (): string => 'text-sm font-normal text-destructive';
152
+
153
+ /**
154
+ * Field separator: a horizontal rule between fields, drawn by the element's own
155
+ * `::before`, with optional centred text in a `fieldSeparatorContentClass()`
156
+ * span.
157
+ */
158
+ export const fieldSeparatorClass = (): string =>
159
+ 'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2 before:absolute before:inset-x-0 before:top-1/2 before:h-px before:bg-border';
160
+
161
+ /** Field separator content: the text on the separator line (`or`, a section name). */
162
+ export const fieldSeparatorContentClass = (): string =>
163
+ 'relative mx-auto block w-fit bg-background px-2 text-muted-foreground';
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Spinner: an animated busy indicator. Tier-1 helpers, `spinnerClass()` for
3
+ * the classes and `spinner()` for the whole inline SVG (the Lucide
4
+ * loader-circle path, so no icon package is needed). It inherits the text
5
+ * colour through `currentColor`, so it matches whatever it sits in.
6
+ *
7
+ * shadcn parity:
8
+ * Spinner → spinner({ class, label }) (the inline SVG)
9
+ * → spinnerClass() (the classes, for your own SVG)
10
+ *
11
+ * Size it with a size utility: `spinner({ class: 'size-6' })` replaces the
12
+ * default `size-4`.
13
+ *
14
+ * Design tokens used: none (it draws in `currentColor`).
15
+ *
16
+ * A11y (required for accessible output):
17
+ * `spinner()` renders `role="status"` + `aria-label="Loading"`, so a
18
+ * standalone spinner is announced. Pass `label` for a more specific name
19
+ * (`spinner({ label: 'Loading invoices' })`).
20
+ * Inside a BUTTON while a save is running, mark the button itself busy:
21
+ * `disabled` (so a second click cannot submit twice) plus `aria-busy="true"`,
22
+ * and keep a visible text label ("Saving"). The button's text already says
23
+ * what is happening, so pass `decorative: true`, which renders the SVG with
24
+ * `aria-hidden="true"` and no role, rather than nesting a second live status
25
+ * inside the button's name. Re-enable the button and drop `aria-busy` when
26
+ * the save settles, and report the outcome somewhere that is announced (a
27
+ * toast, or the form's error text).
28
+ * A spinner that replaces a whole region is better as a skeleton when the
29
+ * final layout is known, with `aria-busy="true"` on the region.
30
+ *
31
+ * @example
32
+ * ```html
33
+ * <div class="flex items-center gap-2 text-sm text-muted-foreground">
34
+ * ${spinner()} Loading invoices
35
+ * </div>
36
+ *
37
+ * <button class=${buttonClass()} type="submit" disabled aria-busy="true">
38
+ * ${spinner({ decorative: true })} Saving
39
+ * </button>
40
+ * ```
41
+ */
42
+ import { html } from '@webjsdev/core';
43
+ import { cn } from '../lib/utils.ts';
44
+
45
+ /** Spinner classes: `size-4` plus `animate-spin`. Apply to your own `<svg>`. */
46
+ export const spinnerClass = (): string => 'size-4 animate-spin';
47
+
48
+ /**
49
+ * The spinner as an inline SVG template. `class` is merged over
50
+ * `spinnerClass()` (a later size utility wins), `label` names it (default
51
+ * "Loading"), and `decorative` hides it from assistive tech for use beside
52
+ * visible text such as a busy button's label.
53
+ */
54
+ export function spinner(opts: { class?: string; label?: string; decorative?: boolean } = {}) {
55
+ const cls = cn(spinnerClass(), opts.class);
56
+ // Branched rather than a nullish `role=${...}` hole: the server renderer
57
+ // stringifies a null attribute value, so a single template would serve
58
+ // `role=""` on the decorative form.
59
+ if (opts.decorative) {
60
+ return html`<svg data-slot="spinner" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class=${cls}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>`;
61
+ }
62
+ return html`<svg data-slot="spinner" role="status" aria-label=${opts.label ?? 'Loading'} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class=${cls}><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>`;
63
+ }
@@ -232,6 +232,38 @@
232
232
  }
233
233
  ]
234
234
  },
235
+ {
236
+ "name": "empty",
237
+ "type": "registry:ui",
238
+ "registryDependencies": [
239
+ "lib-utils"
240
+ ],
241
+ "dependencies": [
242
+ "@webjsdev/core"
243
+ ],
244
+ "files": [
245
+ {
246
+ "path": "components/empty.ts",
247
+ "type": "registry:ui"
248
+ }
249
+ ]
250
+ },
251
+ {
252
+ "name": "field",
253
+ "type": "registry:ui",
254
+ "registryDependencies": [
255
+ "lib-utils"
256
+ ],
257
+ "dependencies": [
258
+ "@webjsdev/core"
259
+ ],
260
+ "files": [
261
+ {
262
+ "path": "components/field.ts",
263
+ "type": "registry:ui"
264
+ }
265
+ ]
266
+ },
235
267
  {
236
268
  "name": "hover-card",
237
269
  "type": "registry:ui",
@@ -410,6 +442,22 @@
410
442
  }
411
443
  ]
412
444
  },
445
+ {
446
+ "name": "spinner",
447
+ "type": "registry:ui",
448
+ "registryDependencies": [
449
+ "lib-utils"
450
+ ],
451
+ "dependencies": [
452
+ "@webjsdev/core"
453
+ ],
454
+ "files": [
455
+ {
456
+ "path": "components/spinner.ts",
457
+ "type": "registry:ui"
458
+ }
459
+ ]
460
+ },
413
461
  {
414
462
  "name": "switch",
415
463
  "type": "registry:ui",