@joewinke/jatui 0.42.0 → 0.43.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/CLAUDE.md +23 -3
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/dist/actions/dismissOutside.d.ts +52 -0
- package/dist/actions/dismissOutside.js +80 -0
- package/dist/components/FileDropzone.svelte +165 -3
- package/dist/components/FileDropzone.svelte.d.ts +9 -0
- package/dist/components/TextInput.svelte +71 -20
- package/dist/components/TextInput.svelte.d.ts +7 -0
- package/dist/components/ThumbnailGrid.svelte +106 -1
- package/dist/components/ThumbnailGrid.svelte.d.ts +7 -0
- package/dist/components/basic/Alert.svelte +113 -16
- package/dist/components/basic/Alert.svelte.d.ts +17 -1
- package/dist/components/basic/Checkbox.svelte +53 -1
- package/dist/components/basic/Checkbox.svelte.d.ts +6 -0
- package/dist/components/basic/RadioGroup.svelte +59 -3
- package/dist/components/basic/RadioGroup.svelte.d.ts +15 -0
- package/dist/components/basic/Slider.svelte +257 -9
- package/dist/components/basic/Slider.svelte.d.ts +14 -2
- package/dist/components/basic/sliderMath.d.ts +29 -0
- package/dist/components/basic/sliderMath.js +64 -0
- package/dist/components/field/FieldHint.svelte +29 -0
- package/dist/components/field/FieldHint.svelte.d.ts +17 -0
- package/dist/components/field/FieldLabel.svelte +100 -0
- package/dist/components/field/FieldLabel.svelte.d.ts +29 -0
- package/dist/components/form-cards/CheckGlyph.svelte +138 -0
- package/dist/components/form-cards/CheckGlyph.svelte.d.ts +29 -0
- package/dist/components/form-cards/CheckboxCard.svelte +120 -0
- package/dist/components/form-cards/CheckboxCard.svelte.d.ts +33 -0
- package/dist/components/form-cards/ChoiceCard.svelte +156 -0
- package/dist/components/form-cards/ChoiceCard.svelte.d.ts +42 -0
- package/dist/components/form-cards/SwitchCard.svelte +114 -0
- package/dist/components/form-cards/SwitchCard.svelte.d.ts +28 -0
- package/dist/components/toast/ToastHost.svelte +56 -3
- package/dist/components/toast/ToastHost.svelte.d.ts +9 -1
- package/dist/components/toast/ToastItem.svelte +75 -23
- package/dist/components/toast/ToastItem.svelte.d.ts +2 -0
- package/dist/components/uploadProgress.d.ts +30 -0
- package/dist/components/uploadProgress.js +47 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.js +11 -1
- package/dist/stores/toast.d.ts +35 -0
- package/dist/stores/toast.js +15 -1
- package/dist/styles/motion.css +72 -0
- package/dist/styles/rail.css +51 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -21,7 +21,8 @@ Then import:
|
|
|
21
21
|
|
|
22
22
|
| Component | Purpose | Key Props |
|
|
23
23
|
|-----------|---------|-----------|
|
|
24
|
-
| `TextInput` | Text input with label, error, icon
|
|
24
|
+
| `TextInput` | Text input with label, error, help, count, floating label. `leading`/`trailing` snippets put an icon or addon ("https://", "kg", a clear button) inside the field — the DaisyUI `input` skin moves to a wrapper and the error shake follows it. `labelTooltip` adds an info button after the label (shared `use:tooltip`). Label and help/error lines are the exported `FieldLabel` / `FieldHint`. | `label`, `value` (bindable), `placeholder`, `error`, `helpText`, `disabled`, `leading?`/`trailing?` (snippets), `labelTooltip?`, `floatingLabel?` |
|
|
25
|
+
| `FieldLabel` / `FieldHint` | The label row and help/error line TextInput uses, exported so custom controls (selects, PhoneInput, your own) line up identically. FieldLabel: `required` asterisk, `tooltip` info button (24px target, keyboard-focusable, rendered outside the `<label>` so it never focuses the input); with no tooltip it renders the exact bare `<label class="label">` TextInput always had. FieldHint: `error` → error tone + `role="alert"`. | FieldLabel: `for`, `text`, `required?`, `tooltip?`, `tooltipLabel?`, `children?` · FieldHint: `id`, `text`, `error?` |
|
|
25
26
|
| `TextArea` | Multiline text input. Auto-grow (`autoResize`, or implied by `maxRows`) grows a whole line at a time from `minRows` (default `rows`) to `maxRows`, then scrolls — CSS `field-sizing: content` where supported, row-quantized JS fallback (`textareaRowsHeight`) elsewhere. | `label`, `value` (bindable), `placeholder`, `rows`, `minRows?`, `maxRows?`, `autoResize?`, `error`, `maxlength` |
|
|
26
27
|
| `SelectInput` | Dropdown select | `label`, `value` (bindable), `options: SelectOption[]`, `placeholder` |
|
|
27
28
|
| `SegmentedControl` | Single-select toggle group that SETS A VALUE (Tabs switch panels): `role="radiogroup"` + `role="radio"`, roving tabindex, arrows move AND select, Home/End, disabled options skipped; sliding thumb on motion tokens (jumps under reduced motion). `solid` (track + raised thumb) or `plain`. Options may be plain strings. | `options: (SegmentedOption \| string)[]`, `value` (bindable), `size?` (`xs\|sm\|md`), `variant?`, `fullWidth?`, `disabled?`, `ariaLabel?`, `option?` (snippet), `onChange?` |
|
|
@@ -38,8 +39,14 @@ Then import:
|
|
|
38
39
|
| `IconSelector` | Searchable, paged icon picker — icon set is fully prop-driven (any component accepting `{ size, class }`, e.g. lucide-svelte), so no icon-library dependency; optional `icon` snippet for custom rendering. Keyboard: arrows move, Enter selects, Home/End, PgUp/PgDn page, Esc closes. | `icons: IconSelectorOption[]`, `selectedIcon` (bindable), `onSelect`, `label`, `colorClass`, `compact`, `columns`, `perPage`, `icon` |
|
|
39
40
|
| `ImageUpload` | Drag-drop image upload | `value` (bindable), `accept`, `maxSize` |
|
|
40
41
|
| `SmartImageEditor` | AI-powered image upload with background removal and multi-variant processing pipeline. **Preferred over `ImageUpload` in editor contexts.** Two modes: simple (drop-in replacement, outputs blob URL) and editor (POST multipart to backend → variant grid). | `value` (bindable), `spec?: ImageProcessingSpec`, `uploadEndpoint?`, `onComplete?`, `accept?`, `maxSizeMb?` |
|
|
41
|
-
| `FileDropzone` | Multi-file/folder/ZIP drag-drop upload zone. Handles FileSystem API folder recursion, MIME/size validation, and shows a pending-file grid. Calls `onFilesSelected(files, errors)` with validated `File[]`. Accepts already-uploaded files via `uploadedFiles: FileDropzoneFile[]` for post-upload display. | `accept?: string[]`, `maxSize?`, `maxFiles?`, `multiple?`, `uploadedFiles?`, `isUploading?`, `isExtracting?`, `dropLabel?`, `dropSubtext?`, `onFilesSelected?` |
|
|
42
|
+
| `FileDropzone` | Multi-file/folder/ZIP drag-drop upload zone. Handles FileSystem API folder recursion, MIME/size validation, and shows a pending-file grid. Calls `onFilesSelected(files, errors)` with validated `File[]`. Accepts already-uploaded files via `uploadedFiles: FileDropzoneFile[]` for post-upload display. Pass `progress` (0–100) to draw upload progress on the zone's own border (dashed border cross-fades into a primary stroke that fills clockwise, plus a sliding `%` badge; `role="progressbar"`, no layout shift, reduced-motion/calm gated). | `accept?: string[]`, `maxSize?`, `maxFiles?`, `multiple?`, `uploadedFiles?`, `isUploading?`, `isExtracting?`, `progress?`, `progressLabel?`, `dropLabel?`, `dropSubtext?`, `onFilesSelected?` |
|
|
42
43
|
| `FileThumbnail` | File thumbnail with MIME-aware icon fallback. Shows an `<img>` for image files (with loading/error states) and typed icons for PDF, ZIP, CAD, and generic files. Size variants: `sm`, `md`, `lg`, `xl`. | `name`, `mimeType`, `url?`, `size?` |
|
|
44
|
+
| `Checkbox` | Native checkbox with label, indeterminate, variants and a 24px target. `animated` draws the box with the shared `CheckGlyph` and draws the tick on (`--motion-fast` × `--motion-glide`, `pathLength=1` dashoffset) when the user checks it — never on load; the native input stays in the DOM, visually hidden. | `checked` (bindable), `label`, `size`, `variant`, `indeterminate`, `animated?`, `onchange` |
|
|
45
|
+
| `CheckboxCard` | Whole-card checkbox: title + description (+ `icon` snippet) on a `ChoiceCard`, animated `CheckGlyph` tick, description wired as `aria-describedby`. Selected = primary border + 6% tint, flat at rest. | `checked` (bindable), `title`, `description?`, `icon?`, `indeterminate?`, `disabled?`, `name?`, `value?`, `size?` (`sm`\|`md`), `variant?`, `controlPosition?` (`start`), `animated?` (true), `onchange?` |
|
|
46
|
+
| `SwitchCard` | Settings row where the whole card toggles a `role="switch"` (DaisyUI `toggle`, motion-token spring). No selected tint — on/off is a setting, not a pick. | `checked` (bindable), `title`, `description?`, `icon?`, `disabled?`, `size?`, `variant?`, `controlPosition?` (`end`), `onchange?` |
|
|
47
|
+
| `RadioGroup` | Native radio group (arrow keys rove natively). `display="card"` renders each option as a `ChoiceCard` with `description`/`icon` per option; `label` names the group. | `value` (bindable), `options: RadioOption[]` (`{value, label, description?, icon?, disabled?}`), `name`, `display?` (`default`\|`card`), `orientation?`, `label?`, `controlPosition?`, `variant`, `size` |
|
|
48
|
+
| `ChoiceCard` / `CheckGlyph` | The shared pieces: ChoiceCard is the `<label>` card shell (pass any native control as the `control` snippet; checked/focus/disabled styling reads the control via `:has()`); CheckGlyph is the presentational box + draw-on tick (`aria-hidden` — pair it with a real input). | ChoiceCard: `title`, `description?`, `control` (snippet), `icon?`, `for?`, `descriptionId?`, `selectedTint?`, `controlPosition?`, `size?` · CheckGlyph: `checked`, `indeterminate`, `animate`, `size`, `variant` |
|
|
49
|
+
| `Slider` | DaisyUI range. Pass a number for one thumb (unchanged markup) or a `[lo, hi]` pair for two thumbs — two stacked native ranges over one drawn track, so native keys + AT work; thumbs can't cross (`minGap`). `bubble` (`always`\|`drag`\|`none`) shows a value bubble that tracks each thumb; `formatValue` feeds the bubble, `showValue` and `aria-valuetext`. Pure helpers exported (`snapToStep`, `moveThumb`, `normalizePair`, `percentOf`, `bubbleLeft`). | `value` (bindable `number \| [number, number]`), `min`, `max`, `step`, `bubble?`, `formatValue?`, `label?`, `thumbLabels?`, `minGap?`, `showValue?`, `onchange?`, `onvaluechange?` |
|
|
43
50
|
| `SignaturePad` | Draw signature on canvas (mouse + touch). Emits base64 PNG on each stroke-end via bindable `signatureData` or `onSignatureChange(data)`. Exposes `clear()`/`isEmpty()`. Pass `disabled` for a dimmed, non-interactive preview. | `signatureData` (bindable), `onSignatureChange`, `label`, `width`, `height`, `disabled`, `required` |
|
|
44
51
|
|
|
45
52
|
### Scheduling & Time
|
|
@@ -68,6 +75,8 @@ Then import:
|
|
|
68
75
|
| `CloseButton` | The one ✕ for dialogs, drawers, sheets, toasts and lightboxes — an IconButton with a built-in X whose stroke is a true CSS pixel at every size. Modal, Drawer, BottomSheet and MediaLightbox use it. | `label?` ('Close'), `size?` ('sm'), `variant?`, `shape?` ('circle'), `tooltip?`, `onclick` |
|
|
69
76
|
| `LinkButton` | Text action styled as a link: `<a>` with `href`, `<button>` without; underline on hover (or always). `external` adds new-tab + rel=noopener + an SR hint. | `href?`, `tone?` (inherit\|primary\|muted\|error), `size?`, `underline?`, `external?`, `disabled?`, `onclick?`, `children` |
|
|
70
77
|
| `ProgressButton` | Multi-phase async submit button (upstreamed from the JAT IDE): walks through the **named phases** of the work (`phases: [{key,label}]` in order) instead of a blank spinner — spinner + current phase label with shimmer, a fill bar that advances to `(index+1)/N`, disabled while busy, green check-flash on `success`. Caller owns the state machine (`phase` / `success`). Renders through the shared `Button`, so `class` composes with join patterns. | `phase?` (null = idle), `phases?: Phase[]`, `idleLabel?`, `success?`, `successLabel?`, `variant?`, `size?`, `disabled?`, `type?`, `class?`, `onclick?`, `children` |
|
|
78
|
+
| `Alert` | Contextual feedback (info/success/warning/error). Optional `dismissible` × button (real `<button>`, `dismissLabel`) — exits on `motionScale`, then `onClose` fires; `action` snippet for a CTA; `introDelay` (ms) enters after surrounding UI settles (reduced motion: waits, then appears instantly); `variant="card"` = vertical sidebar density (icon on top, title, text, full-width CTA). | `type`, `title`, `dismissible?`, `onClose?`, `action?` (snippet), `introDelay?`, `variant?` ('default'\|'card'), `children` |
|
|
79
|
+
| `ToastHost` | Toast mount point — place once at app root; `showToast()` renders into it. `position` = `bottom-end` (default) \| `bottom-start` \| `bottom-center` \| `top-end` \| `top-start` \| `top-center` (logical start/end; top positions stack newest nearest the edge). Toasts keep pause-on-hover, drag-to-dismiss, undo countdown, collapsible `details`; add an optional `title`, `avatar` or `icon`, several `actions` (`{label, onClick, variant?: 'primary'\|'ghost'}` — legacy single `action` still works), and `showProgress` (countdown bar that pauses with the timer). Enter/exit blur is off under `data-motion="calm"` and reduced motion. `showToast({ type?, message, title?, icon?, avatar?, actions?, action?, details?, meta?, durationMs?, onUndo?, showProgress? })`. | `position?` |
|
|
71
80
|
| `Badge` | Status/label badge | `variant`, `size`, `children` (snippet) |
|
|
72
81
|
| `AnimatedNumber` | Number that animates to its new value. `mode='roll'` (default) is the per-digit odometer; `mode='tween'` counts from the previous value (`svelte/motion` Tween, `--motion-move` duration scaled by `data-motion`, instant under reduced motion; screen readers get the final value only). | `value`, `format?: Intl.NumberFormatOptions`, `mode?`, `duration?` (ms) |
|
|
73
82
|
| `StatCard` | KPI tile: label, a value that tweens to its new number, delta chip toned by sign (success/error/neutral tokens; `invertDelta` for metrics where down is good), caption, icon tile (`tone`), info tooltip, inline SVG sparkline (no d3). `variant='footer'` moves caption + delta into a footer band; `href` makes the card a link; `loading` skeleton. A numeric `delta` is a FRACTION (0.125 → "+12.5%"). | `label`, `value`, `format?`, `animate?` (`tween\|roll\|false`), `delta?`, `deltaFormat?`, `deltaTone?`, `invertDelta?`, `caption?`, `info?`, `sparkline?: number[]`, `variant?`, `tone?`, `size?`, `href?`, `loading?`, `icon?`/`children?` (snippets) |
|
|
@@ -164,7 +173,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
164
173
|
|
|
165
174
|
| Component | Purpose | Key Props |
|
|
166
175
|
|-----------|---------|-----------|
|
|
167
|
-
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. | `items: ThumbnailItem[]
|
|
176
|
+
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. Items take optional `progress` (0–100): an upload ring draws around the tile and a tiny % occupies the remove-× corner, cross-fading into the × at 100 (same slot, no layout shift; per-tile `role="progressbar"`). | `items: ThumbnailItem[]` (`{id, url?, alt, progress?}`), `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
|
|
168
177
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
169
178
|
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
170
179
|
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
@@ -484,9 +493,20 @@ if (await showConfirm({ title: "Delete?", body: warnBody, danger: true })) { ...
|
|
|
484
493
|
|
|
485
494
|
`text-2xs` — 11px / 16px line-height / +0.01em tracking. Ships in `styles/rail.css` (already imported by JST). Standardizes the ~13 sites of `text-[10px]` / `text-[0.7rem]` micro-captions across JST routes (timestamps, hint text, footnotes). Sits between Tailwind's `text-xs` (12px) and browser-min legibility.
|
|
486
495
|
|
|
496
|
+
**Type ramp** (same file): composite utilities that set size + line-height + tracking + weight in one class — `text-jatui-caption` (12/16), `text-jatui-label` (11/16, uppercase, +0.08em, 600 — the instrument label), `text-jatui-body` (14/20), `text-jatui-body-strong` (14/20, 600), `text-jatui-title` (18/24, 600), `text-jatui-display` (30/36, 700). Use one instead of stacking `text-xs font-medium tracking-wide`; colour stays with the caller.
|
|
497
|
+
|
|
498
|
+
**Motion utilities** (`styles/motion.css`, all on the tokens): `.jatui-check-draw` (SVG stroke with `pathLength="1"` draws on), `.jatui-row-exit` (row slides aside then collapses — remove it on `animationend`; set `--jatui-row-h` for tall rows), `.jatui-label-out` / `.jatui-label-in` (button label swap).
|
|
499
|
+
|
|
487
500
|
## Utilities
|
|
488
501
|
|
|
489
502
|
```typescript
|
|
503
|
+
// Outside-press dismissal (BoardUI wave 5). Capture-phase pointerdown outside the
|
|
504
|
+
// node AND every `refs` element (trigger, portalled panel) calls onDismiss; Escape
|
|
505
|
+
// too unless defaultPrevented. `enabled: false` disarms. Core is DOM-injectable.
|
|
506
|
+
import { dismissOutside, createDismissOutside, isInsidePath } from "jatui"
|
|
507
|
+
// <div use:dismissOutside={{ onDismiss: (e, reason) => (open = false), refs: [triggerEl], enabled: open }}>
|
|
508
|
+
import type { DismissOutsideOptions, DismissReason } from "jatui"
|
|
509
|
+
|
|
490
510
|
// Streaming-transcript auto-scroll (upstreamed from the JAT IDE — jatui-rl7w6.3).
|
|
491
511
|
// Reader-aware follow: follows the live edge ONLY while the reader is at it;
|
|
492
512
|
// upward scroll/wheel/keys/selection suspend following; coalesces sticks per
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -80,3 +80,17 @@ follow ideas from BoardUI's free items:
|
|
|
80
80
|
`BarList` and `ActivityHeatmap` follow BoardUI Pro ideas built from public
|
|
81
81
|
behaviour only; no Pro source was read or copied. No BoardUI images, logos or
|
|
82
82
|
palettes are included.
|
|
83
|
+
|
|
84
|
+
### Wave 5 — form polish (jatui-bns3w)
|
|
85
|
+
|
|
86
|
+
Written fresh for Svelte 5 / DaisyUI after the behaviour of these BoardUI items:
|
|
87
|
+
|
|
88
|
+
- `checkbox-glyph`, `checkbox-card` → `CheckGlyph`, `CheckboxCard`, `Checkbox animated`
|
|
89
|
+
- `switch-card` → `SwitchCard`; `radio-card` → `RadioGroup display="card"`; shared shell `ChoiceCard`
|
|
90
|
+
- `slider` (two thumbs, value bubbles) → `Slider`
|
|
91
|
+
- `input` / `label` / `hint-text` → `FieldLabel`, `FieldHint`, `TextInput` addons
|
|
92
|
+
- `file-upload` (perimeter progress) → `FileDropzone progress`, `ThumbnailGrid` item progress
|
|
93
|
+
- `notification` → toast `title` / `actions` / `avatar` / `ToastHost position`
|
|
94
|
+
- `announcement` → `Alert` `dismissible` / `action` / `variant="card"`
|
|
95
|
+
- `utils/use-dismiss-on-outside-press` → `dismissOutside`
|
|
96
|
+
- `styles/typography.css`, `styles/globals.css` (`check-draw`) → `text-jatui-*` ramp, motion utilities
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dismissOutside — close a popover / menu / panel when the user presses
|
|
3
|
+
* outside it.
|
|
4
|
+
*
|
|
5
|
+
* <div use:dismissOutside={{ onDismiss: close, refs: [triggerEl] }}>…</div>
|
|
6
|
+
*
|
|
7
|
+
* - Listens to `pointerdown` in the CAPTURE phase on the document, so a
|
|
8
|
+
* child that stops propagation can't hide the outside press, and the panel
|
|
9
|
+
* closes before the pressed element's own click runs.
|
|
10
|
+
* - "Inside" is the node plus every element in `refs` (a trigger button, a
|
|
11
|
+
* portalled sub-panel…). It is tested with `composedPath()`, so presses
|
|
12
|
+
* inside shadow roots and elements removed during the press still count.
|
|
13
|
+
* - `escape: true` (default) also dismisses on Escape, unless something
|
|
14
|
+
* earlier already called `preventDefault()` on that keydown.
|
|
15
|
+
* - `enabled: false` disarms it without tearing it down (bind it to `open`).
|
|
16
|
+
*
|
|
17
|
+
* The core is `createDismissOutside()`, framework-free and DOM-injectable
|
|
18
|
+
* for tests and non-Svelte callers.
|
|
19
|
+
*
|
|
20
|
+
* Idea after BoardUI's use-dismiss-on-outside-press (MIT © 2026 Mertcan
|
|
21
|
+
* Dundar Esmergul); the RAC-specific trigger-toggle half is not ported.
|
|
22
|
+
*/
|
|
23
|
+
export type DismissReason = 'outside' | 'escape';
|
|
24
|
+
export interface DismissOutsideOptions {
|
|
25
|
+
/** Called on an outside press (or Escape). */
|
|
26
|
+
onDismiss: (event: Event, reason: DismissReason) => void;
|
|
27
|
+
/** More elements that count as inside — a getter is re-read per event. */
|
|
28
|
+
refs?: Array<Element | null | undefined> | (() => Array<Element | null | undefined>);
|
|
29
|
+
/** Default true. */
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
/** Also dismiss on Escape. Default true. */
|
|
32
|
+
escape?: boolean;
|
|
33
|
+
/** Which pointer event counts as a press. Default `pointerdown`. */
|
|
34
|
+
event?: 'pointerdown' | 'mousedown' | 'click';
|
|
35
|
+
}
|
|
36
|
+
interface EventTargetLike {
|
|
37
|
+
addEventListener(type: string, fn: (e: Event) => void, opts?: boolean | AddEventListenerOptions): void;
|
|
38
|
+
removeEventListener(type: string, fn: (e: Event) => void, opts?: boolean | EventListenerOptions): void;
|
|
39
|
+
}
|
|
40
|
+
export interface DismissOutsideController {
|
|
41
|
+
update(next: DismissOutsideOptions): void;
|
|
42
|
+
destroy(): void;
|
|
43
|
+
}
|
|
44
|
+
/** True when the event's path passes through any of `roots`. */
|
|
45
|
+
export declare function isInsidePath(event: Event, roots: readonly Element[]): boolean;
|
|
46
|
+
export declare function createDismissOutside(getRoot: () => Element | null | undefined, initial: DismissOutsideOptions, doc?: EventTargetLike | undefined): DismissOutsideController;
|
|
47
|
+
/** Svelte action form. */
|
|
48
|
+
export declare function dismissOutside(node: Element, options: DismissOutsideOptions): {
|
|
49
|
+
update: (next: DismissOutsideOptions) => void;
|
|
50
|
+
destroy: () => void;
|
|
51
|
+
};
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dismissOutside — close a popover / menu / panel when the user presses
|
|
3
|
+
* outside it.
|
|
4
|
+
*
|
|
5
|
+
* <div use:dismissOutside={{ onDismiss: close, refs: [triggerEl] }}>…</div>
|
|
6
|
+
*
|
|
7
|
+
* - Listens to `pointerdown` in the CAPTURE phase on the document, so a
|
|
8
|
+
* child that stops propagation can't hide the outside press, and the panel
|
|
9
|
+
* closes before the pressed element's own click runs.
|
|
10
|
+
* - "Inside" is the node plus every element in `refs` (a trigger button, a
|
|
11
|
+
* portalled sub-panel…). It is tested with `composedPath()`, so presses
|
|
12
|
+
* inside shadow roots and elements removed during the press still count.
|
|
13
|
+
* - `escape: true` (default) also dismisses on Escape, unless something
|
|
14
|
+
* earlier already called `preventDefault()` on that keydown.
|
|
15
|
+
* - `enabled: false` disarms it without tearing it down (bind it to `open`).
|
|
16
|
+
*
|
|
17
|
+
* The core is `createDismissOutside()`, framework-free and DOM-injectable
|
|
18
|
+
* for tests and non-Svelte callers.
|
|
19
|
+
*
|
|
20
|
+
* Idea after BoardUI's use-dismiss-on-outside-press (MIT © 2026 Mertcan
|
|
21
|
+
* Dundar Esmergul); the RAC-specific trigger-toggle half is not ported.
|
|
22
|
+
*/
|
|
23
|
+
function readRefs(refs) {
|
|
24
|
+
const list = typeof refs === 'function' ? refs() : (refs ?? []);
|
|
25
|
+
return list.filter((el) => !!el);
|
|
26
|
+
}
|
|
27
|
+
/** True when the event's path passes through any of `roots`. */
|
|
28
|
+
export function isInsidePath(event, roots) {
|
|
29
|
+
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
|
|
30
|
+
if (path.length)
|
|
31
|
+
return roots.some((r) => path.includes(r));
|
|
32
|
+
const target = event.target;
|
|
33
|
+
return !!target && roots.some((r) => r === target || r.contains(target));
|
|
34
|
+
}
|
|
35
|
+
export function createDismissOutside(getRoot, initial, doc = typeof document !== 'undefined' ? document : undefined) {
|
|
36
|
+
let opts = initial;
|
|
37
|
+
const onPress = (e) => {
|
|
38
|
+
if (opts.enabled === false)
|
|
39
|
+
return;
|
|
40
|
+
const root = getRoot();
|
|
41
|
+
const roots = [...(root ? [root] : []), ...readRefs(opts.refs)];
|
|
42
|
+
if (isInsidePath(e, roots))
|
|
43
|
+
return;
|
|
44
|
+
opts.onDismiss(e, 'outside');
|
|
45
|
+
};
|
|
46
|
+
const onKey = (e) => {
|
|
47
|
+
if (opts.enabled === false || opts.escape === false)
|
|
48
|
+
return;
|
|
49
|
+
const ke = e;
|
|
50
|
+
if (ke.key !== 'Escape' || ke.defaultPrevented || ke.isComposing)
|
|
51
|
+
return;
|
|
52
|
+
opts.onDismiss(e, 'escape');
|
|
53
|
+
};
|
|
54
|
+
let pressType = opts.event ?? 'pointerdown';
|
|
55
|
+
doc?.addEventListener(pressType, onPress, true);
|
|
56
|
+
doc?.addEventListener('keydown', onKey);
|
|
57
|
+
return {
|
|
58
|
+
update(next) {
|
|
59
|
+
const nextType = next.event ?? 'pointerdown';
|
|
60
|
+
if (nextType !== pressType) {
|
|
61
|
+
doc?.removeEventListener(pressType, onPress, true);
|
|
62
|
+
doc?.addEventListener(nextType, onPress, true);
|
|
63
|
+
pressType = nextType;
|
|
64
|
+
}
|
|
65
|
+
opts = next;
|
|
66
|
+
},
|
|
67
|
+
destroy() {
|
|
68
|
+
doc?.removeEventListener(pressType, onPress, true);
|
|
69
|
+
doc?.removeEventListener('keydown', onKey);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Svelte action form. */
|
|
74
|
+
export function dismissOutside(node, options) {
|
|
75
|
+
const ctl = createDismissOutside(() => node, options);
|
|
76
|
+
return {
|
|
77
|
+
update: (next) => ctl.update(next),
|
|
78
|
+
destroy: () => ctl.destroy()
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
<script lang="ts">
|
|
19
19
|
import FileThumbnail from "./FileThumbnail.svelte"
|
|
20
|
+
import { clampProgress, dashOffset, perimeterRect, progressPercent } from "./uploadProgress.js"
|
|
20
21
|
|
|
21
22
|
// Lightweight extension→MIME map (avoids mime-types dependency)
|
|
22
23
|
const EXT_TO_MIME: Record<string, string> = {
|
|
@@ -87,6 +88,15 @@
|
|
|
87
88
|
dropSubtext?: string
|
|
88
89
|
/** Called when the user selects or drops files (after validation) */
|
|
89
90
|
onFilesSelected?: (files: File[], errors: string[]) => void
|
|
91
|
+
/**
|
|
92
|
+
* Upload progress, 0–100. When set, the zone's dashed border cross-fades
|
|
93
|
+
* into a primary stroke that fills clockwise around the perimeter, and a
|
|
94
|
+
* `%` badge slides in on the top edge — no layout shift. Leave it
|
|
95
|
+
* undefined/null for the plain (indeterminate) behaviour.
|
|
96
|
+
*/
|
|
97
|
+
progress?: number | null
|
|
98
|
+
/** Accessible name for the progress indicator (default "Upload progress"). */
|
|
99
|
+
progressLabel?: string
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
let {
|
|
@@ -107,6 +117,8 @@
|
|
|
107
117
|
dropLabel = "Upload files",
|
|
108
118
|
dropSubtext,
|
|
109
119
|
onFilesSelected,
|
|
120
|
+
progress = null,
|
|
121
|
+
progressLabel = "Upload progress",
|
|
110
122
|
}: Props = $props()
|
|
111
123
|
|
|
112
124
|
let isDragOver = $state(false)
|
|
@@ -114,6 +126,27 @@
|
|
|
114
126
|
let dropzoneRef = $state<HTMLElement>(undefined!)
|
|
115
127
|
let selectedFiles = $state<File[]>([])
|
|
116
128
|
|
|
129
|
+
// Upload progress perimeter (jatui-bns3w). The rect tracks the zone's
|
|
130
|
+
// border-box via ResizeObserver so the stroke lands exactly on the 2px border.
|
|
131
|
+
const ZONE_BORDER = 2
|
|
132
|
+
const ZONE_RADIUS = 8 // rounded-lg
|
|
133
|
+
let zoneW = $state(0)
|
|
134
|
+
let zoneH = $state(0)
|
|
135
|
+
const progressValue = $derived(clampProgress(progress))
|
|
136
|
+
const progressActive = $derived(progressValue !== null)
|
|
137
|
+
const progressDone = $derived(progressValue === 100)
|
|
138
|
+
const progressRect = $derived(perimeterRect(zoneW, zoneH, ZONE_BORDER, ZONE_RADIUS))
|
|
139
|
+
|
|
140
|
+
$effect(() => {
|
|
141
|
+
if (!dropzoneRef || typeof ResizeObserver === "undefined") return
|
|
142
|
+
const ro = new ResizeObserver(() => {
|
|
143
|
+
zoneW = dropzoneRef.offsetWidth
|
|
144
|
+
zoneH = dropzoneRef.offsetHeight
|
|
145
|
+
})
|
|
146
|
+
ro.observe(dropzoneRef)
|
|
147
|
+
return () => ro.disconnect()
|
|
148
|
+
})
|
|
149
|
+
|
|
117
150
|
// Human-readable accepted extensions for display
|
|
118
151
|
const acceptedExtensions = $derived(
|
|
119
152
|
accept
|
|
@@ -305,14 +338,17 @@
|
|
|
305
338
|
/>
|
|
306
339
|
|
|
307
340
|
<!-- Dropzone -->
|
|
341
|
+
<div class="relative">
|
|
308
342
|
<div
|
|
309
343
|
bind:this={dropzoneRef}
|
|
310
344
|
class="relative border-2 border-dashed rounded-lg p-6 transition-all duration-200 cursor-pointer
|
|
311
345
|
{isDragOver
|
|
312
346
|
? 'border-primary bg-primary/5 scale-[1.02]'
|
|
313
|
-
:
|
|
314
|
-
? '
|
|
315
|
-
:
|
|
347
|
+
: progressActive
|
|
348
|
+
? 'jatui-dz-border-hidden'
|
|
349
|
+
: disabled
|
|
350
|
+
? 'border-base-300 bg-base-200 cursor-not-allowed'
|
|
351
|
+
: 'border-base-300 hover:border-primary hover:bg-primary/5'}"
|
|
316
352
|
onclick={openFileDialog}
|
|
317
353
|
ondragenter={handleDragEnter}
|
|
318
354
|
ondragleave={handleDragLeave}
|
|
@@ -398,6 +434,50 @@
|
|
|
398
434
|
{/if}
|
|
399
435
|
</div>
|
|
400
436
|
|
|
437
|
+
<!-- Upload progress: perimeter stroke + % badge. A sibling of the
|
|
438
|
+
role="button" zone (a progressbar inside a button is flattened by AT). -->
|
|
439
|
+
<div
|
|
440
|
+
class="jatui-dz-progress"
|
|
441
|
+
class:is-active={progressActive && !isDragOver}
|
|
442
|
+
class:is-done={progressDone}
|
|
443
|
+
role={progressActive ? "progressbar" : undefined}
|
|
444
|
+
aria-label={progressActive ? progressLabel : undefined}
|
|
445
|
+
aria-valuemin={progressActive ? 0 : undefined}
|
|
446
|
+
aria-valuemax={progressActive ? 100 : undefined}
|
|
447
|
+
aria-valuenow={progressActive ? progressPercent(progressValue) : undefined}
|
|
448
|
+
aria-hidden={progressActive ? undefined : "true"}
|
|
449
|
+
>
|
|
450
|
+
{#if zoneW > 0 && zoneH > 0}
|
|
451
|
+
<svg class="jatui-dz-ring" width={zoneW} height={zoneH} aria-hidden="true">
|
|
452
|
+
<rect
|
|
453
|
+
class="jatui-dz-track"
|
|
454
|
+
x={progressRect.x}
|
|
455
|
+
y={progressRect.y}
|
|
456
|
+
width={progressRect.width}
|
|
457
|
+
height={progressRect.height}
|
|
458
|
+
rx={progressRect.rx}
|
|
459
|
+
stroke-width={ZONE_BORDER}
|
|
460
|
+
/>
|
|
461
|
+
<rect
|
|
462
|
+
class="jatui-dz-fill"
|
|
463
|
+
x={progressRect.x}
|
|
464
|
+
y={progressRect.y}
|
|
465
|
+
width={progressRect.width}
|
|
466
|
+
height={progressRect.height}
|
|
467
|
+
rx={progressRect.rx}
|
|
468
|
+
stroke-width={ZONE_BORDER}
|
|
469
|
+
pathLength="100"
|
|
470
|
+
stroke-dasharray="100"
|
|
471
|
+
stroke-dashoffset={dashOffset(progressValue, 100)}
|
|
472
|
+
/>
|
|
473
|
+
</svg>
|
|
474
|
+
{/if}
|
|
475
|
+
<span class="jatui-dz-badge badge badge-sm badge-primary tabular-nums" aria-hidden="true">
|
|
476
|
+
{progressPercent(progressValue)}%
|
|
477
|
+
</span>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
401
481
|
<!-- Selected Files (before upload) -->
|
|
402
482
|
{#if selectedFiles.length > 0 && !isUploading && uploadedFiles.length === 0}
|
|
403
483
|
<div class="mt-4 space-y-2">
|
|
@@ -490,4 +570,86 @@
|
|
|
490
570
|
outline: 2px solid var(--color-primary);
|
|
491
571
|
outline-offset: 2px;
|
|
492
572
|
}
|
|
573
|
+
|
|
574
|
+
/* Upload progress perimeter (jatui-bns3w). The dashed border fades out and
|
|
575
|
+
an SVG stroke on the same 2px line fills clockwise; the % badge slides in
|
|
576
|
+
on the top edge. Opacity/transform only — the zone never changes size. */
|
|
577
|
+
.jatui-dz-border-hidden {
|
|
578
|
+
border-color: transparent;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.jatui-dz-progress {
|
|
582
|
+
position: absolute;
|
|
583
|
+
inset: 0;
|
|
584
|
+
pointer-events: none;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.jatui-dz-ring {
|
|
588
|
+
position: absolute;
|
|
589
|
+
inset: 0;
|
|
590
|
+
overflow: visible;
|
|
591
|
+
opacity: 0;
|
|
592
|
+
transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.jatui-dz-progress.is-active .jatui-dz-ring {
|
|
596
|
+
opacity: 1;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.jatui-dz-track {
|
|
600
|
+
fill: none;
|
|
601
|
+
stroke: var(--color-base-300);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.jatui-dz-fill {
|
|
605
|
+
fill: none;
|
|
606
|
+
stroke: var(--color-primary);
|
|
607
|
+
stroke-linecap: round;
|
|
608
|
+
transition:
|
|
609
|
+
stroke-dashoffset var(--motion-move, 300ms) var(--motion-glide, ease-out),
|
|
610
|
+
stroke var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
.jatui-dz-progress.is-done .jatui-dz-fill {
|
|
614
|
+
stroke: var(--color-success);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
.jatui-dz-badge {
|
|
618
|
+
position: absolute;
|
|
619
|
+
top: 0;
|
|
620
|
+
left: 50%;
|
|
621
|
+
opacity: 0;
|
|
622
|
+
transform: translate(-50%, calc(-50% - var(--motion-travel, 16px)));
|
|
623
|
+
transition:
|
|
624
|
+
opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out),
|
|
625
|
+
transform var(--motion-enter, 200ms) var(--motion-glide, ease-out);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.jatui-dz-progress.is-active .jatui-dz-badge {
|
|
629
|
+
opacity: 1;
|
|
630
|
+
transform: translate(-50%, -50%);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.jatui-dz-progress.is-done .jatui-dz-badge {
|
|
634
|
+
background-color: var(--color-success);
|
|
635
|
+
border-color: var(--color-success);
|
|
636
|
+
color: var(--color-success-content);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/* Calm: no slide, the badge just appears. */
|
|
640
|
+
:global([data-motion="calm"]) .jatui-dz-badge {
|
|
641
|
+
transform: translate(-50%, -50%);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
@media (prefers-reduced-motion: reduce) {
|
|
645
|
+
.jatui-dz-ring,
|
|
646
|
+
.jatui-dz-fill,
|
|
647
|
+
.jatui-dz-badge {
|
|
648
|
+
transition: none;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.jatui-dz-badge {
|
|
652
|
+
transform: translate(-50%, -50%);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
493
655
|
</style>
|
|
@@ -34,6 +34,15 @@ interface Props {
|
|
|
34
34
|
dropSubtext?: string;
|
|
35
35
|
/** Called when the user selects or drops files (after validation) */
|
|
36
36
|
onFilesSelected?: (files: File[], errors: string[]) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Upload progress, 0–100. When set, the zone's dashed border cross-fades
|
|
39
|
+
* into a primary stroke that fills clockwise around the perimeter, and a
|
|
40
|
+
* `%` badge slides in on the top edge — no layout shift. Leave it
|
|
41
|
+
* undefined/null for the plain (indeterminate) behaviour.
|
|
42
|
+
*/
|
|
43
|
+
progress?: number | null;
|
|
44
|
+
/** Accessible name for the progress indicator (default "Upload progress"). */
|
|
45
|
+
progressLabel?: string;
|
|
37
46
|
}
|
|
38
47
|
declare const FileDropzone: import("svelte").Component<Props, {}, "">;
|
|
39
48
|
type FileDropzone = ReturnType<typeof FileDropzone>;
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* opt-in floatingLabel prop (label translates/scales on focus-or-filled, pure CSS).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { Snippet } from 'svelte';
|
|
10
11
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
12
|
+
import FieldLabel from './field/FieldLabel.svelte';
|
|
13
|
+
import FieldHint from './field/FieldHint.svelte';
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Any other native attribute (aria-label, aria-labelledby, data-*, title,
|
|
@@ -56,6 +59,12 @@
|
|
|
56
59
|
* consumers are unaffected when this prop is absent/false.
|
|
57
60
|
*/
|
|
58
61
|
floatingLabel?: boolean;
|
|
62
|
+
/** Leading icon / addon inside the field (e.g. a search glyph, "https://"). */
|
|
63
|
+
leading?: Snippet;
|
|
64
|
+
/** Trailing icon / addon inside the field (e.g. a unit, a clear button). */
|
|
65
|
+
trailing?: Snippet;
|
|
66
|
+
/** Info-icon hint after the label (shared `use:tooltip`). */
|
|
67
|
+
labelTooltip?: string;
|
|
59
68
|
class?: string;
|
|
60
69
|
id?: string;
|
|
61
70
|
name?: string;
|
|
@@ -78,6 +87,9 @@
|
|
|
78
87
|
pattern,
|
|
79
88
|
showCount = false,
|
|
80
89
|
floatingLabel = false,
|
|
90
|
+
leading,
|
|
91
|
+
trailing,
|
|
92
|
+
labelTooltip,
|
|
81
93
|
class: className = '',
|
|
82
94
|
id = `text-input-${Math.random().toString(36).substr(2, 9)}`,
|
|
83
95
|
name = '',
|
|
@@ -87,7 +99,10 @@
|
|
|
87
99
|
|
|
88
100
|
const sizeClass = $derived({ sm: 'input-sm', md: '', lg: 'input-lg' }[size]);
|
|
89
101
|
|
|
90
|
-
|
|
102
|
+
// With a leading/trailing addon the DaisyUI `input` skin moves to a wrapper
|
|
103
|
+
// (DaisyUI 5's `<label class="input">` pattern) and the <input> goes bare.
|
|
104
|
+
const hasAddons = $derived(!!leading || !!trailing);
|
|
105
|
+
const shellClass = $derived(
|
|
91
106
|
[
|
|
92
107
|
'input input-bordered w-full transition-shadow duration-200',
|
|
93
108
|
sizeClass,
|
|
@@ -98,11 +113,13 @@
|
|
|
98
113
|
.filter(Boolean)
|
|
99
114
|
.join(' ')
|
|
100
115
|
);
|
|
116
|
+
const inputClass = $derived(hasAddons ? 'jatui-input-bare' : shellClass);
|
|
101
117
|
|
|
102
118
|
const characterCount = $derived(value?.length || 0);
|
|
103
119
|
const isOverLimit = $derived(maxlength ? characterCount > maxlength : false);
|
|
104
120
|
|
|
105
121
|
let inputEl: HTMLInputElement | undefined = $state();
|
|
122
|
+
let shellEl: HTMLDivElement | undefined = $state();
|
|
106
123
|
let hadError = false;
|
|
107
124
|
|
|
108
125
|
// Error shake: embedded keyframe (jatui-shake-x), no host-app dependency.
|
|
@@ -110,14 +127,15 @@
|
|
|
110
127
|
// Self-contained reflow-restart so repeat errors re-shake.
|
|
111
128
|
$effect(() => {
|
|
112
129
|
const hasError = !!error;
|
|
113
|
-
|
|
130
|
+
const target = hasAddons ? shellEl : inputEl;
|
|
131
|
+
if (hasError && !hadError && target) {
|
|
114
132
|
const reduce =
|
|
115
133
|
typeof window !== 'undefined' &&
|
|
116
134
|
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
117
135
|
if (!reduce) {
|
|
118
|
-
|
|
119
|
-
void
|
|
120
|
-
|
|
136
|
+
target.classList.remove('jatui-input-shake');
|
|
137
|
+
void target.offsetWidth;
|
|
138
|
+
target.classList.add('jatui-input-shake');
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
141
|
hadError = hasError;
|
|
@@ -126,17 +144,10 @@
|
|
|
126
144
|
|
|
127
145
|
<div class="form-control w-full" class:jatui-float-wrap={floatingLabel}>
|
|
128
146
|
{#if label && !floatingLabel}
|
|
129
|
-
<
|
|
130
|
-
<span class="text-[0.8125rem] font-medium text-base-content/85">
|
|
131
|
-
{label}
|
|
132
|
-
{#if required}
|
|
133
|
-
<span class="text-error ml-1">*</span>
|
|
134
|
-
{/if}
|
|
135
|
-
</span>
|
|
136
|
-
</label>
|
|
147
|
+
<FieldLabel for={id} text={label} {required} tooltip={labelTooltip} />
|
|
137
148
|
{/if}
|
|
138
149
|
|
|
139
|
-
|
|
150
|
+
{#snippet field()}
|
|
140
151
|
<input
|
|
141
152
|
{...rest}
|
|
142
153
|
bind:this={inputEl}
|
|
@@ -161,6 +172,18 @@
|
|
|
161
172
|
aria-invalid={error ? 'true' : 'false'}
|
|
162
173
|
onanimationend={() => inputEl?.classList.remove('jatui-input-shake')}
|
|
163
174
|
/>
|
|
175
|
+
{/snippet}
|
|
176
|
+
|
|
177
|
+
<div class:jatui-float-field={floatingLabel}>
|
|
178
|
+
{#if hasAddons}
|
|
179
|
+
<div class="{shellClass} jatui-input-shell" bind:this={shellEl} onanimationend={() => shellEl?.classList.remove('jatui-input-shake')}>
|
|
180
|
+
{#if leading}<span class="jatui-input-addon text-base-content/55">{@render leading()}</span>{/if}
|
|
181
|
+
{@render field()}
|
|
182
|
+
{#if trailing}<span class="jatui-input-addon text-base-content/55">{@render trailing()}</span>{/if}
|
|
183
|
+
</div>
|
|
184
|
+
{:else}
|
|
185
|
+
{@render field()}
|
|
186
|
+
{/if}
|
|
164
187
|
|
|
165
188
|
{#if label && floatingLabel}
|
|
166
189
|
<label for={id} class="jatui-float-label">
|
|
@@ -181,13 +204,9 @@
|
|
|
181
204
|
{/if}
|
|
182
205
|
|
|
183
206
|
{#if error}
|
|
184
|
-
<
|
|
185
|
-
<span id="{id}-error" class="text-[0.8125rem] text-error" role="alert">{error}</span>
|
|
186
|
-
</div>
|
|
207
|
+
<FieldHint id="{id}-error" text={error} error />
|
|
187
208
|
{:else if helpText}
|
|
188
|
-
<
|
|
189
|
-
<span id="{id}-help" class="text-[0.8125rem] text-base-content/45">{helpText}</span>
|
|
190
|
-
</div>
|
|
209
|
+
<FieldHint id="{id}-help" text={helpText} />
|
|
191
210
|
{/if}
|
|
192
211
|
</div>
|
|
193
212
|
|
|
@@ -213,6 +232,38 @@
|
|
|
213
232
|
}
|
|
214
233
|
}
|
|
215
234
|
|
|
235
|
+
/* ── Leading / trailing addons ────────────────────────────────
|
|
236
|
+
The shell carries the DaisyUI input skin; the inner input is bare and
|
|
237
|
+
grows. Focus ring comes from the shell's :focus-within. */
|
|
238
|
+
.jatui-input-shell {
|
|
239
|
+
display: flex;
|
|
240
|
+
align-items: center;
|
|
241
|
+
gap: var(--jatui-space-base, 8px);
|
|
242
|
+
}
|
|
243
|
+
.jatui-input-shell:focus-within {
|
|
244
|
+
outline: 2px solid var(--color-base-content);
|
|
245
|
+
outline-offset: 2px;
|
|
246
|
+
}
|
|
247
|
+
.jatui-input-shell.input-error:focus-within {
|
|
248
|
+
outline-color: var(--color-error);
|
|
249
|
+
}
|
|
250
|
+
:global(.jatui-input-bare) {
|
|
251
|
+
flex: 1 1 auto;
|
|
252
|
+
min-width: 0;
|
|
253
|
+
height: 100%;
|
|
254
|
+
background: transparent;
|
|
255
|
+
border: 0;
|
|
256
|
+
outline: none;
|
|
257
|
+
font: inherit;
|
|
258
|
+
color: inherit;
|
|
259
|
+
}
|
|
260
|
+
.jatui-input-addon {
|
|
261
|
+
display: inline-flex;
|
|
262
|
+
flex-shrink: 0;
|
|
263
|
+
align-items: center;
|
|
264
|
+
white-space: nowrap;
|
|
265
|
+
}
|
|
266
|
+
|
|
216
267
|
/* ── Floating Label ───────────────────────────────────────────
|
|
217
268
|
Label sits inside the field at rest, lifts + scales on focus or filled.
|
|
218
269
|
Pure CSS: uses :placeholder-shown to detect empty state (input must have
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Adds: motion-token error shake (embedded keyframe, no host-app dependency),
|
|
6
6
|
* opt-in floatingLabel prop (label translates/scales on focus-or-filled, pure CSS).
|
|
7
7
|
*/
|
|
8
|
+
import type { Snippet } from 'svelte';
|
|
8
9
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
9
10
|
/**
|
|
10
11
|
* Any other native attribute (aria-label, aria-labelledby, data-*, title,
|
|
@@ -35,6 +36,12 @@ interface Props extends Omit<HTMLInputAttributes, 'value' | 'type' | 'size' | 'c
|
|
|
35
36
|
* consumers are unaffected when this prop is absent/false.
|
|
36
37
|
*/
|
|
37
38
|
floatingLabel?: boolean;
|
|
39
|
+
/** Leading icon / addon inside the field (e.g. a search glyph, "https://"). */
|
|
40
|
+
leading?: Snippet;
|
|
41
|
+
/** Trailing icon / addon inside the field (e.g. a unit, a clear button). */
|
|
42
|
+
trailing?: Snippet;
|
|
43
|
+
/** Info-icon hint after the label (shared `use:tooltip`). */
|
|
44
|
+
labelTooltip?: string;
|
|
38
45
|
class?: string;
|
|
39
46
|
id?: string;
|
|
40
47
|
name?: string;
|