@fishies/ui 0.8.3
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 +26 -0
- package/README.md +60 -0
- package/dist/FishiesImg.d.ts +9 -0
- package/dist/FishiesImg.js +64 -0
- package/dist/Select.d.ts +22 -0
- package/dist/Select.js +95 -0
- package/dist/fx-image.d.ts +3 -0
- package/dist/fx-image.js +25 -0
- package/dist/icons.d.ts +28 -0
- package/dist/icons.generated.d.ts +5 -0
- package/dist/icons.generated.js +658 -0
- package/dist/icons.js +53 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/kit.css +267 -0
- package/dist/kit.d.ts +159 -0
- package/dist/kit.js +104 -0
- package/dist/loader.css +48 -0
- package/dist/loader.d.ts +17 -0
- package/dist/loader.js +27 -0
- package/dist/select-state.d.ts +34 -0
- package/dist/select-state.js +55 -0
- package/dist/select.css +87 -0
- package/dist/tokens.d.ts +17 -0
- package/dist/tokens.generated.css +97 -0
- package/dist/tokens.js +175 -0
- package/handbook/README.md +236 -0
- package/icons/add.png +0 -0
- package/icons/announce.png +0 -0
- package/icons/arrange.png +0 -0
- package/icons/bell.png +0 -0
- package/icons/block.png +0 -0
- package/icons/book.png +0 -0
- package/icons/bug.png +0 -0
- package/icons/camera.png +0 -0
- package/icons/cart-add.png +0 -0
- package/icons/cart.png +0 -0
- package/icons/chat.png +0 -0
- package/icons/check.png +0 -0
- package/icons/clean.png +0 -0
- package/icons/clock.png +0 -0
- package/icons/coin.png +0 -0
- package/icons/coral.png +0 -0
- package/icons/decorate.png +0 -0
- package/icons/download.png +0 -0
- package/icons/drop.png +0 -0
- package/icons/edit.png +0 -0
- package/icons/egg.png +0 -0
- package/icons/emote-food.png +0 -0
- package/icons/emote-heart.png +0 -0
- package/icons/emote-hello.png +0 -0
- package/icons/emote-music.png +0 -0
- package/icons/emote-question.png +0 -0
- package/icons/emote-sleep.png +0 -0
- package/icons/emote-sparkle.png +0 -0
- package/icons/emote-surprise.png +0 -0
- package/icons/exit.png +0 -0
- package/icons/explore.png +0 -0
- package/icons/external.png +0 -0
- package/icons/feed.png +0 -0
- package/icons/fish.png +0 -0
- package/icons/follow.png +0 -0
- package/icons/fullscreen.png +0 -0
- package/icons/gear.png +0 -0
- package/icons/gem.png +0 -0
- package/icons/gift.png +0 -0
- package/icons/globe.png +0 -0
- package/icons/growth.png +0 -0
- package/icons/heart.png +0 -0
- package/icons/help.png +0 -0
- package/icons/home.png +0 -0
- package/icons/idea.png +0 -0
- package/icons/inspect.png +0 -0
- package/icons/inventory.png +0 -0
- package/icons/keeper.png +0 -0
- package/icons/kelp.png +0 -0
- package/icons/keyboard.png +0 -0
- package/icons/level.png +0 -0
- package/icons/link.png +0 -0
- package/icons/lock.png +0 -0
- package/icons/mail.png +0 -0
- package/icons/map.png +0 -0
- package/icons/menu.png +0 -0
- package/icons/music.png +0 -0
- package/icons/mute.png +0 -0
- package/icons/net.png +0 -0
- package/icons/people.png +0 -0
- package/icons/pin.png +0 -0
- package/icons/quest.png +0 -0
- package/icons/quote.png +0 -0
- package/icons/remedy.png +0 -0
- package/icons/reply.png +0 -0
- package/icons/report.png +0 -0
- package/icons/search.png +0 -0
- package/icons/sell.png +0 -0
- package/icons/send.png +0 -0
- package/icons/share.png +0 -0
- package/icons/shell.png +0 -0
- package/icons/shop.png +0 -0
- package/icons/showcase.png +0 -0
- package/icons/smile.png +0 -0
- package/icons/solved.png +0 -0
- package/icons/sound.png +0 -0
- package/icons/staff.png +0 -0
- package/icons/star.png +0 -0
- package/icons/tank.png +0 -0
- package/icons/thermometer.png +0 -0
- package/icons/trash.png +0 -0
- package/icons/trophy.png +0 -0
- package/icons/updates.png +0 -0
- package/icons/upvote.png +0 -0
- package/icons/views.png +0 -0
- package/icons/warning.png +0 -0
- package/icons/wave.png +0 -0
- package/icons/whisper.png +0 -0
- package/package.json +66 -0
- package/src/FishiesImg.tsx +58 -0
- package/src/Select.tsx +119 -0
- package/src/css.d.ts +2 -0
- package/src/fx-image.ts +17 -0
- package/src/icons.generated.ts +658 -0
- package/src/icons.ts +74 -0
- package/src/index.ts +8 -0
- package/src/kit.css +267 -0
- package/src/kit.tsx +190 -0
- package/src/loader.css +48 -0
- package/src/loader.tsx +35 -0
- package/src/select-state.ts +59 -0
- package/src/select.css +87 -0
- package/src/tokens.generated.css +97 -0
- package/src/tokens.ts +196 -0
package/src/Select.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { SystemIcon } from './kit'
|
|
4
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
|
|
5
|
+
import { createPortal } from 'react-dom'
|
|
6
|
+
import { placePopup, selectedIndex, selectKey } from './select-state'
|
|
7
|
+
import './select.css'
|
|
8
|
+
|
|
9
|
+
export type SelectOption = { value: string; label: string; disabled?: boolean; icon?: ReactNode }
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
value: string
|
|
13
|
+
onChange: (value: string) => void
|
|
14
|
+
options: readonly SelectOption[]
|
|
15
|
+
'aria-label'?: string
|
|
16
|
+
'aria-labelledby'?: string
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
|
|
19
|
+
name?: string
|
|
20
|
+
className?: string
|
|
21
|
+
size?: 'regular' | 'compact'
|
|
22
|
+
autoFocus?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TOKENS = ['--fs-select-ink', '--fs-select-muted', '--fs-select-line', '--fs-select-panel', '--fs-select-hover', '--fs-select-accent', '--fs-select-radius', '--fs-select-shadow', '--fs-select-font']
|
|
26
|
+
|
|
27
|
+
export default function Select({ value, onChange, options, disabled, name, className, size = 'regular', autoFocus, ...aria }: Props) {
|
|
28
|
+
const id = useId()
|
|
29
|
+
const trigger = useRef<HTMLButtonElement>(null), list = useRef<HTMLUListElement>(null)
|
|
30
|
+
const [open, setOpen] = useState(false)
|
|
31
|
+
const [active, setActive] = useState(-1)
|
|
32
|
+
const [style, setStyle] = useState<CSSProperties>({})
|
|
33
|
+
const [up, setUp] = useState(false)
|
|
34
|
+
const current = options.find(option => option.value === value)
|
|
35
|
+
|
|
36
|
+
const place = useCallback(() => {
|
|
37
|
+
const button = trigger.current
|
|
38
|
+
if (!button) return
|
|
39
|
+
const rect = button.getBoundingClientRect()
|
|
40
|
+
const spot = placePopup(rect, { width: window.innerWidth, height: window.innerHeight }, 320)
|
|
41
|
+
const computed = getComputedStyle(button)
|
|
42
|
+
const tokens: Record<string, string> = {}
|
|
43
|
+
for (const token of TOKENS) { const resolved = computed.getPropertyValue(token).trim(); if (resolved) tokens[token] = resolved }
|
|
44
|
+
setUp(spot.up)
|
|
45
|
+
setStyle({ ...tokens, fontFamily: computed.fontFamily, left: spot.left, minWidth: spot.width, maxWidth: Math.min(360, window.innerWidth - 16), top: spot.top, bottom: spot.bottom, maxHeight: spot.maxHeight } as CSSProperties)
|
|
46
|
+
}, [])
|
|
47
|
+
|
|
48
|
+
const show = (index: number) => { place(); setActive(index); setOpen(true) }
|
|
49
|
+
const close = (focus: boolean) => { setOpen(false); if (focus) trigger.current?.focus() }
|
|
50
|
+
const choose = (next: string) => { if (next !== value) onChange(next) }
|
|
51
|
+
|
|
52
|
+
useLayoutEffect(() => { if (open) place() }, [open, place])
|
|
53
|
+
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const popup = list.current
|
|
56
|
+
if (!popup || typeof style.left !== 'number') return
|
|
57
|
+
const left = Math.max(8, Math.min(style.left, window.innerWidth - 8 - popup.offsetWidth))
|
|
58
|
+
if (left !== style.left) setStyle(previous => ({ ...previous, left }))
|
|
59
|
+
}, [style])
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) return
|
|
62
|
+
const outside = (event: PointerEvent) => {
|
|
63
|
+
const target = event.target as Node
|
|
64
|
+
if (!trigger.current?.contains(target) && !list.current?.contains(target)) setOpen(false)
|
|
65
|
+
}
|
|
66
|
+
window.addEventListener('pointerdown', outside, true)
|
|
67
|
+
window.addEventListener('resize', place)
|
|
68
|
+
const scrolled = (event: Event) => { if (!list.current?.contains(event.target as Node)) place() }
|
|
69
|
+
window.addEventListener('scroll', scrolled, true)
|
|
70
|
+
return () => { window.removeEventListener('pointerdown', outside, true); window.removeEventListener('resize', place); window.removeEventListener('scroll', scrolled, true) }
|
|
71
|
+
}, [open, place])
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (open && active >= 0) list.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: 'nearest' })
|
|
74
|
+
}, [open, active])
|
|
75
|
+
useEffect(() => { if (disabled) setOpen(false) }, [disabled])
|
|
76
|
+
|
|
77
|
+
const keydown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
78
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return
|
|
79
|
+
const result = selectKey({ open, active }, event.key, options, value)
|
|
80
|
+
if (!result.handled && result.close !== 'blur') return
|
|
81
|
+
if (result.handled) { event.preventDefault(); event.stopPropagation() }
|
|
82
|
+
if (result.open && !open) { show(result.active); return }
|
|
83
|
+
setActive(result.active)
|
|
84
|
+
if (!result.open) close(result.close === 'focus')
|
|
85
|
+
if (result.choose !== undefined) choose(result.choose)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const labelId = `${id}-label`, valueId = `${id}-value`, listId = `${id}-list`
|
|
89
|
+
const labelledby = aria['aria-labelledby'] ?? (aria['aria-label'] ? labelId : undefined)
|
|
90
|
+
const classes = ['fs-select', size === 'compact' ? 'fs-select-compact' : '', open ? 'is-open' : '', className ?? ''].filter(Boolean).join(' ')
|
|
91
|
+
|
|
92
|
+
return <>
|
|
93
|
+
{aria['aria-label'] && !aria['aria-labelledby'] && <span id={labelId} hidden>{aria['aria-label']}</span>}
|
|
94
|
+
<button ref={trigger} type="button" className={classes} disabled={disabled} autoFocus={autoFocus}
|
|
95
|
+
aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? listId : undefined}
|
|
96
|
+
aria-labelledby={labelledby ? `${labelledby} ${valueId}` : undefined}
|
|
97
|
+
aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined}
|
|
98
|
+
onClick={() => open ? close(false) : show(selectedIndex(options, value))} onKeyDown={keydown}>
|
|
99
|
+
{current?.icon && <span className="fs-select-icon" aria-hidden="true">{current.icon}</span>}
|
|
100
|
+
<span id={valueId} className="fs-select-value">{current?.label ?? ''}</span>
|
|
101
|
+
<i aria-hidden="true">▾</i>
|
|
102
|
+
</button>
|
|
103
|
+
{name && <input type="hidden" name={name} value={value} />}
|
|
104
|
+
{open && typeof document !== 'undefined' && createPortal(
|
|
105
|
+
<ul ref={list} id={listId} role="listbox" aria-labelledby={labelledby} tabIndex={-1}
|
|
106
|
+
className={`fs-select-popover${up ? ' is-up' : ''}${size === 'compact' ? ' fs-select-compact' : ''}`} style={style}
|
|
107
|
+
onPointerDown={event => event.preventDefault()}>
|
|
108
|
+
{options.map((option, index) => <li key={option.value} id={`${id}-option-${index}`} data-index={index} role="option"
|
|
109
|
+
aria-selected={option.value === value} aria-disabled={option.disabled || undefined}
|
|
110
|
+
className={index === active ? 'is-active' : undefined}
|
|
111
|
+
onPointerEnter={() => { if (!option.disabled) setActive(index) }}
|
|
112
|
+
onClick={() => { if (option.disabled) return; close(true); choose(option.value) }}>
|
|
113
|
+
{option.icon && <span className="fs-select-icon" aria-hidden="true">{option.icon}</span>}
|
|
114
|
+
<span>{option.label}</span>
|
|
115
|
+
{option.value === value && <b aria-hidden="true"><SystemIcon name="check" size={16}/></b>}
|
|
116
|
+
</li>)}
|
|
117
|
+
</ul>, document.body)}
|
|
118
|
+
</>
|
|
119
|
+
}
|
package/src/css.d.ts
ADDED
package/src/fx-image.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type FxState = 'wait' | 'fade' | 'ready' | 'error'
|
|
2
|
+
|
|
3
|
+
export function reveal(img: HTMLImageElement, instant?: boolean): void {
|
|
4
|
+
if (img.getAttribute('data-fx') !== 'wait' || !img.complete) return
|
|
5
|
+
const src = img.currentSrc || img.src
|
|
6
|
+
const set = (state: string) => {
|
|
7
|
+
if (img.getAttribute('data-fx') === 'wait' && (img.currentSrc || img.src) === src) img.setAttribute('data-fx', state)
|
|
8
|
+
}
|
|
9
|
+
if (!img.naturalWidth) { set('error'); return }
|
|
10
|
+
if (instant) { set('ready'); return }
|
|
11
|
+
if (typeof img.decode !== 'function') { set('fade'); return }
|
|
12
|
+
img.decode().then(() => set('fade'), () => set(img.naturalWidth ? 'fade' : 'error'))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const FX_IMAGE_SCRIPT = `(function(){var r=${reveal.toString()};` +
|
|
16
|
+
`function h(e){var t=e.target;if(t&&t.tagName==='IMG'&&t.hasAttribute('data-fx'))r(t)}` +
|
|
17
|
+
`document.addEventListener('load',h,true);document.addEventListener('error',h,true)})()`
|