@weft-js/warp 0.0.0-stage → 0.1.1
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/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
package/src/Menu.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { menu, moveShuttle, uid, type Align, type Side } from '@weft-js/warp-core'
|
|
2
|
+
import { onCleanup, onMount, state } from '@weft-js/core'
|
|
3
|
+
import { Button, type ButtonProps } from './Button'
|
|
4
|
+
import { Icon, type IconRef } from './Icon'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/components/menu.css'
|
|
8
|
+
|
|
9
|
+
/** One line of a Menu: an item, a group label or a separator. */
|
|
10
|
+
export type MenuEntry =
|
|
11
|
+
| { label: string; onSelect?: () => void; icon?: IconRef | undefined; shortcut?: string; tone?: 'critical' | undefined; disabled?: boolean | undefined; href?: string }
|
|
12
|
+
| { separator: true }
|
|
13
|
+
| { group: string }
|
|
14
|
+
|
|
15
|
+
const Row = ({ e }: { e: Extract<MenuEntry, { label: string }> }) => (
|
|
16
|
+
<>
|
|
17
|
+
{e.icon && <Icon name={e.icon} />}
|
|
18
|
+
<span data-part="item-label">{e.label}</span>
|
|
19
|
+
{e.shortcut && <span data-part="shortcut">{e.shortcut}</span>}
|
|
20
|
+
</>
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Props for Menu. */
|
|
24
|
+
export interface MenuProps {
|
|
25
|
+
/** The trigger's text. */
|
|
26
|
+
label: unknown
|
|
27
|
+
items: MenuEntry[]
|
|
28
|
+
trigger?: ButtonProps | undefined
|
|
29
|
+
side?: Side | undefined
|
|
30
|
+
align?: Align
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A menu button (05 §3, APG): the trigger holds its sweep while open and the panel opens from it. warp-core's menu() owns
|
|
35
|
+
* focus, ↑/↓, Home/End, typeahead, Escape and Tab; the highlight is one shuttle that follows focus and the pointer.
|
|
36
|
+
*/
|
|
37
|
+
export function Menu({ label, items, trigger, side = 'bottom', align = 'start' }: MenuProps) {
|
|
38
|
+
const id = uid('menu')
|
|
39
|
+
const open = state(false)
|
|
40
|
+
const at = state<'first' | 'last'>('first')
|
|
41
|
+
let button: HTMLButtonElement | undefined
|
|
42
|
+
let panel: HTMLDivElement | undefined
|
|
43
|
+
let face: HTMLElement | undefined
|
|
44
|
+
const close = () => (open.value = false)
|
|
45
|
+
const follow = (el: HTMLElement) => {
|
|
46
|
+
moveShuttle(face!, el.offsetTop, el.offsetTop + el.offsetHeight)
|
|
47
|
+
face!.setAttribute('data-on', '')
|
|
48
|
+
}
|
|
49
|
+
onMount(() => {
|
|
50
|
+
const onToggle = (e: ToggleEvent) => {
|
|
51
|
+
if (e.newState === 'open') face!.setAttribute('data-instant', '')
|
|
52
|
+
else {
|
|
53
|
+
face!.removeAttribute('data-on')
|
|
54
|
+
face!.style.removeProperty('--warp-a')
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
panel!.addEventListener('beforetoggle', onToggle)
|
|
58
|
+
onCleanup(() => panel!.removeEventListener('beforetoggle', onToggle))
|
|
59
|
+
})
|
|
60
|
+
// the panel is shown before menu() moves focus in, so the first move lands without travelling
|
|
61
|
+
const syncOpen = (el: HTMLElement, o: { open: boolean; at: 'first' | 'last' }) => {
|
|
62
|
+
const opts = () => ({ open: o.open, at: o.at, trigger: () => button, side, align, onClose: close })
|
|
63
|
+
if (o.open) el.showPopover?.()
|
|
64
|
+
const m = menu(el, opts())
|
|
65
|
+
return {
|
|
66
|
+
update(n: typeof o) {
|
|
67
|
+
o = n
|
|
68
|
+
if (n.open && !el.matches(':popover-open')) el.showPopover?.()
|
|
69
|
+
m.update(opts())
|
|
70
|
+
if (!n.open && el.matches(':popover-open')) el.hidePopover?.()
|
|
71
|
+
if (n.open) requestAnimationFrame(() => requestAnimationFrame(() => face?.removeAttribute('data-instant')))
|
|
72
|
+
},
|
|
73
|
+
destroy() { m.destroy() },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
use(syncOpen, () => panel, () => ({ open: open.value, at: at.value }))
|
|
77
|
+
const onKey = (e: KeyboardEvent) => {
|
|
78
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
at.value = e.key === 'ArrowUp' ? 'last' : 'first'
|
|
81
|
+
open.value = true
|
|
82
|
+
}
|
|
83
|
+
const pick = (e: { onSelect?: () => void }) => { close(); button?.focus(); e.onSelect?.() }
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<Button emphasis="outline" tone="neutral" {...(trigger ?? {})} ref={(el: HTMLButtonElement) => (button = el)} id={`${id}-b`} aria-haspopup="menu" aria-expanded={open.value ? 'true' : 'false'} aria-controls={id} onClick={() => { at.value = 'first'; open.value = !open.value }} onKeyDown={onKey}>
|
|
87
|
+
{label}
|
|
88
|
+
</Button>
|
|
89
|
+
<div
|
|
90
|
+
ref={n => (panel = n)}
|
|
91
|
+
data-scope="menu"
|
|
92
|
+
data-part="content"
|
|
93
|
+
role="menu"
|
|
94
|
+
popover="manual"
|
|
95
|
+
id={id}
|
|
96
|
+
aria-labelledby={`${id}-b`}
|
|
97
|
+
onFocusIn={(e: FocusEvent) => { const t = (e.target as HTMLElement).closest?.<HTMLElement>('[role="menuitem"]'); if (t) follow(t) }}
|
|
98
|
+
onPointerMove={(e: PointerEvent) => { const t = (e.target as HTMLElement).closest?.<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'); if (t && t !== document.activeElement) t.focus({ preventScroll: true }) }}
|
|
99
|
+
>
|
|
100
|
+
<span ref={n => (face = n)} data-part="shuttle" aria-hidden="true"></span>
|
|
101
|
+
{items.map((e, i) => 'separator' in e
|
|
102
|
+
? <div key={i} data-part="separator" role="separator"></div>
|
|
103
|
+
: 'group' in e
|
|
104
|
+
? <div key={i} data-part="group-label">{e.group}</div>
|
|
105
|
+
: e.href && !e.disabled
|
|
106
|
+
? <a key={i} href={e.href} data-part="item" role="menuitem" tabindex="-1" data-tone={e.tone} onClick={() => pick(e)}><Row e={e} /></a>
|
|
107
|
+
: <div key={i} data-part="item" role="menuitem" tabindex="-1" data-tone={e.tone} aria-disabled={e.disabled ? 'true' : undefined} onClick={() => { if (!e.disabled) pick(e) }}><Row e={e} /></div>)}
|
|
108
|
+
</div>
|
|
109
|
+
</>
|
|
110
|
+
)
|
|
111
|
+
}
|
package/src/Modal.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { dialog, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, untrack, type State } from '@weft-js/core'
|
|
4
|
+
import type { Tone } from './Button'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import './styles'
|
|
7
|
+
import '@weft-js/warp-core/styles/components/button.css' // the close button's markup
|
|
8
|
+
import '@weft-js/warp-core/styles/components/dialog.css'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A modal dialog on the native <dialog> (05 §3; components.html:1067-1090): showModal, focus to [autofocus] or the
|
|
12
|
+
* title, back to the opener on every close path, scroll lock by CSS. Named by its title, described by its
|
|
13
|
+
* description. `open` is a box: the dialog writes false when the user closes it. `alert` makes it an alert dialog
|
|
14
|
+
* (no backdrop dismiss; focus Cancel by giving it autofocus). `inline` draws it open in the page's flow, for docs and
|
|
15
|
+
* previews: a non-modal <dialog open> with no top layer, scrim, focus trap or scroll lock; `open` still shows and hides it.
|
|
16
|
+
*/
|
|
17
|
+
export function Modal({ open, title, description, footer, size, placement, alert, tone, mark, inline, children }: { open: State<boolean>; title: unknown; description?: unknown; footer?: unknown; size?: 'sm' | 'md' | 'lg' | undefined; /** Where it sits, and the edge it arrives from. */ placement?: 'center' | 'top' | 'bottom' | 'left' | 'right' | 'full' | undefined; alert?: boolean | undefined; tone?: Tone | undefined; /** Drawn above the title (09's result mark). */ mark?: unknown; /** Drawn open in the page's flow, not over it: no top layer, scrim or focus trap. For docs and previews. */ inline?: boolean; children?: unknown }) {
|
|
18
|
+
const id = uid('dlg')
|
|
19
|
+
const hasDescription = derived(() => description !== undefined)
|
|
20
|
+
const sign = derived(() => mark)
|
|
21
|
+
const foot = derived(() => footer) // one build of the footer, however often it is read
|
|
22
|
+
let el: HTMLDialogElement | undefined
|
|
23
|
+
const flat = untrack(() => !!inline)
|
|
24
|
+
if (!flat) use(dialog, () => el, () => ({ open: open.value, onClose: () => (open.value = false) }))
|
|
25
|
+
// inline: [data-close] still closes it, by `open`, as the dialog action would
|
|
26
|
+
const closer = (e: Event) => { if (flat && (e.target as Element).closest?.('[data-close]')) open.value = false }
|
|
27
|
+
return (
|
|
28
|
+
<dialog ref={n => (el = n)} open={flat ? open.value : undefined} data-presentation={flat ? 'inline' : undefined} onClick={closer} data-scope="dialog" role={alert ? 'alertdialog' : undefined} data-size={size === 'md' ? undefined : size} data-placement={placement === 'center' ? undefined : placement} data-tone={tone} aria-labelledby={`${id}-t`} aria-describedby={hasDescription.value ? `${id}-d` : undefined}>
|
|
29
|
+
<div data-part="header">
|
|
30
|
+
{sign.value}
|
|
31
|
+
<h2 data-part="title" id={`${id}-t`}>{title}</h2>
|
|
32
|
+
{hasDescription.value && <p data-part="description" id={`${id}-d`}>{description}</p>}
|
|
33
|
+
{/* <Button emphasis="ghost" tone="neutral" icon={XIcon} label="Close"> as its markup: a ghost button needs no JS */}
|
|
34
|
+
<button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-icon-only="" aria-label="Close" data-part="close" data-close=""><XIcon /></button>
|
|
35
|
+
</div>
|
|
36
|
+
<div data-part="body">{children}</div>
|
|
37
|
+
{foot.value !== undefined && <div data-part="footer">{foot.value}</div>}
|
|
38
|
+
</dialog>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { DownIcon, LeftIcon, RightIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { addDays, dow, fmt, fmtRange, isoWeek, isoWeekStart, pad, toIso, weekOf, ymd, type ChangeReason, type IsoMonth, type IsoWeek } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, flush, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Button } from './Button'
|
|
5
|
+
import { CalendarGrid } from './Calendar'
|
|
6
|
+
import { bindable, Composite, Hidden, Panel, useOpen, usePickerEnv, type Bindable, type Openable, type RichFieldProps } from './Picker'
|
|
7
|
+
import '@weft-js/warp-core/styles/rich/monthgrid.css'
|
|
8
|
+
import '@weft-js/warp-core/styles/rich/gap-rows.css'
|
|
9
|
+
|
|
10
|
+
export type Cell = { v: string; text: string; name: string }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The period grid (04 §2): a title with arrows over rows of cells, one tab stop. ←/→ ±1, ↑/↓ ± a row, PageUp/PageDown
|
|
14
|
+
* a page, Enter/Space picks; cells are named in full.
|
|
15
|
+
*/
|
|
16
|
+
function PeriodGrid({ title, cells, columns, value, current, prev, next, onPage, onPick, wide }: { title: string; cells: Cell[]; columns: number; value: string | null; current: string; prev: string; next: string; onPage: (d: number) => void; onPick: (v: string, r: ChangeReason) => void; wide?: boolean }) {
|
|
17
|
+
const at = state(untrack(() => value ?? current))
|
|
18
|
+
let box: HTMLDivElement | undefined
|
|
19
|
+
const rows = derived(() => Array.from({ length: Math.ceil(cells.length / columns) }, (_, r) => ({ k: cells[r * columns]!.v, cells: cells.slice(r * columns, r * columns + columns) })))
|
|
20
|
+
const stop = derived(() => (cells.some(c => c.v === at.value) ? at.value : cells[0]!.v))
|
|
21
|
+
const focus = () => { flush(); box?.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]')?.focus() }
|
|
22
|
+
const onKey = (e: KeyboardEvent) => {
|
|
23
|
+
const el = (e.target as Element).closest?.<HTMLElement>('[data-v]')
|
|
24
|
+
if (!el) return
|
|
25
|
+
const i = cells.findIndex(c => c.v === el.dataset.v)
|
|
26
|
+
const d = ({ ArrowLeft: -1, ArrowRight: 1, ArrowUp: -columns, ArrowDown: columns } as Record<string, number>)[e.key]
|
|
27
|
+
if (d) {
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
if (i + d < 0) onPage(-1)
|
|
30
|
+
else if (i + d >= cells.length) onPage(1)
|
|
31
|
+
else at.value = cells[i + d]!.v
|
|
32
|
+
focus()
|
|
33
|
+
} else if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); onPage(e.key === 'PageUp' ? -1 : 1); focus() }
|
|
34
|
+
else if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); at.value = cells[e.key === 'Home' ? 0 : cells.length - 1]!.v; focus() }
|
|
35
|
+
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(cells[i]!.v, 'keyboard') }
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div ref={n => (box = n)} data-scope="monthgrid" onKeyDown={onKey}>
|
|
39
|
+
<div data-part="head">
|
|
40
|
+
<Button emphasis="ghost" size="sm" icon={LeftIcon} label={prev} data-icon="" onClick={() => onPage(-1)} />
|
|
41
|
+
<b aria-live="polite">{title}</b>
|
|
42
|
+
<Button emphasis="ghost" size="sm" icon={RightIcon} label={next} data-icon="" onClick={() => onPage(1)} />
|
|
43
|
+
</div>
|
|
44
|
+
<div role="grid" aria-label={title} class={wide ? 'pg' : undefined} style={wide ? `--c:${columns}` : undefined}>
|
|
45
|
+
{rows.value.map(r => (
|
|
46
|
+
<div key={r.k} role="row">
|
|
47
|
+
{r.cells.map(c => <button key={c.v} type="button" role="gridcell" data-v={c.v} data-initial={c.v === stop.value ? '' : undefined} tabindex={c.v === stop.value ? '0' : '-1'} aria-selected={c.v === value ? 'true' : 'false'} aria-current={c.v === current ? 'date' : undefined} onClick={() => onPick(c.v, 'pointer')}><span aria-hidden="true">{c.text}</span><span class="sr">{c.name + (c.v === current ? ', this period' : '')}</span></button>)}
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const monthCells = (year: number, locale: string): Cell[] => Array.from({ length: 12 }, (_, i) => ({ v: `${year}-${pad(i + 1)}`, text: fmt(toIso(year, i + 1, 1), { month: 'short' }, locale), name: fmt(toIso(year, i + 1, 1), { month: 'long', year: 'numeric' }, locale) }))
|
|
56
|
+
const yearOf = (v: string | null, today: string) => +(v ?? today).slice(0, 4)
|
|
57
|
+
|
|
58
|
+
/** The `pick` button of a field whose whole control opens its panel. */
|
|
59
|
+
function PickButton({ el, text, label, open, disabled, onToggle }: { el: (b: HTMLButtonElement) => void; text: string; label: string; open: boolean; disabled?: boolean | undefined; onToggle: () => void }) {
|
|
60
|
+
return (
|
|
61
|
+
<button ref={n => el(n)} type="button" data-part="pick" aria-haspopup="dialog" aria-expanded={open ? 'true' : 'false'} aria-label={label} disabled={disabled} onClick={onToggle}>
|
|
62
|
+
<span>{text}</span><DownIcon />
|
|
63
|
+
</button>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Props for MonthPicker. */
|
|
68
|
+
export type MonthPickerProps = RichFieldProps & Bindable<IsoMonth | null> & Openable & { min?: IsoMonth | undefined; max?: IsoMonth | undefined }
|
|
69
|
+
|
|
70
|
+
/** MonthPicker (04 §2): a 3×4 grid of the locale's short month names with year arrows. */
|
|
71
|
+
export function MonthPicker({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, locale, today, name, presentation, label, disabled, ...rest }: MonthPickerProps) {
|
|
72
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
73
|
+
const v = bindable<IsoMonth | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
74
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
75
|
+
const year = state(yearOf(untrack(() => v.value), untrack(() => env.today.value)))
|
|
76
|
+
const shown = derived(() => (v.value ? fmt(`${v.value}-01`, { month: 'long', year: 'numeric' }, env.locale.value) : ''))
|
|
77
|
+
let trigger: HTMLButtonElement | undefined
|
|
78
|
+
return (
|
|
79
|
+
<Composite
|
|
80
|
+
{...rest}
|
|
81
|
+
label={label}
|
|
82
|
+
disabled={disabled}
|
|
83
|
+
bound={v}
|
|
84
|
+
filled={!!v.value}
|
|
85
|
+
hold={pop.value}
|
|
86
|
+
control={() => <><PickButton el={n => (trigger = n)} text={shown.value} label={`${label}, ${shown.value || 'none'}`} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} /><Hidden name={name} value={() => v.value ?? ''} /></>}
|
|
87
|
+
end={
|
|
88
|
+
<Panel label="Choose a month" open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
89
|
+
{() => <PeriodGrid title={String(year.value)} cells={monthCells(year.value, env.locale.value)} columns={3} value={v.value} current={env.today.value.slice(0, 7)} prev="Previous year" next="Next year" onPage={d => (year.value += d)} onPick={(x, r) => { v.set(x as IsoMonth, r); pop.set(false) }} />}
|
|
90
|
+
</Panel>
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** WeekPicker's props: a field whose value is an ISO week, or null. */
|
|
97
|
+
export type WeekPickerProps = RichFieldProps & Bindable<IsoWeek | null> & Openable & {
|
|
98
|
+
/** The day each grid row starts on: `iso` (the default) starts on Monday, so a row is one ISO 8601 week; `locale` starts on the locale's first day of the week. The value is an ISO week either way. */
|
|
99
|
+
numbering?: 'iso' | 'locale'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const weekName = (w: string, locale: string) => {
|
|
103
|
+
const s = isoWeekStart(w)
|
|
104
|
+
return `Week ${+w.slice(6)}, ${fmtRange(s, addDays(s, 6), locale, { month: 'long', day: 'numeric', year: 'numeric' })}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** WeekPicker (04 §2): the month grid with a leading week number; selection covers the whole row. */
|
|
108
|
+
export function WeekPicker({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, numbering = 'iso', locale, today, name, presentation, label, disabled, ...rest }: WeekPickerProps) {
|
|
109
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
110
|
+
const v = bindable<IsoWeek | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
111
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
112
|
+
const shown = derived(() => (v.value ? `Week ${+v.value.slice(6)}, ${v.value.slice(0, 4)}` : ''))
|
|
113
|
+
let trigger: HTMLButtonElement | undefined
|
|
114
|
+
return (
|
|
115
|
+
<Composite
|
|
116
|
+
{...rest}
|
|
117
|
+
label={label}
|
|
118
|
+
disabled={disabled}
|
|
119
|
+
bound={v}
|
|
120
|
+
filled={!!v.value}
|
|
121
|
+
hold={pop.value}
|
|
122
|
+
control={() => <><PickButton el={n => (trigger = n)} text={shown.value} label={v.value ? `${label}, ${shown.value}, ${weekName(v.value, env.locale.value)}` : `${label}, none`} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} /><Hidden name={name} value={() => v.value ?? ''} /></>}
|
|
123
|
+
end={
|
|
124
|
+
<Panel label="Choose a week" open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
125
|
+
{() => <CalendarGrid mode="week" value={() => v.value} firstDayOfWeek={numbering === 'iso' ? 1 : undefined} weekNumbers locale={env.locale.value} today={env.today.value} autofocus onPick={(iso, r) => { v.set(weekOf(iso), r); pop.set(false) }} />}
|
|
126
|
+
</Panel>
|
|
127
|
+
}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type Granularity = 'year' | 'quarter' | 'month' | 'week'
|
|
133
|
+
/** Props for PeriodPicker. */
|
|
134
|
+
export type PeriodPickerProps = RichFieldProps & Bindable<string | null> & Openable & { granularity?: Granularity }
|
|
135
|
+
|
|
136
|
+
const granularityOf = (v: string | null | undefined): Granularity => (!v ? 'month' : /-W/.test(v) ? 'week' : /-Q/.test(v) ? 'quarter' : /-/.test(v) ? 'month' : 'year')
|
|
137
|
+
|
|
138
|
+
/** A period value in words (`Q4 2026`, `October 2026`, `Week 44, 2026`). */
|
|
139
|
+
function periodText(v: string, locale: string) {
|
|
140
|
+
const g = granularityOf(v)
|
|
141
|
+
return g === 'year' ? v : g === 'quarter' ? `Q${v.slice(-1)} ${v.slice(0, 4)}` : g === 'month' ? fmt(`${v}-01`, { month: 'long', year: 'numeric' }, locale) : `Week ${+v.slice(6)}, ${v.slice(0, 4)}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function periodCells(g: Granularity, year: number, locale: string): Cell[] {
|
|
145
|
+
if (g === 'year') { const b = Math.floor(year / 12) * 12; return Array.from({ length: 12 }, (_, i) => ({ v: `${b + i}`, text: `${b + i}`, name: `${b + i}` })) }
|
|
146
|
+
if (g === 'quarter') return [1, 2, 3, 4].map(q => ({ v: `${year}-Q${q}`, text: `Q${q}`, name: `Q${q} ${year}, ${fmt(toIso(year, q * 3 - 2, 1), { month: 'short' }, locale)} to ${fmt(toIso(year, q * 3, 1), { month: 'short' }, locale)}` }))
|
|
147
|
+
if (g === 'month') return monthCells(year, locale)
|
|
148
|
+
const out: Cell[] = []
|
|
149
|
+
let d = toIso(year, 1, 4) as string
|
|
150
|
+
d = addDays(d, -((dow(d) + 6) % 7))
|
|
151
|
+
for (let i = 0; i < 53; i++, d = addDays(d, 7)) {
|
|
152
|
+
const w = isoWeek(d)
|
|
153
|
+
if (w.year !== year) break
|
|
154
|
+
out.push({ v: `${w.year}-W${pad(w.week)}`, text: `W${w.week}`, name: `Week ${w.week}, ${fmt(d, { month: 'short', day: 'numeric' }, locale)} to ${fmt(addDays(d, 6), { month: 'short', day: 'numeric', year: 'numeric' }, locale)}` })
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** PeriodPicker (04 §2): one trigger, one grid whose granularity switches. Values: `2026`, `2026-Q4`, `2026-10`, `2026-W44`. */
|
|
160
|
+
export function PeriodPicker({ value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, granularity, locale, today, name, presentation, label, disabled, ...rest }: PeriodPickerProps) {
|
|
161
|
+
const env = usePickerEnv(() => locale, () => today)
|
|
162
|
+
const v = bindable<string | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
163
|
+
const pop = useOpen(() => open, () => defaultOpen, () => onOpenChange)
|
|
164
|
+
const g = derived(() => granularity ?? granularityOf(v.value))
|
|
165
|
+
const year = state(yearOf(untrack(() => v.value), untrack(() => env.today.value)))
|
|
166
|
+
const shown = derived(() => (v.value ? periodText(v.value, env.locale.value) : ''))
|
|
167
|
+
const span = derived(() => { const b = Math.floor(year.value / 12) * 12; return g.value === 'year' ? `${b}–${b + 11}` : String(year.value) })
|
|
168
|
+
const current = derived(() => {
|
|
169
|
+
const t = env.today.value
|
|
170
|
+
const [y, m] = ymd(t)
|
|
171
|
+
return { year: `${y}`, quarter: `${y}-Q${Math.ceil(m / 3)}`, month: t.slice(0, 7), week: weekOf(t) }[g.value]
|
|
172
|
+
})
|
|
173
|
+
let trigger: HTMLButtonElement | undefined
|
|
174
|
+
return (
|
|
175
|
+
<Composite
|
|
176
|
+
{...rest}
|
|
177
|
+
label={label}
|
|
178
|
+
disabled={disabled}
|
|
179
|
+
bound={v}
|
|
180
|
+
filled={!!v.value}
|
|
181
|
+
hold={pop.value}
|
|
182
|
+
control={() => <><PickButton el={n => (trigger = n)} text={shown.value} label={`${label}, ${shown.value || 'none'}`} open={pop.value} disabled={disabled} onToggle={() => pop.set(!pop.value)} /><Hidden name={name} value={() => v.value ?? ''} /></>}
|
|
183
|
+
end={
|
|
184
|
+
<Panel label="Choose a period" open={pop} trigger={() => trigger!} presentation={presentation}>
|
|
185
|
+
{() => <PeriodGrid wide title={span.value} cells={periodCells(g.value, year.value, env.locale.value)} columns={g.value === 'quarter' ? 4 : g.value === 'week' ? 7 : 3} value={v.value} current={current.value} prev="Previous" next="Next" onPage={d => (year.value += d * (g.value === 'year' ? 12 : 1))} onPick={(x, r) => { v.set(x, r); pop.set(false) }} />}
|
|
186
|
+
</Panel>
|
|
187
|
+
}
|
|
188
|
+
/>
|
|
189
|
+
)
|
|
190
|
+
}
|
package/src/Nav.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { navIndicator } from '@weft-js/warp-core'
|
|
2
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
3
|
+
import './styles'
|
|
4
|
+
import '@weft-js/warp-core/styles/components/nav.css'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Links with warp's sliding bar. It grows in from an item's edge, slides as the route or the pointer moves, and grows
|
|
8
|
+
* out through the far edge. Mark the current link with `aria-current` (`Link` from `@weft-js/start` does). `spy` follows
|
|
9
|
+
* the headings of a table of contents. The bar is on the current link in the server's HTML, so nothing moves on load.
|
|
10
|
+
*/
|
|
11
|
+
export function Nav({ label, axis = 'x', spy, children, ...rest }: { label: string; axis?: 'x' | 'y'; spy?: boolean | undefined; children?: unknown; [attr: string]: unknown }) {
|
|
12
|
+
let el: HTMLElement | undefined
|
|
13
|
+
use(navIndicator, () => el, () => ({ axis, spy }))
|
|
14
|
+
return (
|
|
15
|
+
<nav {...rest} ref={n => (el = n)} data-scope="nav" data-axis={axis} aria-label={label}>
|
|
16
|
+
{children}
|
|
17
|
+
<span data-part="indicator" aria-hidden="true"></span>
|
|
18
|
+
</nav>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { DownIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { derived, untrack } from '@weft-js/core'
|
|
3
|
+
import { bindable, type Bindable } from './bind'
|
|
4
|
+
import { Field, type FieldShell } from './Field'
|
|
5
|
+
import '@weft-js/warp-core/styles/inputs/text.css'
|
|
6
|
+
|
|
7
|
+
/** Props for NumberField. */
|
|
8
|
+
export type NumberFieldProps = FieldShell & Bindable<number | null> & { min?: number; max?: number; step?: number; name?: string; required?: boolean }
|
|
9
|
+
|
|
10
|
+
const PAGE = 10
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A number in the field shell (03: `inputmode` over `type="number"`, which changes on a wheel, takes "e" and has its own
|
|
14
|
+
* locale bugs). The input stays real text, so editing, paste and the numeric keypad are the OS's; warp adds APG spinbutton
|
|
15
|
+
* keys (↑/↓, Page keys, Home/End with a bound) and two steppers. Text that isn't a number leaves the value alone until blur.
|
|
16
|
+
*/
|
|
17
|
+
export function NumberField({ value, defaultValue, onValueChange, min, max, step = 1, name, disabled, readonly, ...rest }: NumberFieldProps) {
|
|
18
|
+
const v = bindable<number | null>(() => value, () => defaultValue, null, () => onValueChange)
|
|
19
|
+
const fit = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n))
|
|
20
|
+
const shown = derived(() => (v.value === null ? '' : String(v.value)))
|
|
21
|
+
let input: HTMLInputElement | undefined
|
|
22
|
+
const write = (n: number | null) => {
|
|
23
|
+
if (n !== untrack(() => v.value)) v.set(n, 'keyboard')
|
|
24
|
+
if (input) input.value = n === null ? '' : String(n)
|
|
25
|
+
}
|
|
26
|
+
const nudge = (by: number) => write(fit((untrack(() => v.value) ?? min ?? 0) + by))
|
|
27
|
+
const onKey = (e: KeyboardEvent) => {
|
|
28
|
+
if (disabled || readonly) return
|
|
29
|
+
const by = ({ ArrowUp: step, ArrowDown: -step, PageUp: step * PAGE, PageDown: -step * PAGE } as Record<string, number>)[e.key]
|
|
30
|
+
if (by) { e.preventDefault(); return nudge(by) }
|
|
31
|
+
if (e.key === 'Home' && min !== undefined) { e.preventDefault(); write(min) }
|
|
32
|
+
if (e.key === 'End' && max !== undefined) { e.preventDefault(); write(max) }
|
|
33
|
+
}
|
|
34
|
+
const onInput = (e: Event) => {
|
|
35
|
+
const text = (e.currentTarget as HTMLInputElement).value.trim()
|
|
36
|
+
const n = Number(text)
|
|
37
|
+
if (text === '') v.set(null, 'keyboard')
|
|
38
|
+
else if (Number.isFinite(n)) v.set(fit(n), 'keyboard')
|
|
39
|
+
}
|
|
40
|
+
const onBlur = () => { if (input) input.value = untrack(() => shown.value) }
|
|
41
|
+
const stepper = (label: string, by: number, off: boolean) => (
|
|
42
|
+
<button type="button" data-part="step" tabindex="-1" aria-label={label} disabled={disabled || readonly || off} onMouseDown={(e: Event) => e.preventDefault()} onClick={() => nudge(by)}><DownIcon /></button>
|
|
43
|
+
)
|
|
44
|
+
return (
|
|
45
|
+
<Field
|
|
46
|
+
{...rest}
|
|
47
|
+
disabled={disabled}
|
|
48
|
+
readonly={readonly}
|
|
49
|
+
filled={shown.value !== ''}
|
|
50
|
+
end={<span data-part="steps">{stepper('Increase', step, max !== undefined && v.value !== null && v.value >= max)}{stepper('Decrease', -step, min !== undefined && v.value !== null && v.value <= min)}</span>}
|
|
51
|
+
control={p => (
|
|
52
|
+
<input
|
|
53
|
+
ref={n => (input = n)}
|
|
54
|
+
data-part="input"
|
|
55
|
+
id={p.id}
|
|
56
|
+
role="spinbutton"
|
|
57
|
+
type="text"
|
|
58
|
+
inputmode={step % 1 || (min ?? 0) < 0 ? 'decimal' : 'numeric'}
|
|
59
|
+
autocomplete="off"
|
|
60
|
+
name={name}
|
|
61
|
+
placeholder=" "
|
|
62
|
+
required={p.rest.required}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
readonly={readonly}
|
|
65
|
+
value={shown.value}
|
|
66
|
+
aria-valuenow={v.value ?? undefined}
|
|
67
|
+
aria-valuemin={min}
|
|
68
|
+
aria-valuemax={max}
|
|
69
|
+
aria-describedby={p.describedby}
|
|
70
|
+
aria-invalid={p.invalid ? 'true' : undefined}
|
|
71
|
+
onInput={onInput}
|
|
72
|
+
onKeyDown={onKey}
|
|
73
|
+
onBlur={onBlur}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
/>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { infiniteScroll, pageList, uid } from '@weft-js/warp-core'
|
|
2
|
+
import { derived, flush, untrack, type State } from '@weft-js/core'
|
|
3
|
+
import { Button } from './Button'
|
|
4
|
+
import { bindable } from './bind'
|
|
5
|
+
import { CompactSelect } from './Select'
|
|
6
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
7
|
+
import './styles'
|
|
8
|
+
import '@weft-js/warp-core/styles/data/tokens.css'
|
|
9
|
+
import '@weft-js/warp-core/styles/data/base.css'
|
|
10
|
+
import '@weft-js/warp-core/styles/data/pagination.css'
|
|
11
|
+
import '@weft-js/warp-core/styles/data/load-more.css'
|
|
12
|
+
|
|
13
|
+
/** Props for PaginationBar. */
|
|
14
|
+
export type PaginationBarProps = {
|
|
15
|
+
/** The nav landmark's name; give each bar on a page its own. */
|
|
16
|
+
label?: string
|
|
17
|
+
/** offset (default): numbered pages. cursor: Previous and Next, for a list with no page count. */
|
|
18
|
+
mode?: 'offset' | 'cursor'
|
|
19
|
+
/** 1-based. */
|
|
20
|
+
page?: State<number> | number
|
|
21
|
+
/** The page it opens on when `page` isn't given. */
|
|
22
|
+
defaultPage?: number
|
|
23
|
+
/** Hears the new page (offset) or page index (cursor) on every move. */
|
|
24
|
+
onPageChange?: (page: number) => void
|
|
25
|
+
/** offset: how many rows there are. */
|
|
26
|
+
total?: number
|
|
27
|
+
/** Rows per page: a State to share it, or a number. 25 without it. */
|
|
28
|
+
size?: State<number> | number
|
|
29
|
+
/** The choices in the rows-per-page menu. */
|
|
30
|
+
sizes?: number[]
|
|
31
|
+
/** Hears a new rows-per-page choice; the bar goes back to page 1. */
|
|
32
|
+
onSizeChange?: (size: number) => void
|
|
33
|
+
/** cursor: there is a page after this one, so Next is on. */
|
|
34
|
+
hasMore?: boolean
|
|
35
|
+
/** cursor: there is a page before this one, so Previous is on. */
|
|
36
|
+
hasPrev?: boolean
|
|
37
|
+
/** cursor: the 0-based page showing, which `onPageChange` steps from. */
|
|
38
|
+
pageIndex?: number
|
|
39
|
+
/** cursor: how many rows there are in all, when known ("Showing 50 of 1,200"). */
|
|
40
|
+
supply?: number
|
|
41
|
+
/** cursor: how many rows have loaded so far; `(pageIndex + 1) * size` without it. */
|
|
42
|
+
totalFetched?: number
|
|
43
|
+
/** The locale its numbers are written in. en-US without it. */
|
|
44
|
+
locale?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The pagination bar (06 §8; data.html:1893-1913). Offset mode: rows per page, the range, ‹ 1 … 4 5 6 … 100 › and Go to;
|
|
49
|
+
* under 520px of its own width only ‹ current › remain and the range says the rest. Cursor mode: Previous and Next,
|
|
50
|
+
* and how many are showing. A press keeps focus on the current page, whichever button that now is.
|
|
51
|
+
*/
|
|
52
|
+
export function PaginationBar({ label = 'Pagination', mode = 'offset', page, defaultPage, onPageChange, total = 0, size, sizes = [10, 25, 50, 100], onSizeChange, hasMore, hasPrev, pageIndex = 0, supply, totalFetched, locale }: PaginationBarProps) {
|
|
53
|
+
const at = bindable<number>(() => page, () => defaultPage, 1, () => p => onPageChange?.(p))
|
|
54
|
+
const per = bindable<number>(() => size, () => undefined, 25, () => s => onSizeChange?.(s))
|
|
55
|
+
const pages = derived(() => Math.max(1, Math.ceil(total / per.value)))
|
|
56
|
+
const current = derived(() => Math.min(pages.value, Math.max(1, at.value)))
|
|
57
|
+
const fmt = (n: number) => n.toLocaleString(locale ?? 'en-US')
|
|
58
|
+
let nav: HTMLElement | undefined
|
|
59
|
+
const go = (p: number) => {
|
|
60
|
+
at.set(Math.min(pages.value, Math.max(1, p)))
|
|
61
|
+
flush()
|
|
62
|
+
nav?.querySelector<HTMLElement>('[aria-current="page"]')?.focus()
|
|
63
|
+
}
|
|
64
|
+
const jump = (e: KeyboardEvent) => {
|
|
65
|
+
if (e.key !== 'Enter') return
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
const box = e.currentTarget as HTMLInputElement
|
|
68
|
+
const n = parseInt(box.value, 10)
|
|
69
|
+
if (n) at.set(Math.min(pages.value, Math.max(1, n)))
|
|
70
|
+
box.value = ''
|
|
71
|
+
}
|
|
72
|
+
if (untrack(() => mode) === 'cursor') {
|
|
73
|
+
return (
|
|
74
|
+
<nav data-scope="pagination" aria-label={label}>
|
|
75
|
+
<span>{`Showing ${fmt(totalFetched ?? (pageIndex + 1) * per.value)}${supply !== undefined ? ` of ${fmt(supply)}` : ''}${hasMore ? ' · more available' : ''}`}</span>
|
|
76
|
+
<span data-part="spacer"></span>
|
|
77
|
+
<ol>
|
|
78
|
+
<li><Button emphasis="outline" tone="neutral" size="sm" disabled={!hasPrev} onClick={() => onPageChange?.(pageIndex - 1)}>Previous</Button></li>
|
|
79
|
+
<li><Button emphasis="outline" tone="neutral" size="sm" disabled={!hasMore} onClick={() => onPageChange?.(pageIndex + 1)}>Next</Button></li>
|
|
80
|
+
</ol>
|
|
81
|
+
</nav>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const id = uid('pg')
|
|
85
|
+
const first = derived(() => (total ? (current.value - 1) * per.value + 1 : 0))
|
|
86
|
+
// "Go to" and its box are one item, so the bar never wraps between them: the pair moves to the next line together
|
|
87
|
+
return (
|
|
88
|
+
<nav ref={n => (nav = n)} data-scope="pagination" aria-label={label}>
|
|
89
|
+
<span data-part="field">
|
|
90
|
+
<span aria-hidden="true">Rows<span data-part="long"> per page</span></span>
|
|
91
|
+
<CompactSelect label="Rows per page" value={String(per.value)} options={sizes.map(s => ({ value: String(s), label: String(s) }))} onValueChange={v => { per.set(+v); at.set(1) }} />
|
|
92
|
+
</span>
|
|
93
|
+
<span>{`${fmt(first.value)}–${fmt(Math.min(total, current.value * per.value))} of ${fmt(total)}`}</span>
|
|
94
|
+
<span data-part="spacer"></span>
|
|
95
|
+
<ol>
|
|
96
|
+
<li><button type="button" data-part="page" data-edge="" disabled={current.value === 1} aria-label="Previous page" onClick={() => go(current.value - 1)}>‹</button></li>
|
|
97
|
+
{pageList(current.value, pages.value).map((p, i) => <li key={p === 'gap' ? `gap${i}` : p} data-part={p === 'gap' ? 'gap' : undefined} aria-hidden={p === 'gap' ? 'true' : undefined}>{p === 'gap' ? '…' : <button type="button" data-part="page" aria-current={p === current.value ? 'page' : undefined} aria-label={`Page ${p}`} onClick={() => go(p)}>{p}</button>}</li>)}
|
|
98
|
+
<li><button type="button" data-part="page" data-edge="" disabled={current.value === pages.value} aria-label="Next page" onClick={() => go(current.value + 1)}>›</button></li>
|
|
99
|
+
</ol>
|
|
100
|
+
<span data-part="field" data-goto="">
|
|
101
|
+
<label for={`${id}-go`}>Go to<span data-part="sr-only"> page</span></label>
|
|
102
|
+
<input id={`${id}-go`} data-part="jump" inputmode="numeric" placeholder={String(pages.value)} onKeyDown={jump} />
|
|
103
|
+
</span>
|
|
104
|
+
</nav>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load more (06 §8): a sentinel watched with a 120px margin, and a button that is always there as the fallback. Set
|
|
110
|
+
* `loading` while a page is on its way (the list shows its skeletons and `aria-busy`); `done` swaps in the end message.
|
|
111
|
+
*/
|
|
112
|
+
export function LoadMore({ onLoad, loading, done, message, auto = true, root }: { onLoad: () => void; loading?: boolean | undefined; done?: boolean; message?: unknown; auto?: boolean; root?: (() => HTMLElement | null) | undefined }) {
|
|
113
|
+
let el: HTMLDivElement | undefined
|
|
114
|
+
const reach = () => { if (auto && !loading && !done) onLoad() }
|
|
115
|
+
use(infiniteScroll, () => el, () => ({ onReach: reach, root: root?.() }))
|
|
116
|
+
return (
|
|
117
|
+
<div ref={n => (el = n)} data-scope="load-more">
|
|
118
|
+
<span aria-live="polite">{loading ? 'Loading…' : message}</span>
|
|
119
|
+
{!done && <Button emphasis="outline" tone="neutral" disabled={!!loading} onClick={onLoad}>Load more</Button>}
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { CardIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { cardBrand, cardMask } from '@weft-js/warp-core'
|
|
3
|
+
import { derived, state, untrack } from '@weft-js/core'
|
|
4
|
+
import { Field, type FieldShell } from './Field'
|
|
5
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
6
|
+
import '@weft-js/warp-core/styles/rich/payment.css'
|
|
7
|
+
|
|
8
|
+
/** The card field a payment provider mounts, as far as warp talks to it. */
|
|
9
|
+
export interface ProviderField { on(e: 'focus' | 'blur' | 'change', f: (ev: { complete?: boolean | undefined; error?: string | undefined }) => void): void; destroy(): void }
|
|
10
|
+
/** The field styles warp hands a provider, since its iframe can't read ours. */
|
|
11
|
+
export interface ProviderAppearance { fontFamily: string; fontSize: string; color: string; errorColor: string; borderRadius: string; borderColor: string }
|
|
12
|
+
/** Props for PaymentCardInput. */
|
|
13
|
+
export type PaymentCardInputProps = FieldShell & { mount: (el: HTMLElement, appearance: ProviderAppearance) => ProviderField }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PaymentCardInput (04 §5): the field shell around the provider's hosted field, driven by its events, with warp's
|
|
17
|
+
* tokens passed into its appearance API. warp never renders an input for a raw card number.
|
|
18
|
+
*/
|
|
19
|
+
export function PaymentCardInput({ mount, label, hint, error, ...rest }: PaymentCardInputProps) {
|
|
20
|
+
const focused = state(false)
|
|
21
|
+
const filled = state(false)
|
|
22
|
+
const failed = state<string | undefined>(undefined)
|
|
23
|
+
let host: HTMLDivElement | undefined
|
|
24
|
+
const attach = (el: HTMLDivElement) => {
|
|
25
|
+
const css = getComputedStyle(el)
|
|
26
|
+
const token = (t: string) => css.getPropertyValue(`--warp-${t}`).trim()
|
|
27
|
+
const field = untrack(() => mount)(el, { fontFamily: css.fontFamily, fontSize: token('text-control-size'), color: token('color-text'), errorColor: token('color-critical-text'), borderRadius: token('radius-control'), borderColor: token('color-border-control') })
|
|
28
|
+
field.on('focus', () => (focused.value = true))
|
|
29
|
+
field.on('blur', () => (focused.value = false))
|
|
30
|
+
field.on('change', ev => { filled.value = ev.complete ?? filled.value; failed.value = ev.error })
|
|
31
|
+
return { update() {}, destroy: () => field.destroy() }
|
|
32
|
+
}
|
|
33
|
+
use(attach, () => host)
|
|
34
|
+
return (
|
|
35
|
+
<Field
|
|
36
|
+
icon={CardIcon}
|
|
37
|
+
{...rest}
|
|
38
|
+
group
|
|
39
|
+
label={label}
|
|
40
|
+
hint={hint ?? 'Secured by your payment provider. warp never sees the number.'}
|
|
41
|
+
error={error ?? failed.value}
|
|
42
|
+
filled={filled.value}
|
|
43
|
+
hold={focused.value}
|
|
44
|
+
control={() => <div ref={n => (host = n)} data-part="provider" role="group" aria-label={`${label}, hosted by the payment provider`}></div>}
|
|
45
|
+
/>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Props for CardNumberText. */
|
|
50
|
+
export type CardNumberTextProps = { last4: string; bin?: string; brand?: string; expiry?: { month: number; year: number } }
|
|
51
|
+
|
|
52
|
+
/** CardNumberText (04 §5): the display of a saved card, `•••• 4242`, with the brand from the BIN and expiry as MM/YY. */
|
|
53
|
+
export function CardNumberText({ last4, bin, brand, expiry }: CardNumberTextProps) {
|
|
54
|
+
const name = derived(() => brand ?? cardBrand(bin ?? ''))
|
|
55
|
+
const exp = derived(() => (expiry ? `${String(expiry.month).padStart(2, '0')}/${String(expiry.year % 100).padStart(2, '0')}` : ''))
|
|
56
|
+
// a span can't be named (aria-prohibited-attr): the mask is hidden from screen readers and the words are said instead
|
|
57
|
+
return (
|
|
58
|
+
<span data-card-text="">
|
|
59
|
+
<span aria-hidden="true">{name.value ? `${name.value} ` : ''}{cardMask(last4, name.value)}{exp.value ? ` · ${exp.value}` : ''}</span>
|
|
60
|
+
<span data-part="sr-only">{`${name.value || 'Card'} ending ${last4}`}{exp.value ? `, expires ${exp.value}` : ''}</span>
|
|
61
|
+
</span>
|
|
62
|
+
)
|
|
63
|
+
}
|