@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`,
|
|
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,
|
|
59
|
-
|
|
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.
|
|
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",
|