diff --git a/.browserlist b/.browserlist deleted file mode 100644 index b547231..0000000 --- a/.browserlist +++ /dev/null @@ -1 +0,0 @@ ->0.3% and not dead, firefox>=98, safari>=15.4, chrome>=84 \ No newline at end of file diff --git a/README.md b/README.md index 0bb7288..a4c432a 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,17 @@ Tutu is a comfortable experience for tooting. Designed to work on any device - d The code is built against those targets and Tutu must run on those platforms: -| Firefox | Safari | iOS | Chrome | Edge | -| ------- | ------ | ----- | ------ | ---- | -| 98 | 15.4 | 15.4 | 84 | 87 | +| Firefox | Safari | iOS | Chrome & Edge | +| ------- | ------ | ----- | ------------- | +| 115 | 15.6 | 15.6 | 108 | Tutu trys to push the Web technology to its limit. Some features might not be available on the platform does not meet the requirement. -## The "Next" Branch +## The "Nightly" Branch -The "next" branch of the app is built on every commit pushed into "master". You can tatse latest change but risks your data. +Tutu built on the latest code, called the nightly version. You can tatse latest change but risks your data. -[Launch Tutu (Next)](https://master.tututheapp.pages.dev) +[Launch Tutu (Nightly)](https://master.tututheapp.pages.dev) ## Build & Depoly diff --git a/bun.lockb b/bun.lockb index bec25b1..cd4221a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/devnotes.md b/docs/devnotes.md index 577c968..6e10652 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -84,3 +84,64 @@ But, sometimes you need a redesigned (sometimes better) tool for the generic usa - *What* this new tool does? - *How* this tool works? - Clean up code regularly. Don't keep the unused code forever. + +## Managing CSS + +Two techniques are still: + +- Styled compoenent (solid-styled) +- Native CSS with CSS layering + +The second is recommended for massive use. A stylesheet for a component can be placed alongside +the component's file. The stylesheet must use the same name as the component's file name, but replace the extension with +`.css`. Say there is a component file "PreviewCard.tsx", the corresponding stylesheet is "PreviewCard.css". They are imported +by the component file, so the side effect will be applied by the bundler. + +The speicifc component uses a root class to scope the rulesets' scope. This convention allows the component's style can be influenced +by the other stylesheets. It works because Tutu is an end-user application, we gain the control of all stylesheets in the app (kind of). +Keep in mind that the native stylesheets will be applied globally at any time, you must carefully craft the stylesheet to avoid leaking +of style. + +Three additional CSS layers are declared as: + +- compat: Compatibility rules, like normalize.css +- theme: The theme rules +- material: The internal material styles + +When working on the material package, if the style is intended to work with the user styles, +it must be declared under the material layer. Otherwise the unlayer, which has the +highest priority in the author's, can be used. + +Styled component is still existing. Though styled component, using attributes for scoping, +may not be as performant as the techniques with CSS class names; +it's still provided in the code infrastructure for its ease. + +The following is an example of the recommended usage of solid-styled: + +```tsx +// An example of using solid-styled +import { css } from "solid-styled"; +import { createSignal } from "solid-js"; + +const Component = () => { + const [width, setWidth] = createSignal(100); + + css` + .root { + width: ${width()}%; + } + ` + return
+}; +``` + +When developing new component, you can use styled component at first, and migrate +to native css slowly. + +Before v2.0.0, there are CSS modules in use, but they are removed: + +- Duplicated loads +- Unaware of order (failed composing) +- Not-ready for hot reload + +In short, CSS module does not works well if the stylesheet will be accessed from more than one component. diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 0000000..4d50356 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,32 @@ +# Versioning & Development Cycle + +The versioning policy follows the [Semantic Versioning](https://semver.org/). +Since Tutu is an app for the end user, we redefine the some words in the policy: + +- API changes: the app is no longer available on certain platforms. + +## Development Cycle + +Dependency Freeze -> Development -> Release + +### Dependency Freeze + +This step is for: + +- Update dependencies +- Prepare the new version (like, bump the version number). + +New dependencies should not be added in this step. + +### Development + +In this step, dependencies can only be updated if it's required to fix bugs. + +New dependencies should be added as their use, in this step. + +### Release + +The version is released to production in this step. + +Before the next development step, new versions can still be released to +fix bugs. diff --git a/package.json b/package.json index 6222ecd..69b71d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package", "name": "tutu", - "version": "1.1.0", + "version": "2.0.0", "description": "", "private": true, "type": "module", @@ -10,30 +10,40 @@ "preview": "vite preview", "dist": "vite build", "count-source-lines": "exec scripts/src-lc.sh", - "typecheck": "tsc --noEmit --skipLibCheck" + "typecheck": "tsc --noEmit --skipLibCheck", + "wdio": "wdio run ./wdio.conf.ts" }, "keywords": [], "author": "Rubicon", "license": "Apache-2.0", "devDependencies": { - "@solid-devtools/overlay": "^0.30.1", + "@solid-devtools/overlay": "^0.33.0", "@suid/vite-plugin": "^0.3.1", + "@testing-library/webdriverio": "^3.2.1", "@types/hammerjs": "^2.0.46", "@types/masonry-layout": "^4.2.8", "@vite-pwa/assets-generator": "^0.2.6", + "@wdio/cli": "^9.5.1", + "@wdio/lighthouse-service": "^9.5.1", + "@wdio/local-runner": "^9.5.1", + "@wdio/mocha-framework": "^9.5.0", + "@wdio/spec-reporter": "^9.5.0", "postcss": "^8.4.49", - "prettier": "^3.3.3", - "typescript": "^5.6.3", - "vite": "^5.4.11", + "prettier": "^3.4.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^6.0.7", "vite-plugin-package-version": "^1.1.0", - "vite-plugin-pwa": "^0.20.5", - "vite-plugin-solid": "^2.10.2", + "vite-plugin-pwa": "^0.21.1", + "vite-plugin-solid": "^2.11.0", "vite-plugin-solid-styled": "^0.11.1", + "wdio-vite-service": "^2.0.0", + "wdio-wait-for": "^3.0.11", "workbox-build": "^7.3.0", - "wrangler": "^3.86.1" + "wrangler": "^3.99.0" }, "dependencies": { - "@formatjs/intl-localematcher": "^0.5.7", + "@formatjs/intl-localematcher": "^0.5.10", "@nanostores/persistent": "^0.10.2", "@nanostores/solid": "^0.5.0", "@solid-primitives/event-listener": "^2.3.3", @@ -42,8 +52,8 @@ "@solid-primitives/map": "^0.4.13", "@solid-primitives/page-visibility": "^2.0.17", "@solid-primitives/resize-observer": "^2.0.26", + "@solidjs/router": "^0.15.2", "@solid-primitives/rootless": "^1.4.5", - "@solidjs/router": "^0.15.1", "@suid/icons-material": "^0.8.1", "@suid/material": "^0.18.0", "blurhash": "^2.0.5", @@ -56,9 +66,10 @@ "masto": "^6.10.1", "nanostores": "^0.11.3", "normalize.css": "^8.0.1", - "solid-devtools": "^0.30.1", + "solid-devtools": "^0.33.0", "solid-js": "^1.9.3", "solid-styled": "^0.11.1", + "solid-transition-group": "^0.2.3", "stacktrace-js": "^2.0.2", "workbox-core": "^7.3.0", "workbox-precaching": "^7.3.0" diff --git a/src/App.css b/src/App.css index 70ee2fb..d2643a9 100644 --- a/src/App.css +++ b/src/App.css @@ -1,41 +1,46 @@ -@import "normalize.css/normalize.css"; -@import "./material/theme.css"; +@layer compat, theme, material; -:root { - --safe-area-inset-top: env(safe-area-inset-top); - --safe-area-inset-left: env(safe-area-inset-left); - --safe-area-inset-bottom: env(safe-area-inset-bottom); - --safe-area-inset-right: env(safe-area-inset-right); - background-color: var(--tutu-color-surface, transparent); -} +@import "normalize.css/normalize.css" layer(compat); +@import "./material/theme.css" layer(theme); +@import "./material/material.css" layer(material); -/* -Fix the bottom gap on iOS standalone. -https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-100-height-gap-on-bottom -*/ -@media screen and (display-mode: standalone) { - body { - width: 100%; - height: 100vh; +@layer compat { + :root { + --safe-area-inset-top: env(safe-area-inset-top); + --safe-area-inset-left: env(safe-area-inset-left); + --safe-area-inset-bottom: env(safe-area-inset-bottom); + --safe-area-inset-right: env(safe-area-inset-right); + background-color: var(--tutu-color-surface, transparent); } - #root { - position: fixed; - top: 0; - left: 0; - height: 100vh; - width: 100vw; + /* + Fix the bottom gap on iOS standalone. + https://stackoverflow.com/questions/66005655/pwa-ios-child-of-body-not-taking-100-height-gap-on-bottom + */ + @media screen and (display-mode: standalone) { + body { + width: 100%; + height: 100vh; + } + + #root { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + } + } + + h1 { + margin: 0; + } + + * { + user-select: none; } } .custom-emoji { width: 1em; -} - -h1 { - margin: 0; -} - -* { - user-select: none; -} +} \ No newline at end of file diff --git a/src/accounts/MastodonOAuth2Callback.css b/src/accounts/MastodonOAuth2Callback.css new file mode 100644 index 0000000..c150d03 --- /dev/null +++ b/src/accounts/MastodonOAuth2Callback.css @@ -0,0 +1,22 @@ +.MastodonOAuth2Callback { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 448px; + + @media (max-width: 600px) { + & { + position: static; + height: 100%; + width: 100%; + left: 0; + right: 0; + transform: none; + display: grid; + grid-template-rows: 1fr auto; + height: 100vh; + overflow: auto; + } + } +} \ No newline at end of file diff --git a/src/accounts/MastodonOAuth2Callback.tsx b/src/accounts/MastodonOAuth2Callback.tsx index 398d79d..d530095 100644 --- a/src/accounts/MastodonOAuth2Callback.tsx +++ b/src/accounts/MastodonOAuth2Callback.tsx @@ -8,13 +8,13 @@ import { } from "solid-js"; import { acceptAccountViaAuthCode } from "./stores"; import { $settings } from "../settings/stores"; -import { useDocumentTitle } from "../utils"; -import cards from "~material/cards.module.css"; +import "~material/cards.css"; import { LinearProgress } from "@suid/material"; import Img from "~material/Img"; import { createRestAPIClient } from "masto"; import { Title } from "~material/typography"; import { useNavigator } from "~platform/StackedRouter"; +import DocumentTitle from "~platform/DocumentTitle"; type OAuth2CallbackParams = { code?: string; @@ -27,13 +27,12 @@ const MastodonOAuth2Callback: Component = () => { const titleId = createUniqueId(); const [params] = useSearchParams(); const { push: navigate } = useNavigator(); - const setDocumentTitle = useDocumentTitle("Back from Mastodon..."); const [siteImg, setSiteImg] = createSignal<{ src: string; srcset?: string; blurhash: string; }>(); - const [siteTitle, setSiteTitle] = createSignal("the Mastodon server"); + const [siteTitle, setSiteTitle] = createSignal("Mastodon"); onMount(async () => { const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process; @@ -42,7 +41,6 @@ const MastodonOAuth2Callback: Component = () => { url: onGoingOAuth2Process, }); const ins = await client.v2.instance.fetch(); - setDocumentTitle(`Back from ${ins.title}...`); setSiteTitle(ins.title); const srcset = []; @@ -93,42 +91,45 @@ const MastodonOAuth2Callback: Component = () => { }); }); return ( -
-
- - - } - > - {`Banner + Back from {siteTitle()} +
+
+ - + + } + > + {`Banner + - - Contracting {siteTitle}... - -

- If this page stays too long, you can close this page and sign in - again. -

-
-
+ + Contracting {siteTitle}... + +

+ If this page stays too long, you can close this page and sign in + again. +

+
+ + ); }; diff --git a/src/accounts/SignIn.css b/src/accounts/SignIn.css new file mode 100644 index 0000000..11f467a --- /dev/null +++ b/src/accounts/SignIn.css @@ -0,0 +1,27 @@ +.SignIn { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 448px; + + @media (max-width: 600px) { + & { + position: static; + height: 100vh; + width: 100%; + transform: none; + overflow: auto; + } + } + + >.key-content { + height: 100%; + + >form { + display: flex; + flex-flow: column; + gap: 16px; + } + } +} \ No newline at end of file diff --git a/src/accounts/SignIn.tsx b/src/accounts/SignIn.tsx index c8699d2..3a6233d 100644 --- a/src/accounts/SignIn.tsx +++ b/src/accounts/SignIn.tsx @@ -2,22 +2,21 @@ import { Component, Show, createEffect, - createSelector, createSignal, createUniqueId, onMount, } from "solid-js"; -import cards from "~material/cards.module.css"; +import "~material/cards.css"; import TextField from "~material/TextField.js"; import Button from "~material/Button.js"; -import { useDocumentTitle } from "../utils"; import { Title } from "~material/typography"; -import { css } from "solid-styled"; import { LinearProgress } from "@suid/material"; import { createRestAPIClient } from "masto"; import { getOrRegisterApp } from "./stores"; import { useSearchParams } from "@solidjs/router"; import { $settings } from "../settings/stores"; +import "./SignIn.css"; +import DocumentTitle from "~platform/DocumentTitle"; type ErrorParams = { error: string; @@ -35,15 +34,6 @@ const SignIn: Component = () => { const [serverUrlError, setServerUrlError] = createSignal(false); const [targetSiteTitle, setTargetSiteTitle] = createSignal(""); - useDocumentTitle("Sign In"); - css` - form { - display: flex; - flex-flow: column; - gap: 16px; - } - `; - const serverUrl = () => { const url = rawServerUrl(); if (url.length === 0 || /^%w:/.test(url)) { @@ -121,55 +111,58 @@ const SignIn: Component = () => { }; return ( -
- -
-

Authorization is failed.

-

{params.errorDescription}

-

- Please try again later. If the problem persist, you can seek for - help from the server administrator. -

-
-
-
- -
- Sign in with Your Mastodon Account - - -
- + <> + Sign In +
+ +
+

Authorization is failed.

+

{params.errorDescription}

+

+ Please try again later. If the problem persists, you can ask for + help from the server administrator. +

- -
-
+ +
+ +
+ Sign in with Your Mastodon Account + + +
+ +
+ +
+ + ); }; diff --git a/src/accounts/stores.ts b/src/accounts/stores.ts index c44a012..006ea9e 100644 --- a/src/accounts/stores.ts +++ b/src/accounts/stores.ts @@ -6,10 +6,19 @@ import { } from "masto"; import { createMastoClientFor } from "../masto/clients"; -export type Account = { +export type RemoteServer = { site: string; - accessToken: string; +}; +export type AccountKey = RemoteServer & { + accessToken: string; +}; + +export function isAccountKey(object: RemoteServer): object is AccountKey { + return !!(object as Record)["accessToken"]; +} + +export type Account = AccountKey & { tokenType: string; scope: string; createdAt: number; @@ -17,6 +26,10 @@ export type Account = { inf?: mastodon.v1.AccountCredentials; }; +export function isAccount(object: RemoteServer) { + return isAccountKey(object) && !!(object as Record)["tokenType"]; +} + export const $accounts = persistentAtom("accounts", [], { encode: JSON.stringify, decode: JSON.parse, diff --git a/src/masto/base.ts b/src/masto/base.ts new file mode 100644 index 0000000..79d9755 --- /dev/null +++ b/src/masto/base.ts @@ -0,0 +1,84 @@ +import { createCacheBucket } from "~platform/cache"; +import { MastoHttpError, MastoTimeoutError, MastoUnexpectedError } from "masto"; + +export const CACHE_BUCKET_NAME = "mastodon"; + +export const cacheBucket = /* @__PURE__ */ createCacheBucket(CACHE_BUCKET_NAME); + +export function toSmallCamelCase(object: T): T { + if (!object || typeof object !== "object") { + return object; + } else if (Array.isArray(object)) { + return object.map(toSmallCamelCase) as T; + } + + const result = {} as Record; + for (const k in object) { + const value = toSmallCamelCase(object[k]); + const nk = + typeof k === "string" + ? k.replace(/_(.)/g, (_, match) => match.toUpperCase()) + : k; + result[nk] = value; + } + + return result as T; +} + +function contentTypeOf(headers: Headers) { + const raw = headers.get("Content-Type")?.replace(/\s*;.*$/, ""); + if (!raw) { + return; + } + + return raw; +} + +/** + * Wrpas the error reason into masto's errors: + * {@link MastoHttpError} if the reason is a {@link Response}, + * {@link MastoTimeoutError} if the reason is a timeout error. + * + * @throws If the reason is unexpected, {@link MastoUnexpectedError} will be thrown. + */ +export async function wrapsError(reason: Response): Promise; +export async function wrapsError(reason: { + name: "TimeoutError"; +}): Promise; +export async function wrapsError(reason: T): Promise; + +export async function wrapsError(reason: unknown) { + if (reason instanceof Response) { + const contentType = contentTypeOf(reason.headers); + if (!contentType) { + throw new MastoUnexpectedError( + "The server returned data with an unknown encoding. The server may be down", + ); + } + + const data = await reason.json(); + const { + error: message, + errorDescription, + details, + ...additionalProperties + } = data; + + return new MastoHttpError( + { + statusCode: reason.status, + message, + description: errorDescription, + details, + additionalProperties, + }, + { cause: reason }, + ); + } + + if (reason && (reason as { name?: string }).name === "TimeoutError") { + return new MastoTimeoutError("Request timed out", { cause: reason }); + } + + return reason; +} diff --git a/src/masto/clients.ts b/src/masto/clients.ts index f853fa7..7cd22e7 100644 --- a/src/masto/clients.ts +++ b/src/masto/clients.ts @@ -4,9 +4,10 @@ import { createMemo, createRenderEffect, createResource, + untrack, useContext, } from "solid-js"; -import { Account } from "../accounts/stores"; +import { Account, type RemoteServer } from "../accounts/stores"; import { createRestAPIClient, mastodon } from "masto"; import { useLocation } from "@solidjs/router"; import { useNavigator } from "~platform/StackedRouter"; @@ -57,15 +58,15 @@ export const Provider = Context.Provider; export function useSessions() { const sessions = useSessionsRaw(); - const {push} = useNavigator(); + const { push } = useNavigator(); const location = useLocation(); createRenderEffect(() => { - if (sessions().length > 0) return; - push( - "/accounts/sign-in?back=" + encodeURIComponent(location.pathname), - { replace: "all" }, - ); + if (untrack(() => sessions().length) > 0) return; + + push("/accounts/sign-in?back=" + encodeURIComponent(location.pathname), { + replace: true, + }); }); return sessions; @@ -114,7 +115,7 @@ export function useDefaultSession() { * - If the username is not present, any session on the site is returned; or, * - If no available session available for the pattern, an unauthorised session is returned. * - * In an unauthorised session, the `.account` is `undefined` and the `client` is an + * In an unauthorised session, the `.account` is {@link RemoteServer} and the `client` is an * unauthorised client for the site. This client may not available for some operations. */ export function useSessionForAcctStr(acct: Accessor) { @@ -130,8 +131,8 @@ export function useSessionForAcctStr(acct: Accessor) { return ( authedSession ?? { client: createUnauthorizedClient(inputSite), - account: undefined, - } + account: { site: inputSite } as RemoteServer, // TODO: we need some security checks here? + } as const ); }); } diff --git a/src/masto/statuses.ts b/src/masto/statuses.ts new file mode 100644 index 0000000..4da437e --- /dev/null +++ b/src/masto/statuses.ts @@ -0,0 +1,32 @@ +import { CachedFetch } from "~platform/cache"; +import { cacheBucket, toSmallCamelCase, wrapsError } from "./base"; +import { isAccountKey, type RemoteServer } from "../accounts/stores"; +import { type mastodon } from "masto"; + +export const fetchStatus = /* @__PURE__ */ new CachedFetch( + cacheBucket, + (session: RemoteServer, id: string) => { + const headers = new Headers({ + Accept: "application/json", + }); + if (isAccountKey(session)) { + headers.set("Authorization", `Bearer ${session.accessToken}`); + } + return { + url: new URL(`./api/v1/statuses/${id}`, session.site).toString(), + headers, + }; + }, + async (response) => { + try { + if (!response.ok) { + throw response; + } + return toSmallCamelCase( + await response.json(), + ) as unknown as mastodon.v1.Status; + } catch (reason) { + throw wrapsError(reason); + } + }, +); diff --git a/src/masto/timelines.ts b/src/masto/timelines.ts index 19abea7..0f04e83 100644 --- a/src/masto/timelines.ts +++ b/src/masto/timelines.ts @@ -248,6 +248,64 @@ export type TimelineResource = [ { refetch(info?: TimelineFetchDirection): void }, ]; +export const emptyTimeline = { + list() { + return emptyTimeline; + }, + + setDirection() { + return emptyTimeline; + }, + + async next(): Promise> { + return { + value: undefined, + done: true, + }; + }, + + getDirection(): TimelineFetchDirection { + return "next"; + }, + + clone() { + return emptyTimeline; + }, + + async return(): Promise> { + return { + value: undefined, + done: true, + }; + }, + + async throw(e?: unknown) { + throw e; + }, + + async *values() {}, + async *[Symbol.asyncIterator](): AsyncIterator { + return undefined; + }, + + async then( + onresolve?: null | ((value: any[]) => TNext | PromiseLike), + onrejected?: null | ((reason: unknown) => ENext | PromiseLike), + ) { + try { + if (!onresolve) { + throw new TypeError("no onresolve"); + } + return await onresolve([]); + } catch (reason) { + if (!onrejected) { + throw reason; + } + return await onrejected(reason); + } + }, +}; + /** * Create auto managed timeline controls. * diff --git a/src/material/AppTopBar.css b/src/material/AppTopBar.css index de3426d..d0976ca 100644 --- a/src/material/AppTopBar.css +++ b/src/material/AppTopBar.css @@ -1,6 +1,27 @@ .AppTopBar { - & > .toolbar { + &::before { + contain: strict; + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--safe-area-inset-top, 0); + background-color: rgba(0, 0, 0, 0.2); + } + + &>.MuiToolbar-root { padding-top: var(--safe-area-inset-top, 0px); gap: 8px; + + &>button:first-child, + &>.MuiButtonBase-root:first-child { + margin-left: -0.15em; + } + + &>button:last-child, + &>.MuiButtonBase-root:last-child { + margin-right: -0.15em; + } } -} +} \ No newline at end of file diff --git a/src/material/AppTopBar.tsx b/src/material/AppTopBar.tsx index a9ad382..a75c7c3 100644 --- a/src/material/AppTopBar.tsx +++ b/src/material/AppTopBar.tsx @@ -20,7 +20,6 @@ const AppTopBar: ParentComponent<{ > windowSize.height ? "dense" : "regular"} - class="toolbar" sx={{ paddingTop: "var(--safe-area-inset-top, 0px)" }} > {props.children} diff --git a/src/material/BottomSheet.tsx b/src/material/BottomSheet.tsx index 99bfbd0..843f245 100644 --- a/src/material/BottomSheet.tsx +++ b/src/material/BottomSheet.tsx @@ -7,7 +7,6 @@ import { type ParentComponent, } from "solid-js"; import "./BottomSheet.css"; -import material from "./material.module.css"; import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme"; import { animateSlideInFromRight, @@ -51,7 +50,7 @@ function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) { } const BottomSheet: ParentComponent = (props) => { - let element: HTMLDialogElement; + let element!: HTMLDialogElement; let animation: Animation | undefined; const child = children(() => props.children); @@ -134,7 +133,7 @@ const BottomSheet: ParentComponent = (props) => { return ( > = ( props, ) => { - const [managed, passthough] = splitProps(props, ["class", "type"]); + const [managed, passthough] = splitProps(props, [ "type"]); const type = () => managed.type ?? "button"; return ( ); diff --git a/src/material/Img.tsx b/src/material/Img.tsx index 774460c..8cb33c9 100644 --- a/src/material/Img.tsx +++ b/src/material/Img.tsx @@ -3,14 +3,12 @@ import { splitProps, Component, createSignal, - createEffect, onMount, createRenderEffect, Show, } from "solid-js"; import { css } from "solid-styled"; import { decode } from "blurhash"; -import { mergeClass } from "../utils"; type ImgProps = { blurhash?: string; @@ -24,6 +22,7 @@ const Img: Component = (props) => { "blurhash", "keepBlur", "class", + "classList", "style", ]); const [isImgLoaded, setIsImgLoaded] = createSignal(false); @@ -61,21 +60,21 @@ const Img: Component = (props) => { const onImgLoaded = () => { setIsImgLoaded(true); setImgSize({ - width: imgE.width, - height: imgE.height, + width: imgE!.width, + height: imgE!.height, }); }; const onMetadataLoaded = () => { setImgSize({ - width: imgE.width, - height: imgE.height, + width: imgE!.width, + height: imgE!.height, }); }; onMount(() => { setImgSize((x) => { - const parent = imgE.parentElement; + const parent = imgE!.parentElement; if (!parent) return x; return x ? x @@ -87,7 +86,14 @@ const Img: Component = (props) => { }); return ( -
+
{ diff --git a/src/material/Menu.tsx b/src/material/Menu.tsx index 931b756..3bc2ab0 100644 --- a/src/material/Menu.tsx +++ b/src/material/Menu.tsx @@ -108,7 +108,7 @@ function animateGrowFromTopLeft( * - Use {@link MenuItem} from SUID as children. */ const Menu: Component = (oprops) => { - let root: HTMLDialogElement; + let root!: HTMLDialogElement; const windowSize = useWindowSize(); const [props, rest] = splitProps(oprops, [ "open", diff --git a/src/material/Tab.css b/src/material/Tab.css new file mode 100644 index 0000000..38f49d3 --- /dev/null +++ b/src/material/Tab.css @@ -0,0 +1,32 @@ +.Tab { + cursor: pointer; + background: none; + border: none; + height: 100%; + max-width: min(calc(100% - 56px), 264px); + padding: 10px 24px; + font-size: 0.8135rem; + font-weight: 600; + text-transform: uppercase; + transition: color 120ms var(--tutu-anim-curve-std); + + :root:where([lang^="zh"], + [lang="zh"], + [lang^="kr"], + [lang="kr"], + [lang^="ja"], + [lang="ja"]) & { + font-size: 0.85rem; + } +} + +.MuiToolbar-root .Tab { + color: rgba(255, 255, 255, 0.7); + + &:hover, + &:focus, + &.focus, + &.Tabs-focus { + color: white; + } +} \ No newline at end of file diff --git a/src/material/Tab.tsx b/src/material/Tab.tsx index 2145b89..10d781a 100644 --- a/src/material/Tab.tsx +++ b/src/material/Tab.tsx @@ -1,26 +1,18 @@ import { - Component, createEffect, splitProps, type JSX, type ParentComponent, } from "solid-js"; -import { css } from "solid-styled"; import { useTabListContext } from "./Tabs"; +import "./Tab.css"; const Tab: ParentComponent< { focus?: boolean; - large?: boolean; } & JSX.ButtonHTMLAttributes > = (props) => { - const [managed, rest] = splitProps(props, [ - "focus", - "large", - "type", - "role", - "ref", - ]); + const [managed, rest] = splitProps(props, ["focus", "type", "role", "ref"]); let self: HTMLButtonElement; const { focusOn: [, setFocusOn], @@ -35,32 +27,7 @@ const Tab: ParentComponent< } return managed.focus; }); - css` - .tab { - cursor: pointer; - background: none; - border: none; - min-width: ${managed.large ? "160px" : "72px"}; - height: 48px; - max-width: min(calc(100% - 56px), 264px); - padding: 10px 24px; - font-size: 0.8135rem; - font-weight: 600; - text-transform: uppercase; - transition: color 120ms var(--tutu-anim-curve-std); - } - :global(.MuiToolbar-root) .tab { - color: rgba(255, 255, 255, 0.7); - - &:hover, - &:focus, - &.focus, - &:global(.tablist-focus) { - color: white; - } - } - `; return (